@thebattlearena/corner 0.10.1-beta.78 → 0.10.1-beta.80

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 CHANGED
@@ -374,7 +374,7 @@ rama. El contrato para el proyecto está en `ring/skills/sparring/references/hos
374
374
  **Config del repo** (`.claude/ring.json`, opcional): `gates` (typecheck, tests por ruta con
375
375
  `{paths}`, lint), `validateCommand`, `rulebook` (por defecto el genérico del plugin),
376
376
  `protectedBranches`, `forbidAiTrailers` (por defecto `true`), `heavyCommands`, `delegation`,
377
- `precedents`, `monitors` (ver «Monitores del repo»), `briefGuard` (`warn`, `block` u `off`) y `agents` (modelo y esfuerzo por rol; por
377
+ `precedents`, `monitors` (ver «Monitores del repo»), `briefGuard` (`warn`, `block` u `off`), `layers`, `layersIgnore`, `layersRequired` y `findingsNeedJudge` (ver «Gate de arquitectura»), `relayEvery`, `humanReviewEvery` (ver «Revisión humana por bloques»), `engramUrl` y `engramProject` (ver «Memoria entre peleas»), y `agents` (modelo y esfuerzo por rol; por
378
378
  defecto el luchador y el rudo en `sonnet` con esfuerzo `high` y el esquinero en `opus` con `high`,
379
379
  p. ej. `"agents": { "wrestler": { "model": "opus" } }`). Sin el archivo, `ring` usa los gates que
380
380
  detecta en `package.json`, `pyproject.toml`, `go.mod` y el `Makefile`.
@@ -397,6 +397,74 @@ decisión queda en `<combate>/brief-guard.jsonl`. Y cuando la sesión del árbit
397
397
  `compact-hook` le devuelve (en menos de 10 000 caracteres) el combate, el checklist, sus faltas y el
398
398
  «cómo reanudar» de la tarjeta.
399
399
 
400
+ **Gate de arquitectura**: `ring/scripts/arch-gate.mjs check <worktree> --range A..B` revisa los
401
+ imports que el rango **agrega** contra las capas del repo y, si el repo tiene dependency-cruiser
402
+ (`.dependency-cruiser.cjs` y el paquete instalado), también lo corre, con su lista de violaciones
403
+ conocidas cuando la tiene. Cada violación es un hallazgo que cita P14. El esquinero lo corre en cada
404
+ modo y `mask-guard` en cada push a la rama del PR; la deuda que ya estaba no bloquea. Las capas van
405
+ en `.claude/ring.json`:
406
+
407
+ ```json
408
+ "layers": [
409
+ { "name": "domain", "paths": ["packages/contexts/*/domain/**"], "mayImport": [],
410
+ "forbid": ["^@nestjs/", "^typeorm$"] },
411
+ { "name": "application", "paths": ["apps/public-api/src/**/application/**"],
412
+ "mayImport": ["domain"] },
413
+ { "name": "infrastructure",
414
+ "paths": ["apps/public-api/src/**/typeorm/**", "apps/public-api/src/**/controllers/**"],
415
+ "mayImport": ["application", "domain"] }
416
+ ]
417
+ ```
418
+
419
+ Cuenta la **primera** capa cuya ruta coincide: van primero las más específicas (una
420
+ `infrastructure/` anidada dentro de una `application/` es infraestructura). Con `layersRequired`
421
+ (globs, p. ej. `["apps/public-api/src/**"]`) todo archivo **nuevo** bajo esas rutas tiene que caer en
422
+ alguna capa: el que no, es hallazgo, porque ninguna regla de dependencias lo alcanza. Si el worktree
423
+ de la pelea no tiene `.claude/ring.json` (un repo que lo guarda sin versionar), el gate usa el del
424
+ checkout principal del mismo repo. Los globs usan `**` y `*` (sin llaves `{a,b}`: una ruta por entrada). Las pruebas (`*.spec.*`, `*.test.*`, `__tests__/`, `test/`) quedan fuera por default: pueden armar un adaptador a propósito (`layersIgnore` cambia la lista). Los alias de `tsconfig.json` (`@arena/*`) se resuelven solos. Un repo sin capas ni dependency-cruiser
425
+ recibe un aviso al abrir la pelea (`judges-card open`). Y un hallazgo que cita una regla de
426
+ `findingsNeedJudge` (por defecto `["P14"]`; una pelea de limpieza agrega R6, R11, R12, R19 y R22) no
427
+ se manda a ticket ni se descarta sin `--judge "<lo que dijo el humano>"`.
428
+
429
+ **Un luchador nuevo por concepto, y el relevo del árbitro**: cuando el esquinero aprueba un
430
+ concepto, `judges-card.mjs concept <pr> "<concepto>" --range A..B` lo registra y retira a los
431
+ luchadores que lo cargaron; el siguiente concepto va a uno nuevo (`brief.mjs wrestler <pr> --mode
432
+ resume`), y `brief-guard` avisa (o bloquea) un `SendMessage` a uno retirado. Cada `relayEvery`
433
+ conceptos (4 por default, `null` lo apaga) el árbitro reescribe el «cómo reanudar» de la tarjeta, que
434
+ tiene que nombrar el último concepto, y corre `judges-card.mjs relay <pr>`; hasta entonces
435
+ `brief.mjs` no le arma instrucciones al siguiente luchador. Eso es lo que la sesión recupera al
436
+ compactarse. Y `judges-card.mjs close` no cierra mientras una propuesta de «For the rulebook /
437
+ precedents» (los `X0` incluidos) no tenga decisión: `judges-card.mjs proposal <pr> <n>
438
+ accept|reject --why "<lo que dijo el humano>"`.
439
+
440
+ **Revisión humana por bloques (PR Review Lab)**: cada punto del plan técnico es un bloque (`B<n>` es
441
+ el punto n). `judges-card.mjs plan <pr> estimate <n> --minutes <m> [--priority 1-4]` le pone estimado y
442
+ prioridad. Al pasar a «en curso» se anotan el inicio y el autor (el `user.email` de git del worktree),
443
+ y al pasar a «espera al juez» el fin y el rango (`--range A..B` lo fija a mano); todo va en
444
+ `plan.jsonl`, que es lo que lee el modo «Pelea del ring» de PR Review Lab. Cuando `humanReviewEvery`
445
+ bloques (2 por default, `null` lo apaga) esperan al juez, suena la campana en PR Review Lab y
446
+ `brief.mjs` no le arma instrucciones al siguiente luchador hasta que el humano revise ese corte en un
447
+ solo diff y firme un veredicto con Tape. PR Review Lab lo escribe en `<combate>/review-verdicts.jsonl`
448
+ y el ring lo lee en el siguiente brief o `summary`: si pasa, los bloques quedan en «hecho»; si no
449
+ pasa, vuelven a «en curso» con el feedback y el Tape como rebote, que el brief del siguiente luchador
450
+ trae al principio. Cada veredicto queda en `decisions.md` como decisión del juez (y en Engram).
451
+
452
+ **Memoria entre peleas (Engram, opcional)**: si en `engramUrl` (por default
453
+ `http://127.0.0.1:7437`) contesta un [Engram](https://github.com/Gentleman-Programming/engram)
454
+ (`engram serve`), los scripts le mandan lo que cada pelea aprendió: las faltas, las respuestas y
455
+ decisiones del juez, los precedentes aceptados, los hallazgos `X0` (con su estado) y el estado de la
456
+ pelea (cómo reanudar, plan y conceptos, que se actualiza en el mismo registro). Lo mandan
457
+ `judges-card.mjs` y `findings.mjs` después de cada comando, sin repetir nada (cada envío lleva un id
458
+ de operación sacado de su contenido, y los enviados quedan en `<combate>/engram-sent.json`). De
459
+ vuelta, `brief.mjs` agrega al brief, después de las decisiones y antes de las reglas, lo aprendido en
460
+ otras peleas del repo sobre los archivos del rango, y `judges-card.mjs summary` las faltas y veredictos
461
+ recientes del repo. Las reglas nunca van a Engram: siguen en archivos y el brief las trae completas;
462
+ lo que viene de Engram es contexto, no regla. Sin Engram (o con `engramUrl: null`) todo sigue igual.
463
+ `engram.mjs status` dice si contesta, `engram.mjs sync --all` sube las peleas que ya existían del repo
464
+ y `engram.mjs recall <pr> [--files a,b]` muestra lo que leería un brief. El proyecto es el nombre del
465
+ repo (`engramProject` lo cambia) y `RING_ENGRAM_URL` gana sobre el archivo (`off` lo apaga; así
466
+ corren las pruebas del plugin).
467
+
400
468
  **Delegación y precedentes**: con `delegation: "referee"` (el default) el árbitro decide él mismo
401
469
  las dudas de diseño con el reglamento y los precedentes, anota por qué en `decisions.md` y sigue;
402
470
  sólo te llega una lista corta (merges y bases, flags y variables de entorno, cambios visibles para
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.10.1-beta.78",
3
+ "version": "0.10.1-beta.80",
4
4
  "description": "Corner: tu esquina de Battle Arena en Claude Code (el plugin `battlearena`). Hooks de tracking, objetivo del turno, plan de batalla /battlearena:conquer, Ringside (el panel de tu día), el ring y la radio. Zero-dependency.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "bundle:skill": "node -e \"const{cpSync,rmSync,mkdirSync}=require('fs');const out='plugin/skills';rmSync(out,{recursive:true,force:true});mkdirSync(out,{recursive:true});for(const s of ['arena-objective','arena-self-review','execution-coach','scope-technical-task','pr-review-loop','conquer'])cpSync('../../.claude/skills/'+s,out+'/'+s,{recursive:true,dereference:true})\"",
35
35
  "prepack": "npm run bundle:skill",
36
- "test": "node --test test/*.test.mjs",
36
+ "test": "RING_ENGRAM_URL=off node --test test/*.test.mjs",
37
37
  "preview:ring": "node scripts/ring-preview.mjs"
38
38
  },
39
39
  "engines": {
@@ -53,6 +53,13 @@ clean:** sort-imports or prettier is not installed (`missing`), so the files in
53
53
  checked. Report it as its own line, `format — NO VERIFICADO — falta <missing> — <files>`, never as
54
54
  "no findings".
55
55
 
56
+ **Architecture, in every mode:** `node "<ring>/scripts/arch-gate.mjs" check <wt> --range <A>..<B>`
57
+ (read-only). It checks the imports the range adds against the repo's layers (`layers` in
58
+ `.claude/ring.json`) and runs the repo's dependency-cruiser when it has one. Each line it prints is
59
+ already a finding citing P14 (`<file:line> — P14 — <layer> imports <layer> … — …`): report it as
60
+ it is, as a BLOCKER (P14). Exit 2 means the repo has no gate: no finding, but say in one line that
61
+ layers were reviewed only by eye. A layer the gate does not know is still yours to read (lens 1).
62
+
56
63
  ## Mode: concept
57
64
 
58
65
  A deep review when a concept closes. The brief lists the commits with what each claims to do and a
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  {
14
- "matcher": "Agent|Task",
14
+ "matcher": "Agent|Task|SendMessage",
15
15
  "hooks": [
16
16
  {
17
17
  "type": "command",
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ // The architecture gate: which layer may import which, checked on what a range adds. Zero-dependency.
3
+ //
4
+ // node arch-gate.mjs check <worktree> --range <A>..<B> [--json] exit 0 clean, 1 violations,
5
+ // 2 nothing configured
6
+ // node arch-gate.mjs status <worktree> what the repo has, and how to
7
+ // add what it lacks
8
+ //
9
+ // Two sources, both when the repo has them:
10
+ // - ring's own scanner, from `layers` in `.claude/ring.json`: each layer has `paths` (globs) and
11
+ // `mayImport` (the layers it may depend on; its own is always allowed), and may `forbid` packages
12
+ // by regex (`"forbid": ["^@nestjs/", "^typeorm$"]` for a domain layer). Only imports on lines the
13
+ // range ADDS count: what was already there is debt, not this range's.
14
+ // - the repo's dependency-cruiser, when it has a config (`.dependency-cruiser.{js,cjs,mjs,json}`) and
15
+ // the binary: run on the files the range changed, with the repo's known-violations baseline when
16
+ // it keeps one (`--ignore-known`). Its `error` rules count; warnings are reported, not counted.
17
+ // Every violation is a finding that cites P14 (the host's rules are not the referee's decisions).
18
+ // mask-guard runs the same check on every push to the PR branch; `judges-card open` warns when a
19
+ // repo has neither source.
20
+ import { execFileSync, spawnSync } from 'node:child_process';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join, normalize, posix } from 'node:path';
23
+
24
+ import { isMain, loadConfig } from './config.mjs';
25
+ import { globToRegExp } from './contract.mjs';
26
+
27
+ const CODE = /\.(?:[cm]?[jt]sx?)$/;
28
+ const IMPORT =
29
+ /(?:\bimport\s+(?:type\s+)?(?:[\w*{}\s,$]+\s+from\s+)?|\bexport\s+(?:type\s+)?[\w*{}\s,$]+\s+from\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]([^'"\n]+)['"]/g;
30
+ const DEPCRUISE_CONFIGS = [
31
+ '.dependency-cruiser.js',
32
+ '.dependency-cruiser.cjs',
33
+ '.dependency-cruiser.mjs',
34
+ '.dependency-cruiser.json',
35
+ ];
36
+ const KNOWN_VIOLATIONS = '.dependency-cruiser-known-violations.json';
37
+
38
+ const git = (root, args) =>
39
+ execFileSync('git', ['-C', root, ...args], {
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ maxBuffer: 1 << 26,
43
+ });
44
+
45
+ /** The module specifiers a file imports, with their line (1-based). PURE. */
46
+ export function importSpecs(text) {
47
+ const out = [];
48
+ const lineAt = (index) => text.slice(0, index).split('\n').length;
49
+ for (const match of String(text).matchAll(IMPORT))
50
+ out.push({ spec: match[1], line: lineAt(match.index) });
51
+ return out;
52
+ }
53
+
54
+ /** The new-side line numbers a `git diff -U0` of one file adds. PURE. */
55
+ export function addedLines(diff) {
56
+ const added = new Set();
57
+ for (const hunk of String(diff).matchAll(
58
+ /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/gm,
59
+ )) {
60
+ const start = Number(hunk[1]);
61
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
62
+ for (let i = 0; i < count; i += 1) added.add(start + i);
63
+ }
64
+ return added;
65
+ }
66
+
67
+ const stripJsonComments = (text) =>
68
+ String(text)
69
+ .replace(/\/\*[\s\S]*?\*\//g, '')
70
+ .replace(/(^|[^:"'])\/\/.*$/gm, '$1')
71
+ .replace(/,(\s*[}\]])/g, '$1');
72
+
73
+ /** The path aliases of a tsconfig: [{prefix, targets}] with repo-relative targets. PURE. */
74
+ export function tsAliases(tsconfigText) {
75
+ try {
76
+ const { compilerOptions = {} } = JSON.parse(
77
+ stripJsonComments(tsconfigText),
78
+ );
79
+ const base = compilerOptions.baseUrl ?? '.';
80
+ return Object.entries(compilerOptions.paths ?? {}).map(
81
+ ([key, targets]) => ({
82
+ prefix: key.replace(/\*$/, ''),
83
+ exact: !key.endsWith('*'),
84
+ targets: targets.map((target) =>
85
+ posix.normalize(posix.join(base, target.replace(/\*$/, ''))),
86
+ ),
87
+ }),
88
+ );
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ /** Where a specifier points inside the repo (repo-relative), or null for a package. PURE. */
95
+ export function resolveSpec(fromFile, spec, aliases = []) {
96
+ if (spec.startsWith('.'))
97
+ return posix.normalize(posix.join(posix.dirname(fromFile), spec));
98
+ const alias = aliases.find(({ prefix, exact }) =>
99
+ exact ? spec === prefix : spec.startsWith(prefix),
100
+ );
101
+ if (!alias) return null;
102
+ return posix
103
+ .normalize(
104
+ `${alias.targets[0].replace(/\/$/, '')}/${spec.slice(alias.prefix.length)}`,
105
+ )
106
+ .replace(/\/$/, '');
107
+ }
108
+
109
+ /** The first layer whose globs hold `path` (a file or a module without extension), or null. PURE. */
110
+ export function layerOf(path, layers) {
111
+ return (
112
+ layers.find(({ paths }) =>
113
+ paths.some((glob) => {
114
+ const re = globToRegExp(glob.replace(/^\.\//, ''));
115
+ return (
116
+ re.test(path) || re.test(`${path}.ts`) || re.test(`${path}/index.ts`)
117
+ );
118
+ }),
119
+ ) ?? null
120
+ );
121
+ }
122
+
123
+ /** The violations of one file's added imports. PURE. */
124
+ export function fileViolations({ file, text, added, layers, aliases }) {
125
+ const from = layerOf(file, layers);
126
+ if (!from) return [];
127
+ const out = [];
128
+ for (const { spec, line } of importSpecs(text)) {
129
+ if (!added.has(line)) continue;
130
+ const target = resolveSpec(file, spec, aliases);
131
+ if (target === null) {
132
+ const banned = (from.forbid ?? []).find((pattern) =>
133
+ new RegExp(pattern).test(spec),
134
+ );
135
+ if (banned)
136
+ out.push({
137
+ file,
138
+ line,
139
+ spec,
140
+ from: from.name,
141
+ to: spec,
142
+ why: `the ${from.name} layer may not depend on ${spec} (forbid ${banned})`,
143
+ });
144
+ continue;
145
+ }
146
+ const to = layerOf(target, layers);
147
+ if (!to || to.name === from.name) continue;
148
+ if ((from.mayImport ?? []).includes(to.name)) continue;
149
+ out.push({
150
+ file,
151
+ line,
152
+ spec,
153
+ from: from.name,
154
+ to: to.name,
155
+ why: `${from.name} imports ${to.name} (${spec}); ${from.name} may import only ${(from.mayImport ?? []).join(', ') || 'itself'}`,
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** A violation as a finding line. PURE. */
162
+ export const findingLine = (v) =>
163
+ `${v.file}:${v.line} — P14 — ${v.why} — ${
164
+ v.spec === null
165
+ ? 'move it into the layer its job belongs to (domain, application or infrastructure)'
166
+ : 'depend on a port the inner layer owns, or move the code to the layer that may import it'
167
+ }`;
168
+
169
+ const depcruiseConfig = (root) =>
170
+ DEPCRUISE_CONFIGS.find((name) => existsSync(join(root, name))) ?? null;
171
+ const depcruiseBin = (root) =>
172
+ [join(root, 'node_modules', '.bin', 'depcruise')].find(existsSync) ?? null;
173
+
174
+ /** What the repo has for the gate, and what to add when it has nothing. */
175
+ export function archStatus(root, config) {
176
+ const layers = config.layers ?? [];
177
+ const cruiser = depcruiseConfig(root);
178
+ const bin = depcruiseBin(root);
179
+ return {
180
+ layers: layers.length,
181
+ dependencyCruiser: Boolean(cruiser && bin),
182
+ dependencyCruiserConfig: cruiser,
183
+ dependencyCruiserBinary: Boolean(bin),
184
+ configured: layers.length > 0 || Boolean(cruiser && bin),
185
+ };
186
+ }
187
+
188
+ export const SETUP_HINT =
189
+ 'add "layers" to .claude/ring.json (each with "name", "paths" and "mayImport"; see the ring README, «Gate de arquitectura») or a dependency-cruiser config (.dependency-cruiser.cjs) with the package installed';
190
+
191
+ /** Files the layer check leaves out: tests by default (they may wire adapters on purpose). */
192
+ export const DEFAULT_LAYERS_IGNORE = [
193
+ '**/*.spec.*',
194
+ '**/*.test.*',
195
+ '**/__tests__/**',
196
+ '**/test/**',
197
+ ];
198
+
199
+ const ignored = (file, globs) =>
200
+ globs.some((glob) => globToRegExp(glob).test(file));
201
+
202
+ function ownScan(root, range, config) {
203
+ const layers = config.layers ?? [];
204
+ if (!layers.length) return [];
205
+ const ignore = config.layersIgnore ?? DEFAULT_LAYERS_IGNORE;
206
+ const aliases = existsSync(join(root, 'tsconfig.json'))
207
+ ? tsAliases(readFileSync(join(root, 'tsconfig.json'), 'utf8'))
208
+ : [];
209
+ const head = range.split('..')[1] || 'HEAD';
210
+ const files = git(root, ['diff', '--name-only', '--diff-filter=ACMR', range])
211
+ .split('\n')
212
+ .filter(
213
+ (file) =>
214
+ CODE.test(file) && !ignored(file, ignore) && layerOf(file, layers),
215
+ );
216
+ const imports = files.flatMap((file) =>
217
+ fileViolations({
218
+ file,
219
+ text: git(root, ['show', `${head}:${file}`]),
220
+ added: addedLines(git(root, ['diff', '-U0', range, '--', file])),
221
+ layers,
222
+ aliases,
223
+ }),
224
+ );
225
+ const created = git(root, ['diff', '--name-only', '--diff-filter=A', range])
226
+ .split('\n')
227
+ .filter(Boolean);
228
+ return [...imports, ...placementViolations(created, config)];
229
+ }
230
+
231
+ /**
232
+ * The new files that fall in no layer although a `layersRequired` glob covers them: code that
233
+ * lives outside the architecture is invisible to the rest of the gate. Tests and the
234
+ * `layersIgnore` files are left out. PURE.
235
+ */
236
+ export function placementViolations(created, config) {
237
+ const required = config.layersRequired ?? [];
238
+ if (!required.length) return [];
239
+ const layers = config.layers ?? [];
240
+ const ignore = config.layersIgnore ?? DEFAULT_LAYERS_IGNORE;
241
+ return created
242
+ .filter(
243
+ (file) =>
244
+ CODE.test(file) &&
245
+ !ignored(file, ignore) &&
246
+ required.some((glob) => globToRegExp(glob).test(file)) &&
247
+ !layerOf(file, layers),
248
+ )
249
+ .map((file) => ({
250
+ file,
251
+ line: 1,
252
+ spec: null,
253
+ from: null,
254
+ to: null,
255
+ why: `a new file outside the layers (${layers.map((layer) => layer.name).join(', ')}), so no dependency rule reaches it`,
256
+ }));
257
+ }
258
+
259
+ function cruise(root, range) {
260
+ const cruiser = depcruiseConfig(root);
261
+ const bin = depcruiseBin(root);
262
+ if (!cruiser || !bin) return { ran: false, errors: [], warnings: [] };
263
+ const files = git(root, ['diff', '--name-only', '--diff-filter=ACMR', range])
264
+ .split('\n')
265
+ .filter((file) => CODE.test(file) && existsSync(join(root, file)));
266
+ if (!files.length) return { ran: true, errors: [], warnings: [] };
267
+ const known = existsSync(join(root, KNOWN_VIOLATIONS))
268
+ ? ['--ignore-known', KNOWN_VIOLATIONS]
269
+ : [];
270
+ const run = spawnSync(
271
+ bin,
272
+ ['--config', cruiser, ...known, '--output-type', 'json', ...files],
273
+ { cwd: root, encoding: 'utf8', maxBuffer: 1 << 26 },
274
+ );
275
+ let violations = [];
276
+ try {
277
+ violations = JSON.parse(run.stdout).summary?.violations ?? [];
278
+ } catch {
279
+ return { ran: false, errors: [], warnings: [], failure: run.stderr.trim() };
280
+ }
281
+ const toFinding = (v) => ({
282
+ file: v.from,
283
+ line: 1,
284
+ spec: v.to,
285
+ from: v.rule?.name ?? 'dependency-cruiser',
286
+ to: v.to,
287
+ why: `dependency-cruiser ${v.rule?.name}: ${v.from} → ${v.to}`,
288
+ });
289
+ return {
290
+ ran: true,
291
+ errors: violations
292
+ .filter((v) => v.rule?.severity === 'error')
293
+ .map(toFinding),
294
+ warnings: violations
295
+ .filter((v) => v.rule?.severity !== 'error')
296
+ .map(toFinding),
297
+ };
298
+ }
299
+
300
+ /** The gate on a range: { status, violations, warnings, notes }. */
301
+ export function archCheck(root, range, config) {
302
+ const status = archStatus(root, config);
303
+ if (!status.configured)
304
+ return { status, violations: [], warnings: [], notes: [SETUP_HINT] };
305
+ const own = ownScan(root, range, config);
306
+ const repo = cruise(root, range);
307
+ return {
308
+ status,
309
+ violations: [...own, ...repo.errors],
310
+ warnings: repo.warnings,
311
+ notes: repo.failure
312
+ ? [`dependency-cruiser did not run: ${repo.failure}`]
313
+ : [],
314
+ };
315
+ }
316
+
317
+ /**
318
+ * The config the gate reads for a checkout: its own `.claude/ring.json`, or, when it has none (a
319
+ * bout's worktree of a repo that keeps the file untracked), the main checkout's of the same repo.
320
+ */
321
+ export function archConfig(root, venue = 'backend') {
322
+ const read = (dir) => loadConfig(dir, { contract: false, venue }).config;
323
+ if (existsSync(join(root, '.claude', 'ring.json'))) return read(root);
324
+ try {
325
+ const common = git(root, [
326
+ 'rev-parse',
327
+ '--path-format=absolute',
328
+ '--git-common-dir',
329
+ ]).trim();
330
+ const main = posix.dirname(common);
331
+ if (main !== root && existsSync(join(main, '.claude', 'ring.json')))
332
+ return read(main);
333
+ } catch {
334
+ // Not a git checkout: its own config, defaults included.
335
+ }
336
+ return read(root);
337
+ }
338
+
339
+ /** The gate for a push to a bout's PR branch: { refusal } (null when it passes or is not configured). */
340
+ export function boutArchGate(state, sha) {
341
+ const root = state.worktree;
342
+ const config = archConfig(root, state.venue ?? 'backend');
343
+ if (!archStatus(root, config).configured) return { refusal: null };
344
+ const baseRef = [`origin/${state.base}`, state.base].find((ref) => {
345
+ try {
346
+ git(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]);
347
+ return true;
348
+ } catch {
349
+ return false;
350
+ }
351
+ });
352
+ if (!baseRef) return { refusal: null };
353
+ const base = git(root, ['merge-base', baseRef, sha]).trim();
354
+ const { violations } = archCheck(root, `${base}..${sha}`, config);
355
+ return {
356
+ refusal: violations.length
357
+ ? `${violations.length} architecture violation(s) the range adds (P14):\n${violations.map(findingLine).join('\n')}\nFix them, or take the human's decision to the hard list.`
358
+ : null,
359
+ };
360
+ }
361
+
362
+ function flags(argv) {
363
+ const values = {};
364
+ const rest = [];
365
+ for (let i = 0; i < argv.length; i += 1) {
366
+ if (argv[i] === '--json') values.json = true;
367
+ else if (argv[i].startsWith('--')) values[argv[i].slice(2)] = argv[++i];
368
+ else rest.push(argv[i]);
369
+ }
370
+ return { values, rest };
371
+ }
372
+
373
+ if (isMain(import.meta.url)) {
374
+ const [command, ...argv] = process.argv.slice(2);
375
+ const { values, rest } = flags(argv);
376
+ const root = rest[0] ? normalize(rest[0]) : process.cwd();
377
+ try {
378
+ const config = archConfig(root);
379
+ if (command === 'status') {
380
+ const status = archStatus(root, config);
381
+ console.log(JSON.stringify(status, null, 2));
382
+ if (!status.configured)
383
+ console.error(`arch-gate: nothing configured; ${SETUP_HINT}`);
384
+ process.exit(status.configured ? 0 : 2);
385
+ }
386
+ if (command !== 'check' || !values.range)
387
+ throw new Error(
388
+ 'usage: arch-gate.mjs check <worktree> --range <A>..<B> [--json] | status <worktree>',
389
+ );
390
+ const result = archCheck(root, values.range, config);
391
+ if (values.json) console.log(JSON.stringify(result, null, 2));
392
+ else if (!result.status.configured)
393
+ console.log(`arch — NO VERIFICADO — ${SETUP_HINT}`);
394
+ else {
395
+ for (const v of result.violations) console.log(findingLine(v));
396
+ for (const w of result.warnings) console.log(`(warning) ${w.why}`);
397
+ for (const note of result.notes) console.log(`(note) ${note}`);
398
+ if (!result.violations.length) console.log('arch: clean');
399
+ }
400
+ process.exit(
401
+ !result.status.configured ? 2 : result.violations.length ? 1 : 0,
402
+ );
403
+ } catch (error) {
404
+ console.error(`arch-gate: ${error.message}`);
405
+ process.exit(3);
406
+ }
407
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Hook on PreToolUse (Agent|Task). In a session registered in a bout, a wrestler or a cornerman is
2
+ // Hook on PreToolUse (Agent|Task|SendMessage). In a session registered in a bout, a wrestler or a cornerman is
3
3
  // launched with a brief brief.mjs generated, never one written by hand. Zero-dependency.
4
4
  //
5
5
  // - The referee launches with `prompt: "ring-brief: <path>"`: the guard reads the file, checks its
@@ -8,7 +8,9 @@
8
8
  // - A prompt that is the brief itself is checked the same way and only gets the model and effort.
9
9
  // - A brief that does not hold: `briefGuard: "warn"` (the default) lets the launch through and tells
10
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`.
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.
12
14
  //
13
15
  // Sessions that are not the bout's, agents that are not the ring's and the rudo are left alone.
14
16
  // It never fails a tool call by accident: any error of its own ends in exit 0 with no output.
@@ -94,9 +96,51 @@ export function answer({ input, brief, problem, mode, role, pr, launch }) {
94
96
  };
95
97
  }
96
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
+
97
140
  /** The guard on one hook input; returns what to print (null for nothing). */
98
141
  export function guard(input) {
99
142
  if (input?.hook_event_name !== 'PreToolUse') return null;
143
+ if (input.tool_name === 'SendMessage') return guardMessage(input);
100
144
  if (!AGENT_TOOLS.includes(input.tool_name)) return null;
101
145
  const role = ROLE[classifyAgent(input.tool_input)];
102
146
  if (!role) return null;