@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.
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // Hook on PreToolUse (Agent|Task|SendMessage). 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`. A SendMessage to a wrestler that
12
+ // `judges-card.mjs concept` retired is warned (or blocked) the same way: the next concept goes to a
13
+ // fresh wrestler.
14
+ //
15
+ // Sessions that are not the bout's, agents that are not the ring's and the rudo are left alone.
16
+ // It never fails a tool call by accident: any error of its own ends in exit 0 with no output.
17
+ import { appendFileSync, readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ import { checkBrief } from './brief.mjs';
21
+ import { isMain, loadConfig, projectDir, readState } from './config.mjs';
22
+ import { classifyAgent } from './live-hook.mjs';
23
+ import { findBout, sessionRegistered } from './live.mjs';
24
+
25
+ export const GUARD_LOG = 'brief-guard.jsonl';
26
+ const AGENT_TOOLS = ['Agent', 'Task'];
27
+ const ROLE = { luchador: 'wrestler', esquinero: 'cornerman' };
28
+ const REFERENCE = /^ring-brief:\s*(\S+)$/;
29
+
30
+ /** The brief a prompt carries: the file of a `ring-brief: <path>` reference, or the prompt itself. */
31
+ export function briefOf(
32
+ prompt,
33
+ readFile = (path) => readFileSync(path, 'utf8'),
34
+ ) {
35
+ const text = String(prompt ?? '').trim();
36
+ const path = REFERENCE.exec(text)?.[1] ?? null;
37
+ if (!path) return { text, path: null };
38
+ try {
39
+ return { text: readFile(path), path };
40
+ } catch {
41
+ return { text: null, path };
42
+ }
43
+ }
44
+
45
+ /** Why a brief does not hold for this launch, or null when it holds. PURE given `check`. */
46
+ export function problemOf({ text, path, check, role, pr }) {
47
+ if (text === null) return `there is no brief at ${path}`;
48
+ if (!check.ok) return check.reason;
49
+ if (check.meta.kind !== role)
50
+ return `it is a ${check.meta.kind} brief, launched as the ${role}`;
51
+ if (String(check.meta.pr) !== String(pr))
52
+ return `it is the brief of #${check.meta.pr}, not of this bout (#${pr})`;
53
+ return null;
54
+ }
55
+
56
+ const howToFix = (role, pr) =>
57
+ `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>"\``;
58
+
59
+ /**
60
+ * The hook's answer for one launch: the JSON to print, or null to print nothing. PURE.
61
+ * `brief` is briefOf's result, `problem` problemOf's, `launch` the role's {model, effort}.
62
+ */
63
+ export function answer({ input, brief, problem, mode, role, pr, launch }) {
64
+ if (problem && mode === 'block')
65
+ return {
66
+ hookSpecificOutput: {
67
+ hookEventName: 'PreToolUse',
68
+ permissionDecision: 'deny',
69
+ permissionDecisionReason: `brief-guard: the ${role} brief does not hold (${problem}); ${howToFix(role, pr)}.`,
70
+ },
71
+ };
72
+ if (problem)
73
+ return {
74
+ hookSpecificOutput: {
75
+ hookEventName: 'PreToolUse',
76
+ 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)}.`,
77
+ },
78
+ };
79
+ const updated = {
80
+ ...input,
81
+ prompt: brief.text,
82
+ ...(launch?.model ? { model: launch.model } : {}),
83
+ ...(launch?.effort ? { effort: launch.effort } : {}),
84
+ };
85
+ const same = ['prompt', 'model', 'effort'].every(
86
+ (key) => updated[key] === input[key],
87
+ );
88
+ if (same) return null;
89
+ return {
90
+ hookSpecificOutput: {
91
+ hookEventName: 'PreToolUse',
92
+ permissionDecision: 'allow',
93
+ permissionDecisionReason: `brief-guard: ${brief.path ? `brief ${brief.path} expanded` : 'brief holds'}, ${role} on ${launch?.model ?? input.model} / ${launch?.effort ?? input.effort}`,
94
+ updatedInput: updated,
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The answer to a SendMessage to a wrestler a concept close retired: it carries the old concept's
101
+ * context, and the next concept belongs to a fresh one. PURE.
102
+ */
103
+ export function retiredAnswer({ to, retired, mode, pr }) {
104
+ if (!to || !(retired ?? []).includes(to)) return null;
105
+ const why = `wrestler ${to} closed its concept and was retired; the next concept goes to a fresh wrestler: brief.mjs wrestler ${pr} --mode resume --vars <file with nextConcept>`;
106
+ return mode === 'block'
107
+ ? {
108
+ hookSpecificOutput: {
109
+ hookEventName: 'PreToolUse',
110
+ permissionDecision: 'deny',
111
+ permissionDecisionReason: `brief-guard: ${why}.`,
112
+ },
113
+ }
114
+ : {
115
+ hookSpecificOutput: {
116
+ hookEventName: 'PreToolUse',
117
+ additionalContext: `brief-guard (warn): ${why}.`,
118
+ },
119
+ };
120
+ }
121
+
122
+ function guardMessage(input) {
123
+ const bout = findBout({ cwd: input.cwd, hint: input.tool_input?.summary });
124
+ if (!bout || !sessionRegistered(bout, input.session_id)) return null;
125
+ const state = readState(bout);
126
+ const { config } = loadConfig(projectDir(process.env, input.cwd), {
127
+ contract: false,
128
+ venue: state.venue ?? 'backend',
129
+ });
130
+ const mode = config.briefGuard ?? 'warn';
131
+ if (mode === 'off') return null;
132
+ return retiredAnswer({
133
+ to: input.tool_input?.to,
134
+ retired: state.retiredWrestlers,
135
+ mode,
136
+ pr: state.pr,
137
+ });
138
+ }
139
+
140
+ /** The guard on one hook input; returns what to print (null for nothing). */
141
+ export function guard(input) {
142
+ if (input?.hook_event_name !== 'PreToolUse') return null;
143
+ if (input.tool_name === 'SendMessage') return guardMessage(input);
144
+ if (!AGENT_TOOLS.includes(input.tool_name)) return null;
145
+ const role = ROLE[classifyAgent(input.tool_input)];
146
+ if (!role) return null;
147
+ const bout = findBout({
148
+ cwd: input.cwd,
149
+ hint: input.tool_input?.description,
150
+ });
151
+ if (!bout || !sessionRegistered(bout, input.session_id)) return null;
152
+ const state = readState(bout);
153
+ const { config } = loadConfig(projectDir(process.env, input.cwd), {
154
+ contract: false,
155
+ venue: state.venue ?? 'backend',
156
+ });
157
+ const mode = config.briefGuard ?? 'warn';
158
+ if (mode === 'off') return null;
159
+ const brief = briefOf(input.tool_input?.prompt);
160
+ let check = { ok: false, reason: 'no seal', meta: null };
161
+ if (brief.text !== null) {
162
+ try {
163
+ check = checkBrief(brief.text);
164
+ } catch (error) {
165
+ check = { ok: false, reason: error.message, meta: null };
166
+ }
167
+ }
168
+ const problem = problemOf({ ...brief, check, role, pr: state.pr });
169
+ appendFileSync(
170
+ join(bout, GUARD_LOG),
171
+ `${JSON.stringify({ at: new Date().toISOString(), role, mode, path: brief.path, ok: !problem, problem })}\n`,
172
+ );
173
+ return answer({
174
+ input: input.tool_input,
175
+ brief,
176
+ problem,
177
+ mode,
178
+ role,
179
+ pr: state.pr,
180
+ launch: config.agents?.[role],
181
+ });
182
+ }
183
+
184
+ if (isMain(import.meta.url)) {
185
+ let raw = '';
186
+ process.stdin.setEncoding('utf8');
187
+ process.stdin.on('data', (chunk) => (raw += chunk));
188
+ process.stdin.on('end', () => {
189
+ let out = null;
190
+ try {
191
+ out = guard(JSON.parse(raw));
192
+ } catch {
193
+ out = null;
194
+ }
195
+ if (out) process.stdout.write(JSON.stringify(out));
196
+ process.exit(0);
197
+ });
198
+ }
@@ -0,0 +1,288 @@
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 { recallSection } from './engram.mjs';
46
+ import { conceptsSinceRelay, relayDue } from './relay.mjs';
47
+ import { loadRules } from './rule-ids.mjs';
48
+
49
+ const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
50
+
51
+ /** Per kind and mode: the templates in order, and whether the rules go in (a cut-off resumes the same agent). */
52
+ const SHAPES = {
53
+ wrestler: {
54
+ start: { templates: ['wrestler-brief.md'], rules: true },
55
+ resume: {
56
+ templates: ['wrestler-resume.md', 'wrestler-brief.md'],
57
+ rules: true,
58
+ },
59
+ cutoff: { templates: ['wrestler-cutoff.md'], rules: false },
60
+ },
61
+ cornerman: {
62
+ range: { templates: ['cornerman-range.md'], rules: true },
63
+ concept: { templates: ['cornerman-concept.md'], rules: true },
64
+ batch: { templates: ['cornerman-batch.md'], rules: true },
65
+ },
66
+ };
67
+
68
+ /** What the referee may leave out, and what stands in for it. */
69
+ const OPTIONAL = {
70
+ claims: 'none',
71
+ comments: 'none',
72
+ referencePattern: 'none yet: no round-bell approved so far',
73
+ designReference: 'not applicable: this concept draws no screen',
74
+ };
75
+
76
+ const git = (dir, args) =>
77
+ execFileSync('git', ['-C', dir, ...args], {
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ }).trim();
81
+
82
+ function flags(argv) {
83
+ const values = {};
84
+ const rest = [];
85
+ for (let i = 0; i < argv.length; i += 1) {
86
+ if (!argv[i].startsWith('--')) {
87
+ rest.push(argv[i]);
88
+ continue;
89
+ }
90
+ values[argv[i].slice(2)] = argv[i + 1];
91
+ i += 1;
92
+ }
93
+ return { values, rest };
94
+ }
95
+
96
+ /** The bout of `pr` in this repo: its dir, its state and its venue's config. */
97
+ function boutOf(pr) {
98
+ const dir = boutDir(repoSlug(projectDir()), pr);
99
+ if (!existsSync(join(dir, 'state.json')))
100
+ throw new Error(`no bout #${pr} in this repo (${dir})`);
101
+ const state = readState(dir);
102
+ const { config } = loadConfig(projectDir(), {
103
+ contract: false,
104
+ venue: state.venue ?? 'backend',
105
+ });
106
+ return { dir, state, config };
107
+ }
108
+
109
+ /** The rules block of a bout and its hash: what a brief embeds and what a seal is checked against. */
110
+ function rulesOf(config) {
111
+ const block = rulesBlock(loadRules(config));
112
+ return { block, hash: sha256(block) };
113
+ }
114
+
115
+ /** What the script knows about the bout and its worktree. */
116
+ function boutVars({ dir, state, config }, values) {
117
+ const { worktree } = state;
118
+ const head = git(worktree, ['rev-parse', 'HEAD']);
119
+ const range =
120
+ values.range ??
121
+ (state.lastReviewed ? `${state.lastReviewed}..${head}` : null);
122
+ const commits = range
123
+ ? git(worktree, ['log', '--reverse', '--format=- `%h` %s', range]) ||
124
+ '(none)'
125
+ : '(no range)';
126
+ const files = values.files
127
+ ? values.files
128
+ .split(',')
129
+ .map((file) => file.trim())
130
+ .filter(Boolean)
131
+ : range
132
+ ? git(worktree, ['diff', '--name-only', range])
133
+ .split('\n')
134
+ .filter(Boolean)
135
+ : [];
136
+ const gate = (name) => config.gates?.[name] ?? 'none configured';
137
+ return {
138
+ files,
139
+ vars: {
140
+ pr: state.pr,
141
+ branch: state.branch,
142
+ base: `origin/${state.base}`,
143
+ worktree,
144
+ boutDir: dir,
145
+ ring: RING_ROOT,
146
+ venue: config.venue,
147
+ range: range ?? '',
148
+ commits,
149
+ headSha: head.slice(0, 12),
150
+ lastSubject: git(worktree, ['log', '-1', '--format=%s']),
151
+ gateTypecheck: gate('typecheck'),
152
+ gateTest: gate('test'),
153
+ gateLint: gate('lint'),
154
+ forbidAiTrailers: String(config.forbidAiTrailers),
155
+ diskFloor: String(config.diskFloorGb ?? 5),
156
+ backupBranch: String(config.backupBranch ?? 'none').replace(
157
+ '{pr}',
158
+ state.pr,
159
+ ),
160
+ lenses: (config.lenses ?? []).join('; '),
161
+ leftoverPaths: files.join(', ') || 'the files of the range',
162
+ files: files.map((file) => `- ${file}`).join('\n'),
163
+ },
164
+ };
165
+ }
166
+
167
+ const real = (path) => (existsSync(path) ? realpathSync(path) : path);
168
+
169
+ async function generate(kind, [pr, ...argv]) {
170
+ const { values } = flags(argv);
171
+ const shape = SHAPES[kind]?.[values.mode];
172
+ if (!pr || !shape)
173
+ throw new Error(
174
+ `usage: ${kind} <pr> --mode ${Object.keys(SHAPES[kind]).join('|')} --vars <file.json>`,
175
+ );
176
+ const bout = boutOf(pr);
177
+ if (kind === 'wrestler' && relayDue(bout.state, bout.config.relayEvery))
178
+ throw new Error(
179
+ `a relay is due (${conceptsSinceRelay(bout.state)} concepts since the last one): rewrite the card's «How to resume», run judges-card.mjs relay ${pr}, then brief the next wrestler`,
180
+ );
181
+ const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
182
+ const roots = [
183
+ projectDir(),
184
+ bout.state.worktree,
185
+ bout.dir,
186
+ RING_ROOT,
187
+ ringHome(),
188
+ ].map(real);
189
+ const stray = strayPaths(own, roots);
190
+ if (stray.length)
191
+ throw new Error(
192
+ `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')}`,
193
+ );
194
+ const { vars, files } = boutVars(bout, values);
195
+ const text = fill(
196
+ shape.templates
197
+ .map((name) => readFileSync(join(TEMPLATES, name), 'utf8'))
198
+ .join('\n'),
199
+ { ...OPTIONAL, ...vars, ...own },
200
+ );
201
+ const decisionsPath = join(bout.dir, 'decisions.md');
202
+ const decisions = decisionsExcerpt(
203
+ existsSync(decisionsPath) ? readFileSync(decisionsPath, 'utf8') : '',
204
+ files,
205
+ );
206
+ const rules = rulesOf(bout.config);
207
+ // What earlier bouts of the repo learned on these files, from Engram when it answers: context
208
+ // after the decisions and before the rules, never in their place (a cut-off resumes as it was).
209
+ const learned = shape.rules
210
+ ? await recallSection(bout.config, {
211
+ slug: repoSlug(projectDir()),
212
+ pr,
213
+ files,
214
+ })
215
+ : null;
216
+ // The task goes first and the rules last, as the reference: a brief that opens with the rules
217
+ // reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
218
+ const body = [
219
+ text.trim(),
220
+ '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
221
+ decisions,
222
+ `The whole ledger: \`${decisionsPath}\`.`,
223
+ ...(learned ? [learned] : []),
224
+ ...(shape.rules ? ['---', rules.block] : []),
225
+ ].join('\n\n');
226
+ const briefs = join(bout.dir, 'briefs');
227
+ mkdirSync(briefs, { recursive: true });
228
+ const index = join(briefs, 'index.jsonl');
229
+ const n = existsSync(index)
230
+ ? readFileSync(index, 'utf8').split('\n').filter(Boolean).length + 1
231
+ : 1;
232
+ const meta = {
233
+ kind,
234
+ mode: values.mode,
235
+ pr,
236
+ n,
237
+ rules: shape.rules ? rules.hash : 'none',
238
+ };
239
+ const file = join(briefs, `${kind}-${values.mode}-${n}.md`);
240
+ writeFileSync(file, seal(body, meta));
241
+ appendFileSync(
242
+ index,
243
+ `${JSON.stringify({ ...meta, range: vars.range || null, at: new Date().toISOString(), vars: Object.keys(own) })}\n`,
244
+ );
245
+ const launch = bout.config.agents?.[kind] ?? {};
246
+ console.log(file);
247
+ console.log(
248
+ `Agent{subagent_type: "ring:${kind}", model: "${launch.model}", effort: "${launch.effort}", prompt: "ring-brief: ${file}"}`,
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Whether a brief's text still holds: sealed by brief.mjs, unedited, and on the rules in force
254
+ * today. Returns the seal's meta too (kind, mode, pr) so a caller can check it is the right brief.
255
+ */
256
+ export function checkBrief(text) {
257
+ const sealed = readSeal(text);
258
+ const rulesNow = (meta) =>
259
+ meta.rules === 'none' ? 'none' : rulesOf(boutOf(meta.pr).config).hash;
260
+ return {
261
+ ...briefStatus(text, sealed ? rulesNow(sealed.meta) : null),
262
+ meta: sealed?.meta ?? null,
263
+ };
264
+ }
265
+
266
+ function verify([path]) {
267
+ if (!path) throw new Error('usage: verify <brief.md>');
268
+ const status = checkBrief(readFileSync(path, 'utf8'));
269
+ console.log(
270
+ status.ok ? 'brief holds' : `brief does not hold: ${status.reason}`,
271
+ );
272
+ return status.ok ? 0 : 1;
273
+ }
274
+
275
+ if (isMain(import.meta.url)) {
276
+ const [command, ...rest] = process.argv.slice(2);
277
+ try {
278
+ if (command === 'verify') process.exit(verify(rest));
279
+ if (!SHAPES[command])
280
+ throw new Error(
281
+ 'usage: brief.mjs wrestler|cornerman <pr> --mode … | verify <brief.md>',
282
+ );
283
+ await generate(command, rest);
284
+ } catch (error) {
285
+ console.error(`brief: ${error.message}`);
286
+ process.exit(1);
287
+ }
288
+ }
@@ -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,37 @@ 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 architecture gate (arch-gate.mjs): the repo's layers, each { name, paths (globs),
146
+ // mayImport (layer names), forbid (package regexes) }. Empty: only the repo's dependency-cruiser.
147
+ layers: [],
148
+ // Files the layer check leaves out (null: tests, `*.spec.*`, `*.test.*`, `__tests__/`, `test/`).
149
+ layersIgnore: null,
150
+ // Globs where every NEW code file must fall in a layer (arch-gate flags the ones that do not).
151
+ layersRequired: [],
152
+ // Findings that cite one of these rules are never sent to a ticket or discarded by the referee
153
+ // alone: `findings.mjs ticket|discard` asks for --judge "<what the human said>". A cleanup bout
154
+ // adds the architecture rules (["P14", "R6", "R11", "R12", "R19", "R22"]).
155
+ findingsNeedJudge: ['P14'],
156
+ // Concepts after which the referee relays (rewrites the card's «How to resume» and runs
157
+ // judges-card.mjs relay) before brief.mjs briefs the next wrestler. null: never.
158
+ relayEvery: 4,
159
+ // Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
160
+ // fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
161
+ // rules. The scripts sync and read it when something answers at this URL (null: off). The
162
+ // project defaults to the repo name, as Engram names it.
163
+ engramUrl: 'http://127.0.0.1:7437',
164
+ engramProject: null,
165
+ // The model and effort of each ring agent, passed on every launch. Who writes runs on the
166
+ // cheaper model at high effort; who reviews keeps the strongest one (what slipped in bout #1223
167
+ // slipped in review). A host overrides a role in `.claude/ring.json` (`agents.wrestler.model`).
168
+ agents: Object.freeze({
169
+ wrestler: Object.freeze({ model: 'sonnet', effort: 'high' }),
170
+ rudo: Object.freeze({ model: 'sonnet', effort: 'high' }),
171
+ cornerman: Object.freeze({ model: 'opus', effort: 'high' }),
172
+ }),
139
173
  });
140
174
 
141
175
  const GATES = ['typecheck', 'test', 'lint'];
@@ -331,6 +365,10 @@ export function mergeConfig(detected, host) {
331
365
  throw new Error(
332
366
  `.claude/ring.json: "delegation" must be one of ${DELEGATION_LEVELS.join(', ')}, not ${JSON.stringify(own.delegation)}`,
333
367
  );
368
+ if ('briefGuard' in own && !BRIEF_GUARD_MODES.includes(own.briefGuard))
369
+ throw new Error(
370
+ `.claude/ring.json: "briefGuard" must be one of ${BRIEF_GUARD_MODES.join(', ')}, not ${JSON.stringify(own.briefGuard)}`,
371
+ );
334
372
  validateVenues(own.venues, VENUE_NAMES);
335
373
  return {
336
374
  ...DEFAULTS,
@@ -349,6 +387,12 @@ export function mergeConfig(detected, host) {
349
387
  ? null
350
388
  : { ...DEFAULTS.findingsGate, ...(own.findingsGate ?? {}) },
351
389
  sparring: { ...DEFAULTS.sparring, ...(own.sparring ?? {}) },
390
+ agents: Object.fromEntries(
391
+ Object.entries(DEFAULTS.agents).map(([role, launch]) => [
392
+ role,
393
+ { ...launch, ...(own.agents?.[role] ?? {}) },
394
+ ]),
395
+ ),
352
396
  };
353
397
  }
354
398