@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 +69 -1
- package/package.json +2 -2
- package/ring/agents/cornerman.md +7 -0
- package/ring/hooks/hooks.json +1 -1
- package/ring/scripts/arch-gate.mjs +407 -0
- package/ring/scripts/brief-guard.mjs +46 -2
- package/ring/scripts/brief.mjs +46 -2
- package/ring/scripts/config.mjs +24 -0
- package/ring/scripts/engram-memory.mjs +213 -0
- package/ring/scripts/engram.mjs +318 -0
- package/ring/scripts/findings.mjs +37 -4
- package/ring/scripts/judges-card.mjs +180 -7
- package/ring/scripts/mask-guard.mjs +33 -0
- package/ring/scripts/relay.mjs +127 -0
- package/ring/scripts/review-blocks.mjs +232 -0
- package/ring/scripts/tech-plan-log.mjs +34 -2
- package/ring/skills/no-surrender/SKILL.md +5 -0
- package/ring/skills/referee/SKILL.md +24 -3
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.
|
|
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": {
|
package/ring/agents/cornerman.md
CHANGED
|
@@ -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
|
package/ring/hooks/hooks.json
CHANGED
|
@@ -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;
|