@thebattlearena/corner 0.9.1-beta.70 → 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.
Files changed (58) hide show
  1. package/README.md +46 -26
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/ring/agents/cornerman.md +32 -6
  5. package/ring/agents/rudo.md +2 -1
  6. package/ring/agents/wrestler.md +19 -4
  7. package/ring/hooks/hooks.json +42 -0
  8. package/ring/scripts/bout-stats-render.mjs +366 -0
  9. package/ring/scripts/bout-stats.mjs +360 -0
  10. package/ring/scripts/brief-build.mjs +130 -0
  11. package/ring/scripts/brief-guard.mjs +154 -0
  12. package/ring/scripts/brief.mjs +272 -0
  13. package/ring/scripts/compact-hook.mjs +110 -0
  14. package/ring/scripts/config.mjs +25 -1
  15. package/ring/scripts/contract-endpoints.mjs +163 -0
  16. package/ring/scripts/contract-preview.mjs +34 -14
  17. package/ring/scripts/fight.mjs +27 -0
  18. package/ring/scripts/findings.mjs +82 -3
  19. package/ring/scripts/format-gate.mjs +723 -0
  20. package/ring/scripts/format-worker.mjs +120 -0
  21. package/ring/scripts/front-routes.mjs +345 -0
  22. package/ring/scripts/judges-card.mjs +324 -3
  23. package/ring/scripts/lookout.mjs +22 -4
  24. package/ring/scripts/mask-guard.mjs +34 -1
  25. package/ring/scripts/page-embed.mjs +112 -0
  26. package/ring/scripts/play-by-play.mjs +155 -7
  27. package/ring/scripts/preparation-collect.mjs +177 -0
  28. package/ring/scripts/preparation.mjs +547 -0
  29. package/ring/scripts/round-bell.mjs +9 -2
  30. package/ring/scripts/rule-ids.mjs +124 -0
  31. package/ring/scripts/tech-plan-log.mjs +81 -0
  32. package/ring/scripts/tech-plan.mjs +191 -0
  33. package/ring/scripts/verdicts-log.mjs +75 -0
  34. package/ring/scripts/verdicts.mjs +205 -0
  35. package/ring/skills/play-by-play/SKILL.md +235 -33
  36. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  37. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  38. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  39. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  40. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  41. package/ring/skills/referee/SKILL.md +181 -56
  42. package/ring/skills/referee/references/api-diagram.md +56 -0
  43. package/ring/skills/referee/references/precedents.md +11 -0
  44. package/ring/skills/referee/references/rulebook.md +97 -6
  45. package/ring/skills/referee/templates/api-diagram.html +115 -0
  46. package/ring/skills/referee/templates/cornerman-batch.md +17 -11
  47. package/ring/skills/referee/templates/cornerman-concept.md +28 -30
  48. package/ring/skills/referee/templates/cornerman-range.md +16 -11
  49. package/ring/skills/referee/templates/escalation.md +7 -1
  50. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  51. package/ring/skills/referee/templates/wrestler-brief.md +28 -41
  52. package/ring/skills/referee/templates/wrestler-cutoff.md +4 -0
  53. package/ring/skills/referee/templates/wrestler-resume.md +4 -0
  54. package/ring/skills/sparring/SKILL.md +13 -4
  55. package/ring/skills/sparring/templates/contrato.md +29 -0
  56. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  57. package/ring/skills/title-defense/SKILL.md +2 -2
  58. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ // The numbers of a bout for the final edition of its chronicle: what the lines are made of, the
