@thebattlearena/battle-arena-hooks 0.0.0-stage → 0.5.2

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
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # Hooks de Claude Code (Battle Arena)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scripts **zero-dependency** que corren en la máquina de cada dev y emiten los eventos de las
4
+ sesiones de Claude Code al colector (`apps/public-api`). NO les agregues imports de
5
+ node_modules: la promesa es instalación-cero.
6
+
7
+ | Script | Hook | Qué hace |
8
+ | --------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `emit.mjs` | todos | Enriquece con identidad git, sanitiza y postea a `/ingest`; buffer local si el colector no responde |
10
+ | `session-start.mjs` | SessionStart | Handshake + inyecta ranking (`/v1/leaderboard/rank`) y alerta de rama protegida |
11
+ | `check-connection.mjs` | UserPromptSubmit | Avisa si hay desconexión o eventos en cola |
12
+ | `objective-nudge.mjs` | UserPromptSubmit | Recuerda invocar la skill `arena-objective` |
13
+ | `execution-coach-nudge.mjs` | UserPromptSubmit | Detecta señales de ejecución subóptima en el transcript y, con throttle por sesión, sugiere invocar la skill `execution-coach` |
14
+ | `pr-review-worktree.mjs` | PostToolUse | Al detectar `gh pr create`, inyecta contexto para OFRECER (opt-in) la skill `pr-review-loop`; no crea worktrees ni lanza nada (de eso se encarga la skill si el usuario acepta) |
15
+
16
+ Skills distribuidas con el paquete (se copian a `~/.claude/skills/`): `arena-objective`,
17
+ `arena-self-review`, `execution-coach` (interceptor mayéutico durante la ejecución),
18
+ `scope-technical-task` (coach de alcance pre-ejecución; el interceptor le hace handoff) y
19
+ `pr-review-loop` (atiende la review de Copilot de un PR: corrige → gates → commit → responder/resolver
20
+ hilos → re-review, hasta converger).
21
+
22
+ ### PR review loop (`pr-review-worktree` + skill `pr-review-loop`)
23
+
24
+ Al crear un PR con `gh pr create`, el hook `pr-review-worktree` **sugiere** (no lanza) activar la skill
25
+ `pr-review-loop`. Es **opt-in**: la skill primero **pregunta** al usuario si quiere activarlo,
26
+ explicando qué hará y el **costo** (cada re-review consume Copilot/Actions de la org) y **cuántas
27
+ iteraciones** tolera. Si acepta, el ciclo corre en un **subagente en segundo plano** con **worktree
28
+ aislado** (no bloquea la sesión ni toca el main tree): esperar review → corregir → tsc/lint →
29
+ commit + `git push origin HEAD:<branch>` → responder/resolver hilos → re-review, hasta converger o
30
+ agotar el tope. Al terminar avisa; si no convergió, indica explícitamente qué haría continuar y vuelve
31
+ a preguntar. Nunca mergea (decisión humana). También se invoca a mano: `/pr-review-loop <url-o-número>`.
32
+
33
+ > El hook `pr-review-worktree` queda activo solo tras **(re)instalar** los hooks —`npx
34
+ @thebattlearena/battle-arena-hooks@latest install` o, desde el clone, `bash
35
+ apps/claude-hooks/install.sh` (ambos pasan por `cli.mjs`, la misma fuente)—. Una instalación previa
36
+ > no lo tiene registrado hasta actualizar.
37
+
38
+ Estado local: `~/.claude/hook-stream/{status.json, buffer.ndjson}` y, para el throttle del
39
+ interceptor, `~/.claude/battle-arena/execution-coach/<session>.json`.
40
+
41
+ ## Qué se censura antes de enviar (`lib/sanitize.mjs`)
42
+
43
+ Antes de salir de tu máquina, cada evento pasa por `lib/sanitize.mjs` (patrones en
44
+ `lib/secret-patterns.mjs`):
45
+
46
+ - **Paths de tu máquina**: tu homedir y `/Users/<x>` / `/home/<x>` → `~`.
47
+ - **Variables sensibles por nombre** (`PASSWORD`, `*_TOKEN`, `*_SECRET`, `api_key`, `AUTH`, y en
48
+ estilo env `KEY` / `*_KEY`): se conserva el nombre y el valor queda en `***`.
49
+ - **Llaves de proveedor sueltas** en cualquier texto (comando, resultado, prompt): Retell, OpenAI /
50
+ Anthropic, Linear, GitHub, Slack, Stripe, Google, JWT y AWS; se conserva el prefijo (`sk-***`).
51
+ - **Headers y URLs**: `x-api-key`, `Authorization` (con o sin `Bearer`), `?api_key=` / `?token=`
52
+ y credenciales en URIs (`scheme://user:***@host`).
53
+ - **Archivos `.env` leídos** (Read/Grep de un `.env`/`.env.*`/`*.env`, o `cat`/`head`/`tail`/`grep`
54
+ de uno por Bash): cada línea `NOMBRE=valor` del resultado queda `NOMBRE=***`.
55
+ - Strings de más de 4000 caracteres se recortan.
56
+
57
+ El colector aplica el mismo filtro al ingerir (defensa en profundidad para instalaciones viejas):
58
+ `packages/contexts/arena/domain/services/secret-redaction.service.ts`. La paridad entre ambos la
59
+ cuidan vectores compartidos (`packages/contexts/arena/domain/__tests__/fixtures/secret-redaction.vectors.json`)
60
+ que corren las dos suites: `yarn workspace @thebattlearena/battle-arena-hooks test` (o
61
+ `node --test apps/claude-hooks/test/*.test.mjs`) y el jest de la raíz.
62
+
63
+ ## Manejo de errores del colector
64
+
65
+ `emit.mjs` clasifica la respuesta de `/ingest`:
66
+
67
+ - **400 / 413 / 422** (evento malformado/enorme) → se **descarta** (no se reintenta).
68
+ - **timeout / 5xx / 429** → se **bufferea** y se reenvía al primer envío exitoso.
69
+ - **401 / 403** (API key inválida o revocada, p. ej. tras rotar la key o reiniciar el backend) →
70
+ **no se bufferea** (reintentar con la misma key es en vano). Se marca `authError` en
71
+ `status.json` y los hooks de superficie (`session-start`, `check-connection`) le piden al
72
+ usuario que **genere una key nueva y reinstale** con
73
+ `npx @thebattlearena/battle-arena-hooks@latest install --url <url> --api-key <NUEVA_KEY>`.
74
+ Tras un envío exitoso con la key nueva, el flag se limpia solo. Lógica compartida en
75
+ `lib/reauth.mjs`.
76
+
77
+ ## Instalación rápida
78
+
79
+ La forma recomendada (cualquier SO, sin clonar) es el paquete npm — ver README raíz:
80
+
81
+ ```bash
82
+ npx @thebattlearena/battle-arena-hooks@latest install \
83
+ --url https://api.battlearena.dev --api-key sk_xxx
84
+ ```
85
+
86
+ Desde el clone, el equivalente para contribuidores:
87
+
88
+ ```bash
89
+ HOOK_STREAM_URL=https://api.battlearena.dev \
90
+ HOOK_STREAM_API_KEY=sk_xxx \
91
+ bash apps/claude-hooks/install.sh
92
+ ```
93
+
94
+ ## Configuración
95
+
96
+ El instalador (`cli.mjs`) guarda el endpoint + api-key en
97
+ `~/.claude/battle-arena/config.json` y registra los hooks en `~/.claude/settings.json` con
98
+ comandos `node "<ruta>"` (sin `VAR=valor` inline, que no existe en Windows). Los hooks leen, en
99
+ orden de prioridad:
100
+
101
+ 1. **Variables de entorno** `HOOK_STREAM_URL` / `HOOK_STREAM_API_KEY` (si están seteadas).
102
+ 2. **`config.json`** (`{ "url": "...", "apiKey": "..." }`).
103
+ 3. Default `http://127.0.0.1:4500`.
104
+
105
+ `HOOK_STREAM_API_KEY` con `ARENA_REQUIRE_AUTH=true` necesita scopes `arena:read`
106
+ (handshake/ranking) y `arena:write` (objetivos); `/ingest` acepta cualquier api-key válida.
107
+
108
+ El nudge de objetivos requiere además instalar la skill `arena-objective` en cada repo (o
109
+ global en `~/.claude/skills/`): ver `docs/how-to/install-arena-objective-skill.md`.
110
+
111
+ ## Cutover desde el colector legacy (speech-analytics/tooling/hook-stream)
112
+
113
+ 1. Levanta battle-arena en el puerto 4500 (apagando antes el `yarn hook-stream` legacy) — la
114
+ ruta `/ingest` y el compat `/objective` son idénticos: los hooks viejos siguen funcionando
115
+ sin tocarlos.
116
+ 2. Restaura el histórico si quieres conservar el leaderboard: `pg_dump` de la DB `hook_stream`
117
+ → `battle_arena` (mismas tablas/columnas).
118
+ 3. Actualiza tus settings para apuntar a estos hooks (ruta absoluta al clone) y borra los del
119
+ repo legacy cuando todo el equipo haya migrado.
@@ -0,0 +1,64 @@
1
+ // Hook UserPromptSubmit: latido recurrente de conexión. Lee el status local que deja emit.mjs
2
+ // y, si la última transmisión falló o hay eventos en cola, le avisa al usuario (systemMessage)
3
+ // y a Claude (additionalContext) que el stream está desconectado y reintentando. Cuando todo
4
+ // está sano, no produce ruido. No bloquea (exit 0); la reconexión es automática.
5
+ import { getIdentity } from './lib/identity.mjs';
6
+ import { readStatus, writeStatus } from './lib/state.mjs';
7
+ import { drainBuffer } from './lib/drain.mjs';
8
+ import { reauthContext, reauthSystemMessage } from './lib/reauth.mjs';
9
+ import { TARGET } from './lib/transport.mjs';
10
+
11
+ let raw = '';
12
+ process.stdin.setEncoding('utf8');
13
+ process.stdin.on('data', () => {});
14
+ process.stdin.on('end', async () => {
15
+ let status = readStatus();
16
+
17
+ // Auth: la key no sirve → requiere acción del usuario; se muestra siempre (prioritario sobre
18
+ // el aviso de "desconectado", que sugiere que no hay nada que hacer).
19
+ if (status.authError) {
20
+ process.stdout.write(
21
+ JSON.stringify({
22
+ systemMessage: reauthSystemMessage(TARGET, status.authStatus),
23
+ hookSpecificOutput: {
24
+ hookEventName: 'UserPromptSubmit',
25
+ additionalContext: reauthContext(TARGET, getIdentity().email),
26
+ },
27
+ }),
28
+ );
29
+ return;
30
+ }
31
+
32
+ // Drenado PROACTIVO: si hay cola, la reenviamos aquí mismo en vez de esperar al próximo evento de
33
+ // trabajo (así una sesión que quedó con cola tras un bache del colector se recupera al escribir el
34
+ // siguiente prompt). drainBuffer se autoacota (MAX_DRAIN) y corta si el colector no responde, así
35
+ // que cuesta poco cuando está caído; al volver, la cola se vacía en pocos prompts.
36
+ if ((status.pending || 0) > 0) {
37
+ const { remaining } = await drainBuffer();
38
+ status =
39
+ remaining === 0
40
+ ? {
41
+ connected: true,
42
+ target: TARGET,
43
+ lastOk: new Date().toISOString(),
44
+ pending: 0,
45
+ }
46
+ : { ...status, connected: false, target: TARGET, pending: remaining };
47
+ writeStatus(status);
48
+ }
49
+
50
+ const pending = status.pending || 0;
51
+
52
+ // Conexión sana y sin cola → nada que reportar.
53
+ if (status.connected && pending === 0) return;
54
+
55
+ const colaTxt = pending > 0 ? ` (${pending} eventos en cola)` : '';
56
+ const output = {
57
+ systemMessage: `⚠️ hook-stream desconectado de ${TARGET}${colaTxt} — reintentando automáticamente`,
58
+ hookSpecificOutput: {
59
+ hookEventName: 'UserPromptSubmit',
60
+ additionalContext: `El hook-stream perdió la conexión con ${TARGET}${colaTxt}. Tu actividad se está bufferando localmente y se reenviará en cuanto el colector vuelva a responder; la reconexión es automática y no requiere acción manual.`,
61
+ },
62
+ };
63
+ process.stdout.write(JSON.stringify(output));
64
+ });
package/cli.mjs ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // CLI del paquete @thebattlearena/battle-arena-hooks.
3
+ // Instala los hooks + skill del Battle Arena en ~/.claude SIN clonar el repo.
4
+ //
5
+ // npx @thebattlearena/battle-arena-hooks install \
6
+ // --url https://api.battlearena.dev --api-key sk_xxx
7
+ //
8
+ // (también lee HOOK_STREAM_URL / HOOK_STREAM_API_KEY del entorno)
9
+ import {
10
+ cpSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { execFileSync } from 'node:child_process';
20
+ import os from 'node:os';
21
+
22
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url));
23
+ const HOME = os.homedir();
24
+ const CLAUDE = join(HOME, '.claude');
25
+ const BA_DIR = join(CLAUDE, 'battle-arena');
26
+ const HOOKS_DEST = join(BA_DIR, 'hooks');
27
+ const SKILLS_DEST = join(CLAUDE, 'skills');
28
+ const SETTINGS = join(CLAUDE, 'settings.json');
29
+
30
+ const HOOK_FILES = [
31
+ 'emit.mjs',
32
+ 'session-start.mjs',
33
+ 'check-connection.mjs',
34
+ 'objective-nudge.mjs',
35
+ 'execution-coach-nudge.mjs',
36
+ 'pr-review-worktree.mjs',
37
+ 'objective.mjs',
38
+ ];
39
+
40
+ function arg(name) {
41
+ const i = process.argv.indexOf(`--${name}`);
42
+ return i !== -1 ? process.argv[i + 1] : undefined;
43
+ }
44
+
45
+ function gitEmail() {
46
+ try {
47
+ return execFileSync('git', ['config', 'user.email'], {
48
+ encoding: 'utf8',
49
+ }).trim();
50
+ } catch {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ function install() {
56
+ const url =
57
+ arg('url') || process.env.HOOK_STREAM_URL || 'https://api.battlearena.dev';
58
+ const key = arg('api-key') || process.env.HOOK_STREAM_API_KEY || '';
59
+
60
+ if (!gitEmail()) {
61
+ console.log(
62
+ '⚠️ git config user.email vacío — configúralo: es tu identidad en el arena.',
63
+ );
64
+ }
65
+
66
+ // 1) Hooks → ~/.claude/battle-arena/hooks (ubicación estable)
67
+ mkdirSync(HOOKS_DEST, { recursive: true });
68
+ for (const f of HOOK_FILES) cpSync(join(PKG_DIR, f), join(HOOKS_DEST, f));
69
+ cpSync(join(PKG_DIR, 'lib'), join(HOOKS_DEST, 'lib'), { recursive: true });
70
+ console.log(`✅ hooks copiados a ${HOOKS_DEST}`);
71
+
72
+ // 2) Skills del paquete → ~/.claude/skills (cada subcarpeta de skill/*)
73
+ const skillsRoot = join(PKG_DIR, 'skill');
74
+ if (existsSync(skillsRoot)) {
75
+ mkdirSync(SKILLS_DEST, { recursive: true });
76
+ const skillDirs = readdirSync(skillsRoot, { withFileTypes: true }).filter(
77
+ (d) => d.isDirectory(),
78
+ );
79
+ for (const d of skillDirs) {
80
+ cpSync(join(skillsRoot, d.name), join(SKILLS_DEST, d.name), {
81
+ recursive: true,
82
+ });
83
+ console.log(`✅ skill ${d.name} instalada en ${SKILLS_DEST}`);
84
+ }
85
+ }
86
+
87
+ // 3) Config (url + api-key) → ~/.claude/battle-arena/config.json. Los hooks lo leen de ahí.
88
+ // Evita hornear `VAR=val node ...` en el comando, sintaxis que NO existe en Windows
89
+ // (cmd/PowerShell). Así el comando es solo `node "<ruta>"`, idéntico en los 3 SOs.
90
+ writeFileSync(
91
+ join(BA_DIR, 'config.json'),
92
+ JSON.stringify({ url, apiKey: key || undefined }, null, 2) + '\n',
93
+ );
94
+
95
+ // 4) Hooks en ~/.claude/settings.json (merge idempotente, sin pisar otros)
96
+ const cmd = (f) => `node "${join(HOOKS_DEST, f)}"`;
97
+ const map = {
98
+ SessionStart: ['session-start.mjs'],
99
+ UserPromptSubmit: [
100
+ 'emit.mjs',
101
+ 'check-connection.mjs',
102
+ 'objective-nudge.mjs',
103
+ 'execution-coach-nudge.mjs',
104
+ ],
105
+ PreToolUse: ['emit.mjs'],
106
+ PostToolUse: ['emit.mjs', 'pr-review-worktree.mjs'],
107
+ Notification: ['emit.mjs'],
108
+ Stop: ['emit.mjs'],
109
+ SubagentStop: ['emit.mjs'],
110
+ PreCompact: ['emit.mjs'],
111
+ SessionEnd: ['emit.mjs'],
112
+ };
113
+
114
+ mkdirSync(CLAUDE, { recursive: true });
115
+ const settings = existsSync(SETTINGS)
116
+ ? JSON.parse(readFileSync(SETTINGS, 'utf8'))
117
+ : {};
118
+ settings.hooks = settings.hooks || {};
119
+ for (const [event, scripts] of Object.entries(map)) {
120
+ const groups = (settings.hooks[event] = settings.hooks[event] || []);
121
+ // quita entradas previas del arena (idempotente) y reescribe en su grupo
122
+ for (const g of groups)
123
+ g.hooks = (g.hooks || []).filter(
124
+ (h) =>
125
+ !/battle-arena[/\\]hooks|claude-hooks[/\\]/.test(h.command || ''),
126
+ );
127
+ settings.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
128
+ settings.hooks[event].push({
129
+ hooks: scripts.map((f) => ({ type: 'command', command: cmd(f) })),
130
+ });
131
+ }
132
+ writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
133
+ console.log(`✅ hooks registrados en ${SETTINGS}`);
134
+ console.log(` colector: ${url}${key ? '' : ' (sin api-key)'}`);
135
+ console.log(
136
+ '\nListo. Abre Claude Code en cualquier repo y verás el handshake del arena.',
137
+ );
138
+ }
139
+
140
+ const command = process.argv[2];
141
+ if (command === 'install') {
142
+ install();
143
+ } else {
144
+ console.log(
145
+ 'Uso: npx @thebattlearena/battle-arena-hooks install [--url <colector>] [--api-key <key>]',
146
+ );
147
+ process.exit(command ? 1 : 0);
148
+ }
package/emit.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // Emisor de hooks de Claude Code → colector (local primero, remoto vía HOOK_STREAM_URL).
2
+ // Lo invoca cada hook. Enriquece el payload con la identidad del dev y lo envía a /ingest.
3
+ // Modelo directo + auto-retry: si el envío falla, el evento se bufferea localmente y, al
4
+ // primer envío exitoso posterior, se drena el buffer. Actualiza ~/.claude/hook-stream/status.json
5
+ // para que los hooks de conexión sepan si avisar a Claude. SIEMPRE exit 0; nunca bloquea.
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ import { bufferEvent, readBuffer, writeStatus } from './lib/state.mjs';
9
+ import { drainBuffer, isUnrecoverable } from './lib/drain.mjs';
10
+ import { getIdentity } from './lib/identity.mjs';
11
+ import { isAuthError } from './lib/reauth.mjs';
12
+ import { numstatTotals } from './lib/git-diff.mjs';
13
+ import { sanitize } from './lib/sanitize.mjs';
14
+ import { TARGET, postJson } from './lib/transport.mjs';
15
+
16
+ // "Código nuevo" (LOC): lo porta el evento de `git commit` (NO el push). El push corre en
17
+ // PostToolUse después de actualizar el remote-tracking → el rango quedaba vacío (0 LOC). El commit
18
+ // recién creado es `HEAD~1..HEAD`: timing fiable. El colector deduplica por eventId.
19
+ const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/;
20
+ const isGitCommit = (payload) =>
21
+ payload.hook_event_name === 'PostToolUse' &&
22
+ payload.tool_name === 'Bash' &&
23
+ GIT_COMMIT_PATTERN.test(String(payload.tool_input?.command || ''));
24
+
25
+ // Enriquece SOLO los eventos de commit con los totales del diff; el resto pasa intacto.
26
+ function withCodeMetrics(payload) {
27
+ if (!isGitCommit(payload)) return {};
28
+ const { insertions, deletions, filesChanged } = numstatTotals();
29
+ if (insertions === 0 && deletions === 0) return {};
30
+ return { codeMetrics: { insertions, deletions, filesChanged } };
31
+ }
32
+
33
+ let raw = '';
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', (c) => (raw += c));
36
+ process.stdin.on('end', () => {
37
+ let payload;
38
+ try {
39
+ payload = JSON.parse(raw || '{}');
40
+ } catch {
41
+ return;
42
+ }
43
+
44
+ // Solo reportamos repos de código con remoto (origin). Sin remoto, no nos interesa: no se emite.
45
+ const identity = getIdentity();
46
+ if (!identity.repoUrl) return;
47
+
48
+ // Censura secretos y omite paths de la máquina ANTES de que el evento salga/se persista.
49
+ // `eventId` único: viaja en el buffer, así un reenvío conserva el mismo id y el colector lo
50
+ // deduplica (entrega "al menos una vez" sin duplicar en el feed).
51
+ const event = sanitize({
52
+ ...payload,
53
+ ...withCodeMetrics(payload),
54
+ eventId: randomUUID(),
55
+ identity,
56
+ sentAt: new Date().toISOString(),
57
+ });
58
+
59
+ send(event).catch(() => {});
60
+ });
61
+
62
+ async function send(event) {
63
+ const line = JSON.stringify(event);
64
+ const { ok, status } = await postJson('/ingest', event);
65
+
66
+ if (!ok && isUnrecoverable(status)) return; // evento inservible: descartar, no bufferear
67
+
68
+ // Auth: la key no sirve. No buffereamos (sería en vano) y marcamos authError para que
69
+ // session-start / check-connection le pidan al usuario una key nueva.
70
+ if (!ok && isAuthError(status)) {
71
+ writeStatus({
72
+ connected: false,
73
+ authError: true,
74
+ authStatus: status,
75
+ target: TARGET,
76
+ lastFail: event.sentAt,
77
+ pending: readBuffer().length,
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (!ok) {
83
+ bufferEvent(line);
84
+ writeStatus({
85
+ connected: false,
86
+ target: TARGET,
87
+ lastFail: event.sentAt,
88
+ pending: readBuffer().length,
89
+ });
90
+ return;
91
+ }
92
+
93
+ await drainBuffer();
94
+ writeStatus({
95
+ connected: true,
96
+ target: TARGET,
97
+ lastOk: event.sentAt,
98
+ pending: readBuffer().length,
99
+ });
100
+ }
@@ -0,0 +1,247 @@
1
+ // Hook UserPromptSubmit: inyecta additionalContext para que Claude invoque el skill
2
+ // `execution-coach` (interceptor mayéutico) cuando la ejecución va subóptima. Dos partes:
3
+ //
4
+ // 1. AUTOEVALUACIÓN SEMÁNTICA (cada turno no trivial): le recuerda al modelo —que ya está
5
+ // procesando el turno— que juzgue si el pedido del usuario está vago / subespecificado /
6
+ // confuso. El juicio semántico lo hace el LLM, no regex frágiles aquí.
7
+ // 2. SEÑALES DETERMINISTAS de la sesión (con throttle): contadores baratos sobre el transcript
8
+ // (racha sin guía, dar vueltas sobre un archivo, PRs/pushes sin converger, acoplamiento).
9
+ //
10
+ // Zero-dependency, best-effort, exit 0 SIEMPRE — si algo falla, no estorba el turno.
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const STATE_DIR = path.join(
16
+ os.homedir(),
17
+ '.claude',
18
+ 'battle-arena',
19
+ 'execution-coach',
20
+ );
21
+ const COOLDOWN_TURNS = 5; // no repetir las señales de sesión más de una vez cada N turnos
22
+ const WINDOW = 14; // ventana reciente (en turnos del asistente) para "vueltas"
23
+
24
+ const GUIDANCE_RE =
25
+ /(^|\s)\/(plan|goal|verify|code-review|review|scope|aterrizar)\b/i;
26
+ const GREETING_RE =
27
+ /^(hola|hey|hi|buenas|gracias|ok|oka|okay|s[ií]|dale|listo|va|perfecto)\b/i;
28
+ // Prompts que son solo emojis/símbolos (👍, 👌🙏): triviales. `\b` no matchea emojis, así que
29
+ // los detectamos aparte en vez de meterlos en GREETING_RE.
30
+ const EMOJI_ONLY_RE = /^[\s\p{Extended_Pictographic}\p{Emoji_Presentation}]+$/u;
31
+ const INFRA_IMPORT_RE =
32
+ /(from\s+['"]|require\(\s*['"])(typeorm|@nestjs|sequelize|mongoose|prisma|@prisma|knex|axios|pg|mysql2|redis|ioredis)/;
33
+ const DOMAIN_PATH_RE = /(^|\/)(domain|entities|value-objects|core)\//;
34
+
35
+ const SELF_ASSESS =
36
+ 'Battle Arena — antes de responder, juzga si el pedido del usuario está claramente vago, ' +
37
+ 'subespecificado o confuso: sin objetivo claro, sin criterio de aceptación, o sin saber para ' +
38
+ 'quién/para qué es (p. ej. "quiero un código para desplegar el proyecto" sin decir qué app, ' +
39
+ 'entorno ni cómo se ve "listo"). Si CLARAMENTE lo está, invoca el skill `execution-coach` para ' +
40
+ 'interceptar con preguntas mayéuticas antes de codear. Sé conservador: ante la duda, o si el ' +
41
+ 'pedido ya es concreto, sigue normal sin interrumpir.';
42
+
43
+ function wordCount(s) {
44
+ return (s || '').trim().split(/\s+/).filter(Boolean).length;
45
+ }
46
+
47
+ // --- lectura defensiva del transcript .jsonl ---------------------------------
48
+ function parseTranscript(file) {
49
+ const userTurns = []; // textos de turnos REALES del usuario (no tool_result)
50
+ const assistantTurns = []; // [{ tools: [{name, input}] }] por mensaje del asistente
51
+ let raw = '';
52
+ try {
53
+ raw = readFileSync(file, 'utf8');
54
+ } catch {
55
+ return { userTurns, assistantTurns };
56
+ }
57
+ // Acota el costo por turno en sesiones largas: solo las últimas N líneas. Las señales miran la
58
+ // actividad reciente; un /plan de hace miles de líneas no cambia que una racha actual no tenga guía.
59
+ const allLines = raw.split('\n');
60
+ const lines = allLines.length > 5000 ? allLines.slice(-5000) : allLines;
61
+ for (const line of lines) {
62
+ if (!line.trim()) continue;
63
+ let ev;
64
+ try {
65
+ ev = JSON.parse(line);
66
+ } catch {
67
+ continue;
68
+ }
69
+ const msg = ev.message || ev;
70
+ const role = msg.role || ev.type;
71
+ const content = msg.content;
72
+ if (role === 'user') {
73
+ // string = prompt real; array con bloque text = prompt real; solo tool_result = NO cuenta
74
+ if (typeof content === 'string') {
75
+ userTurns.push(content);
76
+ continue;
77
+ }
78
+ if (Array.isArray(content)) {
79
+ const text = content
80
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
81
+ .map((b) => b.text)
82
+ .join(' ');
83
+ if (text.trim()) userTurns.push(text);
84
+ }
85
+ continue;
86
+ }
87
+ if (role === 'assistant' && Array.isArray(content)) {
88
+ const tools = content
89
+ .filter((b) => b && b.type === 'tool_use')
90
+ .map((b) => ({ name: b.name, input: b.input || {} }));
91
+ if (tools.length) assistantTurns.push({ tools });
92
+ }
93
+ }
94
+ return { userTurns, assistantTurns };
95
+ }
96
+
97
+ // --- señales DETERMINISTAS (contadores; nada de juicio semántico) -------------
98
+ function detectSignals({ userTurns, assistantTurns }) {
99
+ const signals = [];
100
+ const flatTools = assistantTurns.flatMap((t) => t.tools);
101
+ const toolName = (n) => flatTools.filter((t) => t.name === n).length;
102
+ const workCount =
103
+ toolName('Edit') +
104
+ toolName('Write') +
105
+ toolName('MultiEdit') +
106
+ toolName('Bash');
107
+
108
+ // Sin intención de guiar (ni /plan /goal /verify ni ExitPlanMode) pese a trabajo real.
109
+ const guided =
110
+ userTurns.some((t) => GUIDANCE_RE.test(t)) ||
111
+ flatTools.some((t) => ['ExitPlanMode', 'EnterPlanMode'].includes(t.name));
112
+ if (userTurns.length >= 8 && workCount >= 6 && !guided) {
113
+ signals.push(
114
+ 'sin guía de ejecución (muchos turnos con trabajo y ningún /plan, /goal o /verify)',
115
+ );
116
+ }
117
+
118
+ // Dando vueltas — mismo archivo editado en >=3 turnos del asistente recientes.
119
+ const recent = assistantTurns.slice(-WINDOW);
120
+ const fileTurns = new Map();
121
+ recent.forEach((turn, i) => {
122
+ const files = new Set(
123
+ turn.tools
124
+ .filter((t) => ['Edit', 'Write', 'MultiEdit'].includes(t.name))
125
+ .map((t) => t.input && t.input.file_path)
126
+ .filter(Boolean),
127
+ );
128
+ for (const f of files) {
129
+ if (!fileTurns.has(f)) fileTurns.set(f, new Set());
130
+ fileTurns.get(f).add(i);
131
+ }
132
+ });
133
+ const looping = [...fileTurns.entries()].find(([, turns]) => turns.size >= 3);
134
+ if (looping) {
135
+ signals.push(
136
+ `dando vueltas sobre el mismo archivo (${path.basename(looping[0])})`,
137
+ );
138
+ }
139
+
140
+ // No converge — PRs/pushes repetidos.
141
+ const bashCmds = flatTools
142
+ .filter((t) => t.name === 'Bash')
143
+ .map((t) => (t.input && t.input.command) || '');
144
+ const prCreates = bashCmds.filter((c) => /gh\s+pr\s+create/.test(c)).length;
145
+ const pushes = bashCmds.filter((c) => /git\s+push/.test(c)).length;
146
+ if (prCreates >= 2 || pushes >= 3) {
147
+ signals.push('múltiples PRs/pushes sin converger en una solución');
148
+ }
149
+
150
+ // Acoplamiento — edición a dominio/entities/core que importa infra/ORM/framework.
151
+ const coupling = flatTools.some((t) => {
152
+ if (!['Edit', 'Write', 'MultiEdit'].includes(t.name)) return false;
153
+ const fp = t.input && t.input.file_path;
154
+ if (!fp || !DOMAIN_PATH_RE.test(fp)) return false;
155
+ // Write usa `content`, Edit usa `new_string`, MultiEdit lleva el texto en `edits[].new_string`.
156
+ const multi = (t.input.edits || []).map((e) => e && e.new_string).join('');
157
+ const body = `${t.input.content || ''}${t.input.new_string || ''}${multi}`;
158
+ return INFRA_IMPORT_RE.test(body);
159
+ });
160
+ if (coupling) {
161
+ signals.push(
162
+ 'posible acoplamiento: dominio importando infraestructura (ORM/framework)',
163
+ );
164
+ }
165
+
166
+ return signals;
167
+ }
168
+
169
+ // --- throttle por sesión (solo para las señales de sesión) --------------------
170
+ function readState(session) {
171
+ try {
172
+ return JSON.parse(
173
+ readFileSync(path.join(STATE_DIR, `${session}.json`), 'utf8'),
174
+ );
175
+ } catch {
176
+ return { lastNudgeTurn: -Infinity };
177
+ }
178
+ }
179
+ function writeState(session, state) {
180
+ try {
181
+ mkdirSync(STATE_DIR, { recursive: true });
182
+ writeFileSync(
183
+ path.join(STATE_DIR, `${session}.json`),
184
+ JSON.stringify(state),
185
+ );
186
+ } catch {}
187
+ }
188
+
189
+ // --- main ---------------------------------------------------------------------
190
+ let raw = '';
191
+ process.stdin.setEncoding('utf8');
192
+ process.stdin.on('data', (c) => (raw += c));
193
+ process.stdin.on('end', () => {
194
+ try {
195
+ const input = JSON.parse(raw || '{}');
196
+ const prompt = String(input.prompt || '');
197
+ // Sanitiza el session_id antes de usarlo como nombre de archivo del state (evita path
198
+ // traversal con `/` o `..`) y acótalo.
199
+ const session =
200
+ String(input.session_id || 'default')
201
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
202
+ .slice(0, 128) || 'default';
203
+
204
+ // Turnos triviales (saludo/confirmación corta/solo emojis): nunca interrumpir.
205
+ const trimmed = prompt.trim();
206
+ if (
207
+ wordCount(prompt) < 3 ||
208
+ GREETING_RE.test(trimmed) ||
209
+ EMOJI_ONLY_RE.test(trimmed)
210
+ )
211
+ return;
212
+
213
+ const { userTurns, assistantTurns } = parseTranscript(
214
+ input.transcript_path,
215
+ );
216
+ const turnNo = userTurns.length;
217
+
218
+ // Las señales de sesión se incluyen con throttle (no repetir la misma queja cada turno).
219
+ const state = readState(session);
220
+ let signals = [];
221
+ if (turnNo - (state.lastNudgeTurn ?? -Infinity) >= COOLDOWN_TURNS) {
222
+ signals = detectSignals({ userTurns, assistantTurns });
223
+ if (signals.length)
224
+ writeState(session, { lastNudgeTurn: turnNo, signals });
225
+ }
226
+
227
+ // La autoevaluación semántica va SIEMPRE (es solo un recordatorio barato; el modelo juzga).
228
+ let context = SELF_ASSESS;
229
+ if (signals.length) {
230
+ context +=
231
+ '\n\nAdemás, señales deterministas en esta sesión (revísalas con `execution-coach` si ' +
232
+ 'siguen vigentes):\n' +
233
+ signals.map((s) => `- ${s}`).join('\n');
234
+ }
235
+
236
+ process.stdout.write(
237
+ JSON.stringify({
238
+ hookSpecificOutput: {
239
+ hookEventName: 'UserPromptSubmit',
240
+ additionalContext: context,
241
+ },
242
+ }),
243
+ );
244
+ } catch {
245
+ // best-effort: nunca estorbar el turno
246
+ }
247
+ });