@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.
- package/README.md +46 -26
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/ring/agents/cornerman.md +32 -6
- package/ring/agents/rudo.md +2 -1
- package/ring/agents/wrestler.md +19 -4
- package/ring/hooks/hooks.json +42 -0
- package/ring/scripts/bout-stats-render.mjs +366 -0
- package/ring/scripts/bout-stats.mjs +360 -0
- package/ring/scripts/brief-build.mjs +130 -0
- package/ring/scripts/brief-guard.mjs +154 -0
- package/ring/scripts/brief.mjs +272 -0
- package/ring/scripts/compact-hook.mjs +110 -0
- package/ring/scripts/config.mjs +25 -1
- package/ring/scripts/contract-endpoints.mjs +163 -0
- package/ring/scripts/contract-preview.mjs +34 -14
- package/ring/scripts/fight.mjs +27 -0
- package/ring/scripts/findings.mjs +82 -3
- package/ring/scripts/format-gate.mjs +723 -0
- package/ring/scripts/format-worker.mjs +120 -0
- package/ring/scripts/front-routes.mjs +345 -0
- package/ring/scripts/judges-card.mjs +324 -3
- package/ring/scripts/lookout.mjs +22 -4
- package/ring/scripts/mask-guard.mjs +34 -1
- package/ring/scripts/page-embed.mjs +112 -0
- package/ring/scripts/play-by-play.mjs +155 -7
- package/ring/scripts/preparation-collect.mjs +177 -0
- package/ring/scripts/preparation.mjs +547 -0
- package/ring/scripts/round-bell.mjs +9 -2
- package/ring/scripts/rule-ids.mjs +124 -0
- package/ring/scripts/tech-plan-log.mjs +81 -0
- package/ring/scripts/tech-plan.mjs +191 -0
- package/ring/scripts/verdicts-log.mjs +75 -0
- package/ring/scripts/verdicts.mjs +205 -0
- package/ring/skills/play-by-play/SKILL.md +235 -33
- package/ring/skills/play-by-play/templates/blocks.css +196 -0
- package/ring/skills/play-by-play/templates/chronicle.html +358 -55
- package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
- package/ring/skills/play-by-play/templates/interview-form.js +516 -0
- package/ring/skills/play-by-play/templates/now-panel.js +719 -0
- package/ring/skills/referee/SKILL.md +181 -56
- package/ring/skills/referee/references/api-diagram.md +56 -0
- package/ring/skills/referee/references/precedents.md +11 -0
- package/ring/skills/referee/references/rulebook.md +97 -6
- package/ring/skills/referee/templates/api-diagram.html +115 -0
- 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/escalation.md +7 -1
- package/ring/skills/referee/templates/round-bell-body.md +4 -0
- package/ring/skills/referee/templates/wrestler-brief.md +28 -41
- 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 +13 -4
- package/ring/skills/sparring/templates/contrato.md +29 -0
- package/ring/skills/sparring/templates/rudo-brief.md +7 -0
- package/ring/skills/title-defense/SKILL.md +2 -2
- package/ring/skills/weigh-in/SKILL.md +6 -0
|
@@ -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
|
+
}
|
package/ring/scripts/config.mjs
CHANGED
|
@@ -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
|
|
|
@@ -712,7 +736,7 @@ function listDirs(dir) {
|
|
|
712
736
|
|
|
713
737
|
/** The bout records the chronicle narrates; play-by-play.html and state.json are not records. */
|
|
714
738
|
const CHRONICLED =
|
|
715
|
-
/^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$/;
|
|
739
|
+
/^(decisions|rebounds|title-defense|judges-card)\.md$|^round-.*\.md$|^(interview|verdicts|plan)\.jsonl$|^plan-tecnico\.md$/;
|
|
716
740
|
|
|
717
741
|
/**
|
|
718
742
|
* Is the bout's play-by-play behind its records? Only once it has a link (`playByPlayUrl`): a
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// «Endpoints del contrato» for the chronicle's weigh-in: what the judge has to see to sign the API
|
|
2
|
+
// contract, inside the one Artifact page and without a server. The plugin is zero-dependency and
|
|
3
|
+
// cannot parse YAML in Node, so this side only gathers the spec files (every file a changed spec
|
|
4
|
+
// root reaches through `$ref`, as the base has it and as the worktree proposes it, the same
|
|
5
|
+
// proposal contract-preview.mjs serves) and writes them into the page as JSON; the page parses and
|
|
6
|
+
// renders them in the browser (templates/endpoints-view.js, js-yaml from the CDN), operation by
|
|
7
|
+
// operation, with the schemas expanded. Regenerating replaces the block, never duplicates it.
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { loadConfig, readState } from './config.mjs';
|
|
14
|
+
import { globToRegExp } from './contract.mjs';
|
|
15
|
+
import { isSpecRoot, reachable, specRoots } from './contract-preview.mjs';
|
|
16
|
+
|
|
17
|
+
const show = (worktree, ref, path) => {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync('git', ['-C', worktree, 'show', `${ref}:${path}`], {
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const refExists = (worktree, ref) => {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync('git', ['-C', worktree, 'rev-parse', '--verify', '-q', ref], {
|
|
32
|
+
stdio: 'ignore',
|
|
33
|
+
});
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The spec roots whose reachable files differ between `read.base` and `read.head`, each with those
|
|
42
|
+
* files: `{ path: { base, head } }`, a side null when the file does not exist there and `head`
|
|
43
|
+
* omitted (`same: true`) when it did not change, so an untouched fragment travels once. PURE apart
|
|
44
|
+
* from `read`.
|
|
45
|
+
*/
|
|
46
|
+
export function specSources(roots, read) {
|
|
47
|
+
return roots
|
|
48
|
+
.map((root) => {
|
|
49
|
+
const paths = new Set([
|
|
50
|
+
...reachable([root], read.head),
|
|
51
|
+
...reachable([root], read.base),
|
|
52
|
+
]);
|
|
53
|
+
const files = Object.fromEntries(
|
|
54
|
+
[...paths].sort().map((path) => {
|
|
55
|
+
const base = read.base(path);
|
|
56
|
+
const head = read.head(path);
|
|
57
|
+
return [path, base === head ? { base, same: true } : { base, head }];
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
const changed = Object.values(files).some((file) => !file.same);
|
|
61
|
+
return changed ? { root, files } : null;
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The spec roots the PR deletes: files gone from the worktree that the config's `apiSpec` names
|
|
68
|
+
* and that were an OpenAPI root at the base. `specRoots` only sees the worktree, so without these
|
|
69
|
+
* a deleted root never reaches the judge. PURE apart from `baseHead`.
|
|
70
|
+
*/
|
|
71
|
+
export function deletedSpecRoots(deleted, apiSpec, baseHead) {
|
|
72
|
+
const named = (path) =>
|
|
73
|
+
apiSpec.some((entry) => {
|
|
74
|
+
const spec = entry.replace(/^\.\//, '');
|
|
75
|
+
return /[*?]/.test(spec) ? globToRegExp(spec).test(path) : spec === path;
|
|
76
|
+
});
|
|
77
|
+
return deleted.filter(
|
|
78
|
+
(path) => named(path) && isSpecRoot(path, baseHead(path) ?? ''),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bout's changed spec roots against its stack base, read from its worktree. */
|
|
83
|
+
export function collectContractSources(boutDir) {
|
|
84
|
+
const state = readState(boutDir);
|
|
85
|
+
const { worktree, base } = state;
|
|
86
|
+
if (!worktree || !existsSync(worktree))
|
|
87
|
+
throw new Error(`the worktree of #${state.pr} is gone (${worktree})`);
|
|
88
|
+
const ref = refExists(worktree, `origin/${base}`) ? `origin/${base}` : base;
|
|
89
|
+
const { config } = loadConfig(worktree);
|
|
90
|
+
const head = (path) => {
|
|
91
|
+
const file = join(worktree, path);
|
|
92
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : null;
|
|
93
|
+
};
|
|
94
|
+
const atBase = (path) => show(worktree, ref, path);
|
|
95
|
+
const deleted = execFileSync(
|
|
96
|
+
'git',
|
|
97
|
+
[
|
|
98
|
+
'-C',
|
|
99
|
+
worktree,
|
|
100
|
+
'diff',
|
|
101
|
+
'--name-only',
|
|
102
|
+
'--no-renames',
|
|
103
|
+
'--diff-filter=D',
|
|
104
|
+
ref,
|
|
105
|
+
],
|
|
106
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
107
|
+
)
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
const roots = [
|
|
111
|
+
...new Set([
|
|
112
|
+
...specRoots(worktree, config),
|
|
113
|
+
...deletedSpecRoots(deleted, config.apiSpec, (path) =>
|
|
114
|
+
atBase(path)?.slice(0, 2048),
|
|
115
|
+
),
|
|
116
|
+
]),
|
|
117
|
+
];
|
|
118
|
+
return {
|
|
119
|
+
pr: state.pr,
|
|
120
|
+
base: ref,
|
|
121
|
+
roots: specSources(roots, { head, base: atBase }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** JSON safe inside a `<script>` element. PURE. */
|
|
126
|
+
const LINE_SEPARATOR = String.fromCharCode(0x2028);
|
|
127
|
+
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
|
|
128
|
+
export const scriptJson = (value) =>
|
|
129
|
+
JSON.stringify(value)
|
|
130
|
+
.replace(/</g, '\\u003c')
|
|
131
|
+
.split(LINE_SEPARATOR)
|
|
132
|
+
.join('\\u2028')
|
|
133
|
+
.split(PARAGRAPH_SEPARATOR)
|
|
134
|
+
.join('\\u2029');
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The block that goes after `<!-- ring:api-contract #<pr> -->`: the container the page renders
|
|
138
|
+
* into, a line for a reader without scripts, and the sources as JSON. PURE.
|
|
139
|
+
*/
|
|
140
|
+
export function endpointsBlock(sources) {
|
|
141
|
+
const roots = sources.roots.map((entry) => entry.root).join(', ');
|
|
142
|
+
const fallback = sources.roots.length
|
|
143
|
+
? `Los endpoints del contrato se dibujan al abrir la página (${roots} contra ${sources.base}).`
|
|
144
|
+
: `Ningún spec cambia contra ${sources.base}.`;
|
|
145
|
+
return [
|
|
146
|
+
`<div class="endpoints" data-ring-spec="${sources.pr}"><p class="endpoints-wait">${fallback}</p></div>`,
|
|
147
|
+
`<script type="application/json" id="ring-spec-${sources.pr}">${scriptJson(sources)}</script>`,
|
|
148
|
+
].join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The fingerprint of the spec files a page embeds for this bout (SHA-256 of the JSON exactly as
|
|
153
|
+
* written into the page, which the page hashes too), or null when they cannot be read.
|
|
154
|
+
*/
|
|
155
|
+
export function contractSourcesHash(boutDir) {
|
|
156
|
+
try {
|
|
157
|
+
return createHash('sha256')
|
|
158
|
+
.update(scriptJson(collectContractSources(boutDir)))
|
|
159
|
+
.digest('hex');
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|