3
+ // rhythm of the commits (by day, by hour, by conventional type) and the referee's book (decisions,
4
+ // rebounds, findings, contracts, fouls, round-bells). Read-only: git in the wrestler's worktree and
5
+ // the bout dir. The pure parts are exported and tested; the CLI prints JSON or, with --html, the
6
+ // "El cierre en números" block ready for the chronicle template. Zero-dependency.
7
+ //
8
+ // node bout-stats.mjs <pr> [--tz <Area/City>] [--repo <owner/name>] [--html] [--lang es|en]
9
+ // [--moments <json|file>]
10
+ //
11
+ // --moments takes an array of { at, title, tag } (tag: one of the chronicle's event types); the
12
+ // skill picks them from the play-by-play JSON. Every time comes from the records, never invented.
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { basename, join } from 'node:path';
16
+
17
+ import { renderStats } from './bout-stats-render.mjs';
18
+ import {
19
+ classifyDecision,
20
+ foulTarget,
21
+ parseDecisionLine,
22
+ parseReboundLine,
23
+ } from './chronicle.mjs';
24
+ import {
25
+ boutDir,
26
+ isMain,
27
+ projectDir,
28
+ readState,
29
+ repoSlug,
30
+ slugFromRemote,
31
+ } from './config.mjs';
32
+ import { counts, readLedger } from './findings.mjs';
33
+
34
+ const LOCKFILES = [
35
+ 'package-lock.json',
36
+ 'yarn.lock',
37
+ 'pnpm-lock.yaml',
38
+ 'bun.lockb',
39
+ 'npm-shrinkwrap.json',
40
+ 'Cargo.lock',
41
+ 'go.sum',
42
+ 'poetry.lock',
43
+ 'Pipfile.lock',
44
+ 'Gemfile.lock',
45
+ 'composer.lock',
46
+ ];
47
+ const TEST_PATH = /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|spec)\//;
48
+ const TEST_FILE =
49
+ /\.(test|spec|e2e-spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
50
+ const CODE_FILE =
51
+ /\.([cm]?[jt]sx?|py|go|rb|java|kt|kts|swift|rs|cs|php|vue|svelte|scala|ex|exs|dart|m|mm|c|cc|cpp|h|hpp|sql)$/;
52
+
53
+ /** Which part of the bout a changed path belongs to: tests, lockfile, code or other. PURE. */
54
+ export function lineBucket(path) {
55
+ const file = String(path);
56
+ if (LOCKFILES.includes(basename(file))) return 'lockfile';
57
+ if (TEST_PATH.test(file) || TEST_FILE.test(file)) return 'tests';
58
+ if (CODE_FILE.test(file)) return 'code';
59
+ return 'other';
60
+ }
61
+
62
+ /**
63
+ * Totals of `git diff --numstat` output, split by bucket. A binary file ("-" counts) counts as a
64
+ * file with no lines. A rename (`a => b`, `{a => b}`) is bucketed by its new path. PURE.
65
+ */
66
+ export function numstatTotals(text) {
67
+ const empty = () => ({ added: 0, deleted: 0, files: 0 });
68
+ const buckets = {
69
+ code: empty(),
70
+ tests: empty(),
71
+ lockfile: empty(),
72
+ other: empty(),
73
+ };
74
+ const total = empty();
75
+ for (const line of String(text).split('\n').filter(Boolean)) {
76
+ const [added, deleted, ...rest] = line.split('\t');
77
+ const path = rest
78
+ .join('\t')
79
+ .replace(/\{[^{}]* => ([^{}]*)\}/g, '$1')
80
+ .replace(/^.* => /, '');
81
+ const bucket = buckets[lineBucket(path)];
82
+ const plus = Number(added) || 0;
83
+ const minus = Number(deleted) || 0;
84
+ for (const target of [bucket, total]) {
85
+ target.added += plus;
86
+ target.deleted += minus;
87
+ target.files += 1;
88
+ }
89
+ }
90
+ return { ...total, buckets };
91
+ }
92
+
93
+ const TYPE_ORDER = [
94
+ 'feat',
95
+ 'fix',
96
+ 'refactor',
97
+ 'test',
98
+ 'docs',
99
+ 'perf',
100
+ 'style',
101
+ 'build',
102
+ 'ci',
103
+ 'chore',
104
+ ];
105
+
106
+ /** The conventional type of a commit subject (`feat(mobile): …` → feat), or "other". PURE. */
107
+ export function commitType(subject) {
108
+ const type = /^([a-z]+)(\([^)]*\))?!?:/i
109
+ .exec(String(subject).trim())?.[1]
110
+ ?.toLowerCase();
111
+ return TYPE_ORDER.includes(type) ? type : 'other';
112
+ }
113
+
114
+ const dayKey = (at, timeZone) =>
115
+ new Intl.DateTimeFormat('en-CA', {
116
+ timeZone,
117
+ year: 'numeric',
118
+ month: '2-digit',
119
+ day: '2-digit',
120
+ }).format(new Date(at));
121
+ const hourOf = (at, timeZone) =>
122
+ Number(
123
+ new Intl.DateTimeFormat('en-GB', {
124
+ timeZone,
125
+ hour: '2-digit',
126
+ hour12: false,
127
+ })
128
+ .format(new Date(at))
129
+ .replace('24', '00'),
130
+ );
131
+
132
+ /** Days between two YYYY-MM-DD keys, both included, so a quiet day still shows as zero. PURE. */
133
+ function daysBetween(first, last) {
134
+ const days = [];
135
+ for (
136
+ let at = Date.parse(`${first}T12:00:00Z`);
137
+ at <= Date.parse(`${last}T12:00:00Z`);
138
+ at += 86_400_000
139
+ )
140
+ days.push(new Date(at).toISOString().slice(0, 10));
141
+ return days;
142
+ }
143
+
144
+ /**
145
+ * The rhythm of `git log --format=%cI%x09%s` lines in a time zone: commits per local day (quiet
146
+ * days included), per local hour (24 slots) and per conventional type (fixed order, "other" last).
147
+ * PURE.
148
+ */
149
+ export function commitRhythm(log, timeZone) {
150
+ const commits = String(log)
151
+ .split('\n')
152
+ .filter(Boolean)
153
+ .map((line) => {
154
+ const [at, ...subject] = line.split('\t');
155
+ return { at, subject: subject.join('\t') };
156
+ })
157
+ .filter((commit) => !Number.isNaN(Date.parse(commit.at)));
158
+ const byHour = Array.from({ length: 24 }, () => 0);
159
+ const perDay = new Map();
160
+ const perType = new Map();
161
+ for (const commit of commits) {
162
+ byHour[hourOf(commit.at, timeZone)] += 1;
163
+ const day = dayKey(commit.at, timeZone);
164
+ perDay.set(day, (perDay.get(day) ?? 0) + 1);
165
+ const type = commitType(commit.subject);
166
+ perType.set(type, (perType.get(type) ?? 0) + 1);
167
+ }
168
+ const keys = [...perDay.keys()].sort();
169
+ const byDay = keys.length
170
+ ? daysBetween(keys[0], keys.at(-1)).map((day) => ({
171
+ day,
172
+ n: perDay.get(day) ?? 0,
173
+ }))
174
+ : [];
175
+ const byType = [...TYPE_ORDER, 'other']
176
+ .filter((type) => perType.has(type))
177
+ .map((type) => ({ type, n: perType.get(type) }));
178
+ return { total: commits.length, byDay, byHour, byType };
179
+ }
180
+
181
+ /**
182
+ * The referee's book from the ledgers: decisions (distinct ids, the judge's among them), contracts
183
+ * the judge approved, fouls, rebounds (closed and open), first and last recorded time. PURE.
184
+ */
185
+ export function ledgerCounts(decisionsText, reboundsText) {
186
+ const decisions = String(decisionsText)
187
+ .split('\n')
188
+ .map(parseDecisionLine)
189
+ .filter((entry) => entry?.id);
190
+ const rebounds = String(reboundsText)
191
+ .split('\n')
192
+ .map(parseReboundLine)
193
+ .filter(Boolean);
194
+ const ids = (list) => new Set(list.map((entry) => entry.id)).size;
195
+ const fouls = decisions.filter(
196
+ (entry) => classifyDecision(entry) === 'falta',
197
+ );
198
+ // As applyFoulReferences in the chronicle: a line marking an earlier decision of the ledger
199
+ // counts as that decision's foul, so two marks of the same D<n> are one foul.
200
+ const logged = new Set(decisions.map((entry) => entry.id));
201
+ const foulOf = (entry) => {
202
+ const target = foulTarget(entry.text);
203
+ return target && target !== entry.id && logged.has(target)
204
+ ? target
205
+ : entry.id;
206
+ };
207
+ // ISO times with different offsets do not sort as strings: sort by the instant.
208
+ const times = [...decisions, ...rebounds]
209
+ .map((entry) => entry.at)
210
+ .filter((at) => at && !Number.isNaN(Date.parse(at)))
211
+ .sort((a, b) => Date.parse(a) - Date.parse(b));
212
+ return {
213
+ decisions: ids(decisions.filter((entry) => /^D\d+$/.test(entry.id))),
214
+ judgeDecisions: ids(
215
+ decisions.filter((entry) => classifyDecision(entry) === 'juez'),
216
+ ),
217
+ contracts: ids(
218
+ decisions.filter((entry) => /contract approved/i.test(entry.text)),
219
+ ),
220
+ fouls: new Set(fouls.map(foulOf)).size,
221
+ rebounds: {
222
+ total: rebounds.length,
223
+ closed: rebounds.filter((entry) => entry.closed).length,
224
+ },
225
+ first: times[0] ?? null,
226
+ last: times.at(-1) ?? null,
227
+ };
228
+ }
229
+
230
+ /** Round numbers from the bout dir's `round-<n>-<slug>.md` files (one per round-bell). PURE. */
231
+ export function roundBells(files) {
232
+ return new Set(
233
+ files.map((file) => /^round-(\d+)-/.exec(file)?.[1]).filter(Boolean),
234
+ ).size;
235
+ }
236
+
237
+ /** "134 h 54" between two ISO times. PURE. */
238
+ export function durationLabel(first, last) {
239
+ if (!first || !last) return null;
240
+ const minutes = Math.max(
241
+ 0,
242
+ Math.floor((Date.parse(last) - Date.parse(first)) / 60_000),
243
+ );
244
+ return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')}`;
245
+ }
246
+
247
+ /** Moments for the highlight reel: { at, title, tag } sorted by time, the time checked. PURE. */
248
+ export function readMoments(items) {
249
+ if (!Array.isArray(items))
250
+ throw new Error('--moments: expected a JSON array');
251
+ return items
252
+ .map((item) => {
253
+ if (Number.isNaN(Date.parse(item?.at)))
254
+ throw new Error(`--moments: "${item?.title}" has no valid time`);
255
+ return {
256
+ at: item.at,
257
+ title: String(item.title ?? ''),
258
+ tag: String(item.tag ?? 'campana'),
259
+ };
260
+ })
261
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
262
+ }
263
+
264
+ const run = (args, cwd) =>
265
+ execFileSync('git', ['-C', cwd, ...args], {
266
+ encoding: 'utf8',
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ maxBuffer: 64 * 1024 * 1024,
269
+ });
270
+
271
+ /** Everything above for one bout, read from git and its dir. */
272
+ export function collectStats(dir, timeZone) {
273
+ const state = readState(dir);
274
+ const read = (name) =>
275
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
276
+ const notes = [];
277
+ const book = ledgerCounts(read('decisions.md'), read('rebounds.md'));
278
+ const worktreeOk = Boolean(state.worktree && existsSync(state.worktree));
279
+ if (!worktreeOk)
280
+ notes.push(
281
+ `git: the wrestler's worktree ${state.worktree} is gone; lines and commits are missing`,
282
+ );
283
+ const base = `origin/${state.base}`;
284
+ const lines = worktreeOk
285
+ ? numstatTotals(
286
+ run(['diff', '--numstat', `${base}...HEAD`], state.worktree),
287
+ )
288
+ : null;
289
+ const rhythm = worktreeOk
290
+ ? commitRhythm(
291
+ run(
292
+ ['log', '--no-merges', '--format=%cI%x09%s', `${base}..HEAD`],
293
+ state.worktree,
294
+ ),
295
+ timeZone,
296
+ )
297
+ : null;
298
+ return {
299
+ pr: state.pr ?? null,
300
+ timezone: timeZone,
301
+ head: worktreeOk
302
+ ? run(['rev-parse', '--short=9', 'HEAD'], state.worktree).trim()
303
+ : null,
304
+ duration: {
305
+ first: book.first,
306
+ last: book.last,
307
+ label: durationLabel(book.first, book.last),
308
+ },
309
+ lines,
310
+ rhythm,
311
+ book: {
312
+ ...book,
313
+ findings: counts(readLedger(dir)),
314
+ roundBells: roundBells(readdirSync(dir)),
315
+ },
316
+ notes,
317
+ };
318
+ }
319
+
320
+ function option(argv, name) {
321
+ const index = argv.indexOf(`--${name}`);
322
+ return index === -1 ? null : argv[index + 1];
323
+ }
324
+
325
+ function main([pr, ...argv]) {
326
+ if (!pr)
327
+ throw new Error(
328
+ 'usage: bout-stats.mjs <pr> [--tz <zone>] [--repo <owner/name>] [--html] [--lang es|en] [--moments <json|file>]',
329
+ );
330
+ const repo = option(argv, 'repo');
331
+ const timeZone =
332
+ option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
333
+ const dir = boutDir(repo ? slugFromRemote(repo) : repoSlug(projectDir()), pr);
334
+ if (!existsSync(join(dir, 'state.json')))
335
+ throw new Error(`no bout for #${pr} in ${dir}`);
336
+ const stats = collectStats(dir, timeZone);
337
+ if (!argv.includes('--html')) return JSON.stringify(stats, null, 2);
338
+ const momentsValue = option(argv, 'moments');
339
+ const moments = momentsValue
340
+ ? readMoments(
341
+ JSON.parse(
342
+ /^\s*\[/.test(momentsValue)
343
+ ? momentsValue
344
+ : readFileSync(momentsValue, 'utf8'),
345
+ ),
346
+ )
347
+ : [];
348
+ return renderStats(stats, { lang: option(argv, 'lang') ?? 'es', moments });
349
+ }
350
+
351
+ if (isMain(import.meta.url)) {
352
+ try {
353
+ console.log(main(process.argv.slice(2)));
354
+ } catch (error) {
355
+ console.error(
356
+ `bout-stats: ${String(error.stderr || error.message).trim()}`,
357
+ );
358
+ process.exit(1);
359
+ }
360
+ }
@@ -0,0 +1,130 @@
1
+ // The pure rules of a generated brief (brief.mjs is its CLI): the rules block, the excerpt of
2
+ // decisions, the filling of a template with no placeholder left, the seal that proves the brief
3
+ // came from the script with the rules in force, and the paths a brief may point to. Zero-dependency.
4
+ import { createHash } from 'node:crypto';
5
+ import { basename, isAbsolute, sep } from 'node:path';
6
+
7
+ import { parseDecisionLine } from './chronicle.mjs';
8
+ import { checklistLines } from './rule-ids.mjs';
9
+
10
+ export const BRIEF_VERSION = 'v1';
11
+ const SEAL = /^<!-- ring-brief (v\d+) (.*) -->$/;
12
+
13
+ export const sha256 = (text) =>
14
+ `sha256:${createHash('sha256').update(text).digest('hex')}`;
15
+
16
+ /**
17
+ * The rules every brief carries, in full and before anything else (the same text for every brief
18
+ * of a venue, so a model can reuse it): the architecture checklist, then each rule file. PURE.
19
+ */
20
+ export function rulesBlock({ sources, entries }) {
21
+ const files = sources.map(
22
+ ({ path, text }) =>
23
+ `<rules file="${basename(path)}">\n${text.trim()}\n</rules>`,
24
+ );
25
+ return [
26
+ '# The rules in force for this bout',
27
+ '',
28
+ 'Embedded in full by `brief.mjs`: these are the rules you apply and cite by id. Nothing you',
29
+ 'remember from another bout replaces them.',
30
+ '',
31
+ '## Architecture checklist (what slips past a line-by-line review; check it first)',
32
+ '',
33
+ ...checklistLines(entries),
34
+ '',
35
+ ...files,
36
+ ].join('\n');
37
+ }
38
+
39
+ /**
40
+ * The decisions a brief carries: every foul (what must not be repeated), the ones whose text names
41
+ * a file of the range, and the last `last`; in ledger order, each once. PURE.
42
+ */
43
+ export function decisionsExcerpt(
44
+ decisionsText,
45
+ files = [],
46
+ { last = 20 } = {},
47
+ ) {
48
+ const lines = String(decisionsText ?? '')
49
+ .split('\n')
50
+ .filter((line) => parseDecisionLine(line)?.id);
51
+ const names = files.map((file) => basename(file)).filter(Boolean);
52
+ const tail = new Set(lines.slice(-last));
53
+ const kept = lines.filter(
54
+ (line) =>
55
+ tail.has(line) ||
56
+ /\bFOUL\b/.test(line) ||
57
+ names.some((name) => line.includes(name)),
58
+ );
59
+ return kept.length ? kept.join('\n') : '(no decisions yet)';
60
+ }
61
+
62
+ /** The template with every `{{name}}` filled; throws naming each one left empty. PURE. */
63
+ export function fill(template, vars) {
64
+ const missing = new Set();
65
+ const text = template.replace(/\{\{(\w+)\}\}/g, (whole, name) => {
66
+ const value = vars[name];
67
+ if (value === undefined || value === null || String(value).trim() === '') {
68
+ missing.add(name);
69
+ return whole;
70
+ }
71
+ return String(value);
72
+ });
73
+ if (missing.size)
74
+ throw new Error(`the brief is missing: ${[...missing].join(', ')}`);
75
+ return text;
76
+ }
77
+
78
+ /** The last line of a brief: what it is and the hashes that prove it. PURE. */
79
+ export function seal(body, meta) {
80
+ const fields = Object.entries({ ...meta, body: sha256(body) })
81
+ .map(([key, value]) => `${key}=${value}`)
82
+ .join(' ');
83
+ return `${body}\n<!-- ring-brief ${BRIEF_VERSION} ${fields} -->\n`;
84
+ }
85
+
86
+ /** The seal of a brief, its body and whether the body is the one sealed; null without a seal. PURE. */
87
+ export function readSeal(text) {
88
+ const lines = String(text).replace(/\n$/, '').split('\n');
89
+ const match = SEAL.exec(lines.at(-1) ?? '');
90
+ if (!match) return null;
91
+ const meta = Object.fromEntries(
92
+ match[2].split(' ').map((pair) => pair.split('=')),
93
+ );
94
+ const body = lines.slice(0, -1).join('\n');
95
+ return { version: match[1], meta, body, intact: sha256(body) === meta.body };
96
+ }
97
+
98
+ /**
99
+ * Whether a sealed brief still holds: its body untouched and its rules the ones in force now
100
+ * (`rulesHash`). The reason when it does not. PURE.
101
+ */
102
+ export function briefStatus(text, rulesHash) {
103
+ const sealed = readSeal(text);
104
+ if (!sealed)
105
+ return { ok: false, reason: 'not generated by brief.mjs (no seal)' };
106
+ if (!sealed.intact)
107
+ return { ok: false, reason: 'edited by hand after it was generated' };
108
+ if (sealed.meta.rules && sealed.meta.rules !== rulesHash)
109
+ return { ok: false, reason: 'the rules changed since; generate it again' };
110
+ return { ok: true, meta: sealed.meta };
111
+ }
112
+
113
+ const ABSOLUTE_PATH = /(?:^|[\s`'"(])(\/[^\s`'"()]+)/g;
114
+
115
+ /**
116
+ * The absolute paths in the referee's values that point outside the places a brief may send an
117
+ * agent (the repo, the worktree, the bout dir, the ring): a session scratchpad is wiped on restart,
118
+ * so nothing the human asked for may live there. PURE.
119
+ */
120
+ export function strayPaths(vars, roots) {
121
+ const inside = (path) =>
122
+ roots.some((root) => path === root || path.startsWith(root + sep));
123
+ return Object.entries(vars).flatMap(([name, value]) =>
124
+ [...String(value ?? '').matchAll(ABSOLUTE_PATH)]
125
+ .map((match) => match[1].replace(/[.,;:]+$/, ''))
126
+ .filter((path) => isAbsolute(path))
127
+ .filter((path) => !inside(path))
128
+ .map((path) => ({ name, path })),
129
+ );
130
+ }
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ // Hook on PreToolUse (Agent|Task). In a session registered in a bout, a wrestler or a cornerman is
3
+ // launched with a brief brief.mjs generated, never one written by hand. Zero-dependency.
4
+ //
5
+ // - The referee launches with `prompt: "ring-brief: <path>"`: the guard reads the file, checks its
6
+ // seal (generated, unedited, on today's rules, of this bout and of this role) and replaces the
7
+ // prompt with the file's text, with the model and effort `.claude/ring.json` sets for the role.
8
+ // - A prompt that is the brief itself is checked the same way and only gets the model and effort.
9
+ // - A brief that does not hold: `briefGuard: "warn"` (the default) lets the launch through and tells
10
+ // the referee why; `"block"` refuses it with the command that generates one; `"off"` does nothing.
11
+ // Every decision goes to `<bout dir>/brief-guard.jsonl`.
12
+ //
13
+ // Sessions that are not the bout's, agents that are not the ring's and the rudo are left alone.
14
+ // It never fails a tool call by accident: any error of its own ends in exit 0 with no output.
15
+ import { appendFileSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ import { checkBrief } from './brief.mjs';
19
+ import { isMain, loadConfig, projectDir, readState } from './config.mjs';
20
+ import { classifyAgent } from './live-hook.mjs';
21
+ import { findBout, sessionRegistered } from './live.mjs';
22
+
23
+ export const GUARD_LOG = 'brief-guard.jsonl';
24
+ const AGENT_TOOLS = ['Agent', 'Task'];
25
+ const ROLE = { luchador: 'wrestler', esquinero: 'cornerman' };
26
+ const REFERENCE = /^ring-brief:\s*(\S+)$/;
27
+
28
+ /** The brief a prompt carries: the file of a `ring-brief: <path>` reference, or the prompt itself. */
29
+ export function briefOf(
30
+ prompt,
31
+ readFile = (path) => readFileSync(path, 'utf8'),
32
+ ) {
33
+ const text = String(prompt ?? '').trim();
34
+ const path = REFERENCE.exec(text)?.[1] ?? null;
35
+ if (!path) return { text, path: null };
36
+ try {
37
+ return { text: readFile(path), path };
38
+ } catch {
39
+ return { text: null, path };
40
+ }
41
+ }
42
+
43
+ /** Why a brief does not hold for this launch, or null when it holds. PURE given `check`. */
44
+ export function problemOf({ text, path, check, role, pr }) {
45
+ if (text === null) return `there is no brief at ${path}`;
46
+ if (!check.ok) return check.reason;
47
+ if (check.meta.kind !== role)
48
+ return `it is a ${check.meta.kind} brief, launched as the ${role}`;
49
+ if (String(check.meta.pr) !== String(pr))
50
+ return `it is the brief of #${check.meta.pr}, not of this bout (#${pr})`;
51
+ return null;
52
+ }
53
+
54
+ const howToFix = (role, pr) =>
55
+ `generate it with \`node <ring>/scripts/brief.mjs ${role} ${pr} --mode <mode> --vars <file.json>\` and launch with \`prompt: "ring-brief: <the path it prints>"\``;
56
+
57
+ /**
58
+ * The hook's answer for one launch: the JSON to print, or null to print nothing. PURE.
59
+ * `brief` is briefOf's result, `problem` problemOf's, `launch` the role's {model, effort}.
60
+ */
61
+ export function answer({ input, brief, problem, mode, role, pr, launch }) {
62
+ if (problem && mode === 'block')
63
+ return {
64
+ hookSpecificOutput: {
65
+ hookEventName: 'PreToolUse',
66
+ permissionDecision: 'deny',
67
+ permissionDecisionReason: `brief-guard: the ${role} brief does not hold (${problem}); ${howToFix(role, pr)}.`,
68
+ },
69
+ };
70
+ if (problem)
71
+ return {
72
+ hookSpecificOutput: {
73
+ hookEventName: 'PreToolUse',
74
+ additionalContext: `brief-guard (warn): this ${role} was launched with a brief that does not hold (${problem}). It runs without the rules and decisions brief.mjs embeds; next time ${howToFix(role, pr)}.`,
75
+ },
76
+ };
77
+ const updated = {
78
+ ...input,
79
+ prompt: brief.text,
80
+ ...(launch?.model ? { model: launch.model } : {}),
81
+ ...(launch?.effort ? { effort: launch.effort } : {}),
82
+ };
83
+ const same = ['prompt', 'model', 'effort'].every(
84
+ (key) => updated[key] === input[key],
85
+ );
86
+ if (same) return null;
87
+ return {
88
+ hookSpecificOutput: {
89
+ hookEventName: 'PreToolUse',
90
+ permissionDecision: 'allow',
91
+ permissionDecisionReason: `brief-guard: ${brief.path ? `brief ${brief.path} expanded` : 'brief holds'}, ${role} on ${launch?.model ?? input.model} / ${launch?.effort ?? input.effort}`,
92
+ updatedInput: updated,
93
+ },
94
+ };
95
+ }
96
+
97
+ /** The guard on one hook input; returns what to print (null for nothing). */
98
+ export function guard(input) {
99
+ if (input?.hook_event_name !== 'PreToolUse') return null;
100
+ if (!AGENT_TOOLS.includes(input.tool_name)) return null;
101
+ const role = ROLE[classifyAgent(input.tool_input)];
102
+ if (!role) return null;
103
+ const bout = findBout({
104
+ cwd: input.cwd,
105
+ hint: input.tool_input?.description,
106
+ });
107
+ if (!bout || !sessionRegistered(bout, input.session_id)) return null;
108
+ const state = readState(bout);
109
+ const { config } = loadConfig(projectDir(process.env, input.cwd), {
110
+ contract: false,
111
+ venue: state.venue ?? 'backend',
112
+ });
113
+ const mode = config.briefGuard ?? 'warn';
114
+ if (mode === 'off') return null;
115
+ const brief = briefOf(input.tool_input?.prompt);
116
+ let check = { ok: false, reason: 'no seal', meta: null };
117
+ if (brief.text !== null) {
118
+ try {
119
+ check = checkBrief(brief.text);
120
+ } catch (error) {
121
+ check = { ok: false, reason: error.message, meta: null };
122
+ }
123
+ }
124
+ const problem = problemOf({ ...brief, check, role, pr: state.pr });
125
+ appendFileSync(
126
+ join(bout, GUARD_LOG),
127
+ `${JSON.stringify({ at: new Date().toISOString(), role, mode, path: brief.path, ok: !problem, problem })}\n`,
128
+ );
129
+ return answer({
130
+ input: input.tool_input,
131
+ brief,
132
+ problem,
133
+ mode,
134
+ role,
135
+ pr: state.pr,
136
+ launch: config.agents?.[role],
137
+ });
138
+ }
139
+
140
+ if (isMain(import.meta.url)) {
141
+ let raw = '';
142
+ process.stdin.setEncoding('utf8');
143
+ process.stdin.on('data', (chunk) => (raw += chunk));
144
+ process.stdin.on('end', () => {
145
+ let out = null;
146
+ try {
147
+ out = guard(JSON.parse(raw));
148
+ } catch {
149
+ out = null;
150
+ }
151
+ if (out) process.stdout.write(JSON.stringify(out));
152
+ process.exit(0);
153
+ });
154
+ }