@thebattlearena/corner 0.10.0 → 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/ring/agents/cornerman.md +14 -5
- package/ring/agents/rudo.md +2 -1
- package/ring/agents/wrestler.md +8 -4
- package/ring/hooks/hooks.json +22 -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 +24 -0
- package/ring/scripts/findings.mjs +79 -2
- package/ring/scripts/rule-ids.mjs +124 -0
- package/ring/skills/referee/SKILL.md +46 -15
- package/ring/skills/referee/references/rulebook.md +33 -3
- 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/wrestler-brief.md +28 -44
- 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 +3 -2
- package/ring/skills/title-defense/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -123,28 +123,28 @@ El detalle (esquema, íconos y colores válidos) está en «Monitores del repo»
|
|
|
123
123
|
Con la fuente Battle Arena Icons instalada (la pone `install`), estos símbolos se dibujan con sus
|
|
124
124
|
glifos; sin ella, con el carácter de la tabla.
|
|
125
125
|
|
|
126
|
-
| Ícono
|
|
127
|
-
|
|
|
128
|
-
| ✓
|
|
129
|
-
| ↻
|
|
130
|
-
| ↷
|
|
131
|
-
| ○
|
|
132
|
-
| ●
|
|
133
|
-
| ⚠
|
|
134
|
-
| ✗
|
|
135
|
-
| ✎
|
|
136
|
-
| ✋
|
|
137
|
-
| 👍 👎
|
|
138
|
-
| 📅
|
|
139
|
-
| 💡
|
|
140
|
-
| 🧪
|
|
141
|
-
| ▸ ▾ ▴
|
|
142
|
-
| ↗
|
|
143
|
-
| ♪ ▶ ⏸ ▲
|
|
144
|
-
| spinner
|
|
145
|
-
| ■ (ámbar)
|
|
146
|
-
| 👊N (rojo) | ring
|
|
147
|
-
| ◣
|
|
126
|
+
| Ícono | Dónde | Significa |
|
|
127
|
+
| ---------- | ------------------- | ------------------------------------------------------- |
|
|
128
|
+
| ✓ | punto, banda | entregado |
|
|
129
|
+
| ↻ | punto, banda | en curso: tiene tiempo hoy |
|
|
130
|
+
| ↷ | punto, banda | desplazado por un emergente |
|
|
131
|
+
| ○ | punto, banda | por hacer: sin tiempo todavía |
|
|
132
|
+
| ● | punto, en el cierre | avanzó pero no se entregó |
|
|
133
|
+
| ⚠ | banda, riesgos | riesgo abierto; en una ficha, «va con avisos» |
|
|
134
|
+
| ✗ | ficha | un paso falló |
|
|
135
|
+
| ✎ | emergente, banda | falta responder el motivo |
|
|
136
|
+
| ✋ | riesgo | levantar la mano: avisa a tu líder |
|
|
137
|
+
| 👍 👎 | riesgo | si el aviso de riesgo acertó o no (ajusta la detección) |
|
|
138
|
+
| 📅 | punto | reunión ligada |
|
|
139
|
+
| 💡 | punto, complemento | tiempo en cabina |
|
|
140
|
+
| 🧪 | punto, complemento | sesión de Tape |
|
|
141
|
+
| ▸ ▾ ▴ | filas | desplegar, contraer, cerrar el detalle |
|
|
142
|
+
| ↗ | ligas | abrir en la web |
|
|
143
|
+
| ♪ ▶ ⏸ ▲ | radio | la canción, escuchar, dejar de escuchar, votar |
|
|
144
|
+
| spinner | ring | alguien del combate está trabajando |
|
|
145
|
+
| ■ (ámbar) | ring | te toca decidir |
|
|
146
|
+
| 👊N (rojo) | ring | Copilot dejó N hilos |
|
|
147
|
+
| ◣ | ficha de Copilot | en la esquina: Copilot está revisando |
|
|
148
148
|
|
|
149
149
|
Las fichas del ring son el **árbitro**, el **luchador**, el **esquinero**, la **campana** (round-bell),
|
|
150
150
|
**Copilot** y el **juez** (tú). Su borde dice el estado: gris quieta, azul trabajando, morada en la
|
|
@@ -167,8 +167,8 @@ Se invocan escribiendo el comando o pidiéndolo con tus palabras («arma mi plan
|
|
|
167
167
|
|
|
168
168
|
### Comandos
|
|
169
169
|
|
|
170
|
-
| Comando
|
|
171
|
-
|
|
|
170
|
+
| Comando | Para qué sirve |
|
|
171
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
172
172
|
| `/ringside` | Abre **Ringside**: la pelea de tu día junto al chat. Tu plan con el avance y el tiempo de cada punto contra lo comprometido (semáforo verde · amarillo · rojo), los emergentes (clic para ver su motivo), los riesgos siempre visibles con ✋ Levantar la mano y 👍/👎, y el pie con Plan · Reuniones · Fuera · Total. Pestaña **Semana** con tus hábitos. Sin datos, te dice el **siguiente paso** (instalar, armar el plan, entrar a la daily). `/scorecard`, su nombre anterior, lo sigue abriendo con un aviso. |
|
|
173
173
|
|
|
174
174
|
Arriba del prompt sale además una **banda** compacta de Ringside (`LER-640 ✓ 1 ↻ 1 ↷ 1 ⚠ 1 Abrir`, cada contador con su color).
|
|
@@ -374,8 +374,28 @@ 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
|
|
378
|
-
`
|
|
377
|
+
`precedents`, `monitors` (ver «Monitores del repo»), `briefGuard` (`warn`, `block` u `off`) y `agents` (modelo y esfuerzo por rol; por
|
|
378
|
+
defecto el luchador y el rudo en `sonnet` con esfuerzo `high` y el esquinero en `opus` con `high`,
|
|
379
|
+
p. ej. `"agents": { "wrestler": { "model": "opus" } }`). Sin el archivo, `ring` usa los gates que
|
|
380
|
+
detecta en `package.json`, `pyproject.toml`, `go.mod` y el `Makefile`.
|
|
381
|
+
|
|
382
|
+
**Briefs generados, no escritos**: el árbitro no le escribe a mano las instrucciones al luchador
|
|
383
|
+
ni al esquinero. `ring/scripts/brief.mjs` las arma desde los templates con el reglamento y los
|
|
384
|
+
precedentes embebidos completos (abriendo con un checklist de arquitectura: lo que más se escapa en
|
|
385
|
+
una revisión línea por línea), las decisiones que importan (todas las faltas, las de esos archivos
|
|
386
|
+
y las últimas 20) y los commits del rango; las sella y las registra en
|
|
387
|
+
`<combate>/briefs/index.jsonl`. El árbitro sólo pone sus valores (el encargo, las invariantes…).
|
|
388
|
+
Todo hallazgo de un esquinero o de una revisión tuya cita una regla del reglamento
|
|
389
|
+
(`<archivo:línea> — R11 — …`): `findings.mjs add` rechaza el que no la trae, y uno que ninguna regla
|
|
390
|
+
cubre lleva `X0` y queda como propuesta para el reglamento.
|
|
391
|
+
El árbitro lanza al luchador y al esquinero con `prompt: "ring-brief: <archivo>"`: el hook
|
|
392
|
+
`brief-guard` revisa el sello (generado por `brief.mjs`, sin editar, con las reglas de hoy, de este
|
|
393
|
+
combate y de ese rol), pone el texto del archivo como instrucción y el modelo y esfuerzo del rol.
|
|
394
|
+
Con `briefGuard: "warn"` (el default en `.claude/ring.json`) un brief que no cumple pasa y el árbitro
|
|
395
|
+
recibe el aviso; con `"block"` se rechaza con el comando que lo genera; `"off"` lo apaga. Cada
|
|
396
|
+
decisión queda en `<combate>/brief-guard.jsonl`. Y cuando la sesión del árbitro se compacta, el hook
|
|
397
|
+
`compact-hook` le devuelve (en menos de 10 000 caracteres) el combate, el checklist, sus faltas y el
|
|
398
|
+
«cómo reanudar» de la tarjeta.
|
|
379
399
|
|
|
380
400
|
**Delegación y precedentes**: con `delegation: "referee"` (el default) el árbitro decide él mismo
|
|
381
401
|
las dudas de diseño con el reglamento y los precedentes, anota por qué en `decisions.md` y sigue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thebattlearena/corner",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1-beta.78",
|
|
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": {
|
package/ring/agents/cornerman.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: cornerman
|
|
3
3
|
description: The ring's read-only reviewer. Reviews a fixed commit range against the rulebook in one of three modes the referee names in its brief, range (≤15 lines of findings), concept (numbered behavior invariants, tests on a `git archive` copy) or batch (≤20 files through the lenses of the bout's venue). Never edits, commits or touches anyone's index. Launched by `/ring:referee` at high effort.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: opus
|
|
5
6
|
effort: high
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -10,9 +11,14 @@ command that writes the index or the tree of someone else's worktree. You read w
|
|
|
10
11
|
`git -C <worktree> log|show|diff <refs>` and with file reads. Your cwd resets between commands,
|
|
11
12
|
so every git command carries `-C`.
|
|
12
13
|
|
|
13
|
-
Your brief names the **mode**, the worktree, the base
|
|
14
|
-
decisions
|
|
15
|
-
|
|
14
|
+
Your brief names the **mode**, the worktree, the base and the exact range, and carries the
|
|
15
|
+
decisions and then the rules in full at its end (`brief.mjs` embeds them: the architecture
|
|
16
|
+
checklist first, then the rulebook and the precedents). If your prompt is only `ring-brief:
|
|
17
|
+
<path>`, read that file whole: it is your brief. The base is a remote ref (`origin/main`); if the
|
|
18
|
+
brief names a bare branch, use its `origin/` ref, never the local branch, which may be behind.
|
|
19
|
+
Read the rules and the decisions first, the checklist before anything: what slips past a
|
|
20
|
+
line-by-line review is there. **Every finding cites the rule it breaks in its second field**, or
|
|
21
|
+
`X0` when no rule covers it; the referee's ledger refuses a finding without one. Review **only the lines the
|
|
16
22
|
range adds or changes**: no taste, no debt that was already there.
|
|
17
23
|
|
|
18
24
|
The referee filters your report against the base before acting on it, so be exact: every finding
|
|
@@ -88,8 +94,11 @@ bout's venue). For a `backend` bout they are these 8:
|
|
|
88
94
|
with its business sentence (R15), the backend shaped by the business and not by a screen
|
|
89
95
|
(R16), every optional with its reason (R17), aggregates born valid (R18), no primitives
|
|
90
96
|
crossing commands, events or ports (R19), no new endpoint an existing one covers (R20), names
|
|
91
|
-
that do not collide with another context (R21)
|
|
92
|
-
|
|
97
|
+
that do not collide with another context (R21), no ternary or optional filling a gap that
|
|
98
|
+
should not exist (R23), types derived from their source (R24), provider responses through an
|
|
99
|
+
anti-corruption layer (R25), business metrics apart from monitoring (R26), a cross-cutting
|
|
100
|
+
precondition checked once (R27). A deviation from a pattern the host's rules fix is a BLOCKER
|
|
101
|
+
(P14).
|
|
93
102
|
6. Errors: does it fail open or closed, who catches what.
|
|
94
103
|
7. Expressiveness: names, more than 3 parameters, control booleans.
|
|
95
104
|
8. Production risk: concurrency, idempotency, volume, reversibility, and a new infrastructure
|
package/ring/agents/rudo.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name: rudo
|
|
3
3
|
description: The ring's sparring executor. Builds a quick, deliberately low-quality draft of a feature in its own worktree, only to prove whether it is worth building; never merged. Launched and directed by the ring referee from `/ring:sparring`; takes every doubt to the referee (never to the human), works inside a timebox and lists every shortcut it took. Suspends the style and test rules, never the live-operation ones.
|
|
4
4
|
tools: Read, Grep, Glob, Edit, Write, Bash
|
|
5
|
-
|
|
5
|
+
model: sonnet
|
|
6
|
+
effort: high
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
You are the rudo: in lucha libre the rudo wins however he can, the técnico wins clean. You are
|
package/ring/agents/wrestler.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name: wrestler
|
|
3
3
|
description: The ring's executor. Applies a refactor or a correction package in its own worktree, one concept at a time, without changing behavior, with the host repo's gates and small thematic commits. Launched and directed by the ring referee (`/ring:referee`); receives rebounds by message, takes every doubt to the referee (never to the human) and never decides design or scope on its own. Does not review its own work with reviewers and does not open ready PRs.
|
|
4
4
|
tools: Read, Grep, Glob, Edit, Write, Bash
|
|
5
|
-
|
|
5
|
+
model: sonnet
|
|
6
|
+
effort: high
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
You write code; someone else reviews it. Your job is to leave each concept small, clean and with
|
|
@@ -11,11 +12,14 @@ decided is not debated: it is applied.
|
|
|
11
12
|
|
|
12
13
|
## Sources
|
|
13
14
|
|
|
14
|
-
- Your brief
|
|
15
|
+
- Your brief (if your prompt is only `ring-brief: <path>`, read that file whole: it is your
|
|
16
|
+
brief): the assignment, the worktree, the branch, the base, the order of concepts, the
|
|
15
17
|
frozen zones, the decisions already taken, what waits on the human, and the absolute path of the
|
|
16
18
|
ring scripts (`<ring>/scripts`).
|
|
17
|
-
- The
|
|
18
|
-
|
|
19
|
+
- The rules, embedded in full at the end of your brief by `brief.mjs`: the architecture checklist
|
|
20
|
+
first, then the rulebook and the precedents. They are the ones in force; apply them while you
|
|
21
|
+
write and check the checklist before every commit. Rules are cited by number (R, T, F, P),
|
|
22
|
+
precedents by id (PRn bundled, HPn the host's).
|
|
19
23
|
- Every doubt goes to the referee in your report, never to the human: the referee decides it or
|
|
20
24
|
escalates it.
|
|
21
25
|
- The host repo's own instructions (`AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` if present).
|
package/ring/hooks/hooks.json
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
|
+
{
|
|
14
|
+
"matcher": "Agent|Task",
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/brief-guard.mjs\"",
|
|
19
|
+
"timeout": 10
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
13
23
|
{
|
|
14
24
|
"matcher": "Agent|Task|AskUserQuestion|Bash",
|
|
15
25
|
"hooks": [
|
|
@@ -97,6 +107,18 @@
|
|
|
97
107
|
}
|
|
98
108
|
]
|
|
99
109
|
}
|
|
110
|
+
],
|
|
111
|
+
"SessionStart": [
|
|
112
|
+
{
|
|
113
|
+
"matcher": "compact",
|
|
114
|
+
"hooks": [
|
|
115
|
+
{
|
|
116
|
+
"type": "command",
|
|
117
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/compact-hook.mjs\"",
|
|
118
|
+
"timeout": 10
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
100
122
|
]
|
|
101
123
|
}
|
|
102
124
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The pure rules of a generated brief (brief.mjs is its CLI): the rules block, the excerpt of
|
|
2
|
+
// decisions, the filling of a template with no placeholder left, the seal that proves the brief
|
|
3
|
+
// came from the script with the rules in force, and the paths a brief may point to. Zero-dependency.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { basename, isAbsolute, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { parseDecisionLine } from './chronicle.mjs';
|
|
8
|
+
import { checklistLines } from './rule-ids.mjs';
|
|
9
|
+
|
|
10
|
+
export const BRIEF_VERSION = 'v1';
|
|
11
|
+
const SEAL = /^<!-- ring-brief (v\d+) (.*) -->$/;
|
|
12
|
+
|
|
13
|
+
export const sha256 = (text) =>
|
|
14
|
+
`sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The rules every brief carries, in full and before anything else (the same text for every brief
|
|
18
|
+
* of a venue, so a model can reuse it): the architecture checklist, then each rule file. PURE.
|
|
19
|
+
*/
|
|
20
|
+
export function rulesBlock({ sources, entries }) {
|
|
21
|
+
const files = sources.map(
|
|
22
|
+
({ path, text }) =>
|
|
23
|
+
`<rules file="${basename(path)}">\n${text.trim()}\n</rules>`,
|
|
24
|
+
);
|
|
25
|
+
return [
|
|
26
|
+
'# The rules in force for this bout',
|
|
27
|
+
'',
|
|
28
|
+
'Embedded in full by `brief.mjs`: these are the rules you apply and cite by id. Nothing you',
|
|
29
|
+
'remember from another bout replaces them.',
|
|
30
|
+
'',
|
|
31
|
+
'## Architecture checklist (what slips past a line-by-line review; check it first)',
|
|
32
|
+
'',
|
|
33
|
+
...checklistLines(entries),
|
|
34
|
+
'',
|
|
35
|
+
...files,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The decisions a brief carries: every foul (what must not be repeated), the ones whose text names
|
|
41
|
+
* a file of the range, and the last `last`; in ledger order, each once. PURE.
|
|
42
|
+
*/
|
|
43
|
+
export function decisionsExcerpt(
|
|
44
|
+
decisionsText,
|
|
45
|
+
files = [],
|
|
46
|
+
{ last = 20 } = {},
|
|
47
|
+
) {
|
|
48
|
+
const lines = String(decisionsText ?? '')
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter((line) => parseDecisionLine(line)?.id);
|
|
51
|
+
const names = files.map((file) => basename(file)).filter(Boolean);
|
|
52
|
+
const tail = new Set(lines.slice(-last));
|
|
53
|
+
const kept = lines.filter(
|
|
54
|
+
(line) =>
|
|
55
|
+
tail.has(line) ||
|
|
56
|
+
/\bFOUL\b/.test(line) ||
|
|
57
|
+
names.some((name) => line.includes(name)),
|
|
58
|
+
);
|
|
59
|
+
return kept.length ? kept.join('\n') : '(no decisions yet)';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The template with every `{{name}}` filled; throws naming each one left empty. PURE. */
|
|
63
|
+
export function fill(template, vars) {
|
|
64
|
+
const missing = new Set();
|
|
65
|
+
const text = template.replace(/\{\{(\w+)\}\}/g, (whole, name) => {
|
|
66
|
+
const value = vars[name];
|
|
67
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
68
|
+
missing.add(name);
|
|
69
|
+
return whole;
|
|
70
|
+
}
|
|
71
|
+
return String(value);
|
|
72
|
+
});
|
|
73
|
+
if (missing.size)
|
|
74
|
+
throw new Error(`the brief is missing: ${[...missing].join(', ')}`);
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The last line of a brief: what it is and the hashes that prove it. PURE. */
|
|
79
|
+
export function seal(body, meta) {
|
|
80
|
+
const fields = Object.entries({ ...meta, body: sha256(body) })
|
|
81
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
82
|
+
.join(' ');
|
|
83
|
+
return `${body}\n<!-- ring-brief ${BRIEF_VERSION} ${fields} -->\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The seal of a brief, its body and whether the body is the one sealed; null without a seal. PURE. */
|
|
87
|
+
export function readSeal(text) {
|
|
88
|
+
const lines = String(text).replace(/\n$/, '').split('\n');
|
|
89
|
+
const match = SEAL.exec(lines.at(-1) ?? '');
|
|
90
|
+
if (!match) return null;
|
|
91
|
+
const meta = Object.fromEntries(
|
|
92
|
+
match[2].split(' ').map((pair) => pair.split('=')),
|
|
93
|
+
);
|
|
94
|
+
const body = lines.slice(0, -1).join('\n');
|
|
95
|
+
return { version: match[1], meta, body, intact: sha256(body) === meta.body };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a sealed brief still holds: its body untouched and its rules the ones in force now
|
|
100
|
+
* (`rulesHash`). The reason when it does not. PURE.
|
|
101
|
+
*/
|
|
102
|
+
export function briefStatus(text, rulesHash) {
|
|
103
|
+
const sealed = readSeal(text);
|
|
104
|
+
if (!sealed)
|
|
105
|
+
return { ok: false, reason: 'not generated by brief.mjs (no seal)' };
|
|
106
|
+
if (!sealed.intact)
|
|
107
|
+
return { ok: false, reason: 'edited by hand after it was generated' };
|
|
108
|
+
if (sealed.meta.rules && sealed.meta.rules !== rulesHash)
|
|
109
|
+
return { ok: false, reason: 'the rules changed since; generate it again' };
|
|
110
|
+
return { ok: true, meta: sealed.meta };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ABSOLUTE_PATH = /(?:^|[\s`'"(])(\/[^\s`'"()]+)/g;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The absolute paths in the referee's values that point outside the places a brief may send an
|
|
117
|
+
* agent (the repo, the worktree, the bout dir, the ring): a session scratchpad is wiped on restart,
|
|
118
|
+
* so nothing the human asked for may live there. PURE.
|
|
119
|
+
*/
|
|
120
|
+
export function strayPaths(vars, roots) {
|
|
121
|
+
const inside = (path) =>
|
|
122
|
+
roots.some((root) => path === root || path.startsWith(root + sep));
|
|
123
|
+
return Object.entries(vars).flatMap(([name, value]) =>
|
|
124
|
+
[...String(value ?? '').matchAll(ABSOLUTE_PATH)]
|
|
125
|
+
.map((match) => match[1].replace(/[.,;:]+$/, ''))
|
|
126
|
+
.filter((path) => isAbsolute(path))
|
|
127
|
+
.filter((path) => !inside(path))
|
|
128
|
+
.map((path) => ({ name, path })),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hook on PreToolUse (Agent|Task). In a session registered in a bout, a wrestler or a cornerman is
|
|
3
|
+
// launched with a brief brief.mjs generated, never one written by hand. Zero-dependency.
|
|
4
|
+
//
|
|
5
|
+
// - The referee launches with `prompt: "ring-brief: <path>"`: the guard reads the file, checks its
|
|
6
|
+
// seal (generated, unedited, on today's rules, of this bout and of this role) and replaces the
|
|
7
|
+
// prompt with the file's text, with the model and effort `.claude/ring.json` sets for the role.
|
|
8
|
+
// - A prompt that is the brief itself is checked the same way and only gets the model and effort.
|
|
9
|
+
// - A brief that does not hold: `briefGuard: "warn"` (the default) lets the launch through and tells
|
|
10
|
+
// the referee why; `"block"` refuses it with the command that generates one; `"off"` does nothing.
|
|
11
|
+
// Every decision goes to `<bout dir>/brief-guard.jsonl`.
|
|
12
|
+
//
|
|
13
|
+
// Sessions that are not the bout's, agents that are not the ring's and the rudo are left alone.
|
|
14
|
+
// It never fails a tool call by accident: any error of its own ends in exit 0 with no output.
|
|
15
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { checkBrief } from './brief.mjs';
|
|
19
|
+
import { isMain, loadConfig, projectDir, readState } from './config.mjs';
|
|
20
|
+
import { classifyAgent } from './live-hook.mjs';
|
|
21
|
+
import { findBout, sessionRegistered } from './live.mjs';
|
|
22
|
+
|
|
23
|
+
export const GUARD_LOG = 'brief-guard.jsonl';
|
|
24
|
+
const AGENT_TOOLS = ['Agent', 'Task'];
|
|
25
|
+
const ROLE = { luchador: 'wrestler', esquinero: 'cornerman' };
|
|
26
|
+
const REFERENCE = /^ring-brief:\s*(\S+)$/;
|
|
27
|
+
|
|
28
|
+
/** The brief a prompt carries: the file of a `ring-brief: <path>` reference, or the prompt itself. */
|
|
29
|
+
export function briefOf(
|
|
30
|
+
prompt,
|
|
31
|
+
readFile = (path) => readFileSync(path, 'utf8'),
|
|
32
|
+
) {
|
|
33
|
+
const text = String(prompt ?? '').trim();
|
|
34
|
+
const path = REFERENCE.exec(text)?.[1] ?? null;
|
|
35
|
+
if (!path) return { text, path: null };
|
|
36
|
+
try {
|
|
37
|
+
return { text: readFile(path), path };
|
|
38
|
+
} catch {
|
|
39
|
+
return { text: null, path };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why a brief does not hold for this launch, or null when it holds. PURE given `check`. */
|
|
44
|
+
export function problemOf({ text, path, check, role, pr }) {
|
|
45
|
+
if (text === null) return `there is no brief at ${path}`;
|
|
46
|
+
if (!check.ok) return check.reason;
|
|
47
|
+
if (check.meta.kind !== role)
|
|
48
|
+
return `it is a ${check.meta.kind} brief, launched as the ${role}`;
|
|
49
|
+
if (String(check.meta.pr) !== String(pr))
|
|
50
|
+
return `it is the brief of #${check.meta.pr}, not of this bout (#${pr})`;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const howToFix = (role, pr) =>
|
|
55
|
+
`generate it with \`node <ring>/scripts/brief.mjs ${role} ${pr} --mode <mode> --vars <file.json>\` and launch with \`prompt: "ring-brief: <the path it prints>"\``;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The hook's answer for one launch: the JSON to print, or null to print nothing. PURE.
|
|
59
|
+
* `brief` is briefOf's result, `problem` problemOf's, `launch` the role's {model, effort}.
|
|
60
|
+
*/
|
|
61
|
+
export function answer({ input, brief, problem, mode, role, pr, launch }) {
|
|
62
|
+
if (problem && mode === 'block')
|
|
63
|
+
return {
|
|
64
|
+
hookSpecificOutput: {
|
|
65
|
+
hookEventName: 'PreToolUse',
|
|
66
|
+
permissionDecision: 'deny',
|
|
67
|
+
permissionDecisionReason: `brief-guard: the ${role} brief does not hold (${problem}); ${howToFix(role, pr)}.`,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
if (problem)
|
|
71
|
+
return {
|
|
72
|
+
hookSpecificOutput: {
|
|
73
|
+
hookEventName: 'PreToolUse',
|
|
74
|
+
additionalContext: `brief-guard (warn): this ${role} was launched with a brief that does not hold (${problem}). It runs without the rules and decisions brief.mjs embeds; next time ${howToFix(role, pr)}.`,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const updated = {
|
|
78
|
+
...input,
|
|
79
|
+
prompt: brief.text,
|
|
80
|
+
...(launch?.model ? { model: launch.model } : {}),
|
|
81
|
+
...(launch?.effort ? { effort: launch.effort } : {}),
|
|
82
|
+
};
|
|
83
|
+
const same = ['prompt', 'model', 'effort'].every(
|
|
84
|
+
(key) => updated[key] === input[key],
|
|
85
|
+
);
|
|
86
|
+
if (same) return null;
|
|
87
|
+
return {
|
|
88
|
+
hookSpecificOutput: {
|
|
89
|
+
hookEventName: 'PreToolUse',
|
|
90
|
+
permissionDecision: 'allow',
|
|
91
|
+
permissionDecisionReason: `brief-guard: ${brief.path ? `brief ${brief.path} expanded` : 'brief holds'}, ${role} on ${launch?.model ?? input.model} / ${launch?.effort ?? input.effort}`,
|
|
92
|
+
updatedInput: updated,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The guard on one hook input; returns what to print (null for nothing). */
|
|
98
|
+
export function guard(input) {
|
|
99
|
+
if (input?.hook_event_name !== 'PreToolUse') return null;
|
|
100
|
+
if (!AGENT_TOOLS.includes(input.tool_name)) return null;
|
|
101
|
+
const role = ROLE[classifyAgent(input.tool_input)];
|
|
102
|
+
if (!role) return null;
|
|
103
|
+
const bout = findBout({
|
|
104
|
+
cwd: input.cwd,
|
|
105
|
+
hint: input.tool_input?.description,
|
|
106
|
+
});
|
|
107
|
+
if (!bout || !sessionRegistered(bout, input.session_id)) return null;
|
|
108
|
+
const state = readState(bout);
|
|
109
|
+
const { config } = loadConfig(projectDir(process.env, input.cwd), {
|
|
110
|
+
contract: false,
|
|
111
|
+
venue: state.venue ?? 'backend',
|
|
112
|
+
});
|
|
113
|
+
const mode = config.briefGuard ?? 'warn';
|
|
114
|
+
if (mode === 'off') return null;
|
|
115
|
+
const brief = briefOf(input.tool_input?.prompt);
|
|
116
|
+
let check = { ok: false, reason: 'no seal', meta: null };
|
|
117
|
+
if (brief.text !== null) {
|
|
118
|
+
try {
|
|
119
|
+
check = checkBrief(brief.text);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
check = { ok: false, reason: error.message, meta: null };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const problem = problemOf({ ...brief, check, role, pr: state.pr });
|
|
125
|
+
appendFileSync(
|
|
126
|
+
join(bout, GUARD_LOG),
|
|
127
|
+
`${JSON.stringify({ at: new Date().toISOString(), role, mode, path: brief.path, ok: !problem, problem })}\n`,
|
|
128
|
+
);
|
|
129
|
+
return answer({
|
|
130
|
+
input: input.tool_input,
|
|
131
|
+
brief,
|
|
132
|
+
problem,
|
|
133
|
+
mode,
|
|
134
|
+
role,
|
|
135
|
+
pr: state.pr,
|
|
136
|
+
launch: config.agents?.[role],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isMain(import.meta.url)) {
|
|
141
|
+
let raw = '';
|
|
142
|
+
process.stdin.setEncoding('utf8');
|
|
143
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
144
|
+
process.stdin.on('end', () => {
|
|
145
|
+
let out = null;
|
|
146
|
+
try {
|
|
147
|
+
out = guard(JSON.parse(raw));
|
|
148
|
+
} catch {
|
|
149
|
+
out = null;
|
|
150
|
+
}
|
|
151
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
152
|
+
process.exit(0);
|
|
153
|
+
});
|
|
154
|
+
}
|