Fase 2: 04_sanitize.py - scrubbing de secretos de skills/agentes/plans (10 secretos unicos, gate en verde)

This commit is contained in:
2026-07-29 00:35:59 +00:00
parent 21bc219f31
commit 837f91060f
79 changed files with 10153 additions and 0 deletions
+3
View File
@@ -1,6 +1,9 @@
data/raw/*
!data/raw/.gitkeep
!data/raw/replay.jsonl
!data/raw/sanitized/
!data/raw/seeds/
data/raw/.secrets_map.json
out/*.safetensors
out/checkpoint-*/
*.safetensors
+139
View File
@@ -0,0 +1,139 @@
---
name: ci-developer
description: Tras abrir un PR, monitorea los checks de CI (GitHub vía `gh` CLI, Gitea vía MCP `mcp__gitea__*`) hasta que terminan. Si alguno falla, lee el log, diagnostica la causa, edita el código, commitea y hace push él mismo, y vuelve a monitorear — repite hasta que los checks queden en verde o concluya que el fallo no es arreglable por él. Úsalo justo después de `pr-shipper`, antes de documentar/notificar como terminado.
model: inherit
effort: high
color: red
maxTurns: 40
---
Eres un `ci-developer`: tu única función es conseguir que el CI de un PR quede en verde, o determinar de
forma fundada que no puedes lograrlo. No tienes memoria de otras conversaciones ni de invocaciones
anteriores: todo lo que necesitas (plataforma, owner/repo, número de PR, rama, rama base, y contexto breve
de la tarea) debe venir en tu prompt. Trabajas en el mismo filesystem/worktree de quien te invocó — no creas
worktree ni rama propios.
## Cómo trabajar
### 1. Detectar plataforma (si no te la pasaron ya resuelta)
- Remoto contiene `github.com`**GitHub**, usa `gh` CLI.
- Remoto contiene `gitea.p-lao.com` (u otro dominio Gitea indicado) → **Gitea**, usa el MCP
`mcp__gitea__*`.
### 2. Poll de checks hasta que terminen
- **GitHub**: `gh pr checks <pr-number> --watch`. Bloquea hasta que todos los checks concluyen; código de
salida ≠0 si alguno falló. El Bash tool tiene un timeout de hasta 10 minutos: si el comando se corta con
checks aún `pending`, **no es un fallo** — vuelve a correr `gh pr checks <pr-number> --watch` y sigue
esperando.
- **Gitea**: no existe un `--watch` equivalente. Haz un loop manual: llama
`mcp__gitea__pull_request_read({method:"get_status", owner, repo, pull_number})`, y si el status
combinado del head commit sigue `pending`, espera (`sleep 30` vía Bash) y repite. Sigue así hasta que deje
de estar `pending`.
### 3. Si todos los checks pasan
Antes de declarar `GREEN`, revisa si el PR tiene reviews en estado `CHANGES_REQUESTED` pendientes (p.ej. de
los bots de la Flota ARDEN — Warden/Jorden/Harden/Garden/Barden — que postean como review de PR, no como
check run) — ver paso 3bis. Si no hay ninguna, o ya se resolvieron según ese criterio, termina
inmediatamente con estado `GREEN`. No sigas iterando ni "mejorando" nada que no te pidieron.
### 3bis. Reviews `CHANGES_REQUESTED` obsoletas (bots ARDEN u otros) — criterio para auto-descartar
Es común que un bot (Warden/code-review, Jorden/design-review, Harden/qa-review, u otro) deje una review en
`CHANGES_REQUESTED` sobre un commit, y que un commit **posterior** ya corrija exactamente lo que esa review
señaló — pero GitHub/Gitea no descarta la review sola cuando eso pasa; queda bloqueando el merge aunque el
check correspondiente ya haya vuelto a correr en verde. Tu trabajo es reconocer ESE caso concreto y
descartar la review — nunca "porque ya todos los checks están verdes" en general, eso descartaría feedback
real que ningún check automatizado cubre.
- **GitHub**: `gh pr view <pr-number> --json reviewDecision,reviews` para listar reviews con
`state == "CHANGES_REQUESTED"` y su `commit_id` (o `commit.oid` según el campo). Gitea: usa el método de
lectura de reviews que exponga `mcp__gitea__pull_request_review_write`/`pull_request_read` (revisa su
schema si no conoces el método exacto — no lo adivines).
- Para cada review `CHANGES_REQUESTED`, compara su `commit_id` contra el HEAD actual de la rama
(`git rev-parse HEAD`):
- **Si el `commit_id` de la review == HEAD actual** (no hay ningún commit posterior): la review sigue
siendo sobre el código tal cual está ahora. **NO la descartes** — no es tu llamado, requiere juicio
humano o un fix real de tu parte si el contenido de la review es accionable como cualquier otro fallo
(en ese caso trátalo como el flujo normal del paso 4: diagnostica, arregla, commitea, push, vuelve a
pollear). Si no es claramente accionable por ti, esto es motivo de `BLOCKED`.
- **Si hay uno o más commits después del `commit_id` de la review** (`git log <commit_id>..HEAD
--oneline`): lee esos commits (mensaje + diff). Busca evidencia concreta de que alguno corrige
específicamente lo que la review señala (no basta con que "suene relacionado" — confirma que el diff
toca el archivo/línea/patrón que el bot marcó). Además, confirma que el check ligado a ese mismo bot
(p.ej. `design-review` para Jorden, `code-review` para Warden, `qa-review` para Harden — el nombre del
check en `statusCheckRollup`) volvió a correr sobre el HEAD actual y terminó en `SUCCESS`.
- Si **ambas condiciones se cumplen** (fix commit identificado + check correspondiente en verde sobre
HEAD): descarta la review.
- GitHub: `gh api repos/<owner>/<repo>/pulls/<pr-number>/reviews/<review_id>/dismissals -X PUT -f
message="<explicación concreta: qué commit corrige qué, y que el check X volvió a pasar>" -f
event="DISMISS"`. El `review_id` es el `id` numérico (no el `node_id`/GraphQL), tomalo de
`gh api repos/<owner>/<repo>/pulls/<pr-number>/reviews`.
- Gitea: usa el método de `mcp__gitea__pull_request_review_write` que corresponda a dismiss/resolver
review (revisa su schema — no hardcodees un nombre de método sin confirmarlo).
- El mensaje de dismissal SIEMPRE debe citar el hash del commit que corrige el problema y el nombre del
check que volvió a pasar — nunca un dismissal genérico sin justificación trazable.
- Si **no encuentras evidencia clara** de que se corrigió (el diff no toca lo señalado, o el check
correspondiente no volvió a correr/no está en `SUCCESS`): **NO descartes la review**. Trátala como
`BLOCKED` y repórtala igual que cualquier otro bloqueo no arreglable por ti — que decida un humano.
- Repite esta verificación para cada review `CHANGES_REQUESTED` distinta que exista en el PR — puede
haber más de una.
### 4. Si algún check falla
a) **Ubica el fallo y su log completo**:
- GitHub: `gh pr checks <pr-number>` para ver qué check(s) fallaron. Luego
`gh run list --branch <branch> -L 20` para ubicar el run correspondiente al head commit, y
`gh run view <run-id> --log-failed` para el log del/los job(s) fallidos.
- Gitea: `mcp__gitea__actions_run_read({method:"list_runs", owner, repo, status:"failure"})` para ubicar
el run del head commit/rama, luego `list_run_jobs` sobre ese run para el job fallido, y
`get_job_log_preview` (o `download_job_log` si el preview no alcanza a mostrar el error real) para el
log completo.
b) **Diagnostica la causa real** leyendo el log de error junto con el código relevante (Read/Grep) — no
adivines; identifica la línea/regla/test exacto que falla y por qué.
c) **Arregla el código directamente**: edita los archivos necesarios con el fix mínimo y correcto para esa
falla concreta. No refactorices ni toques nada fuera del alcance del fallo.
d) **Commitea y haz push tú mismo** (excepción explícita a la regla general de "solo el agente que invoca
commitea" — aplica solo a ti, porque corres en un ciclo secuencial y cerrado, sin que nadie más toque git
en paralelo durante este ciclo):
- Mensaje de commit con HEREDOC, en la rama indicada (nunca en `main`/`master`/`dev`).
- **NUNCA** incluyas `Co-Authored-By: Claude`, "Generated with Claude Code", 🤖 ni ninguna atribución a
Claude — regla absoluta, verifica el mensaje antes de commitear.
- Solo `git add` de los archivos que realmente tocaste — nunca `git add -A`.
- `git push origin <branch>` — nunca `--force`/`--force-with-lease`.
e) **Vuelve al paso 2** (re-poll) para ver si el push disparó un nuevo run y si ahora pasa.
### 5. Cuándo declarar que NO es arreglable (a tu discreción — no hay un número fijo de intentos)
Detente y devuelve `BLOCKED` cuando concluyas fundadamente que:
- El **mismo check vuelve a fallar con la misma causa** después de ya haber intentado corregirla (no hay
progreso real entre intentos).
- El fallo es evidentemente **ajeno al diff de la tarea**: infraestructura caída, credenciales/secretos
faltantes que solo un administrador puede otorgar, un servicio externo no disponible, o un test
claramente flaky y no relacionado con los cambios de este PR.
- El fix requeriría una **decisión de alcance o arquitectura** que no te corresponde tomar solo (p.ej.
cambiar una API pública, o una decisión de producto).
No sigas reintentando el mismo fix una y otra vez sin cambiar de diagnóstico — si tu primer intento no
resolvió la causa raíz, reconsidera el diagnóstico antes de un segundo intento; si tampoco funciona y ya no
tienes una hipótesis nueva y fundada, detente y reporta `BLOCKED` en vez de seguir a ciegas.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
ESTADO: GREEN / BLOCKED
CHECKS: (lista de checks con su resultado final)
FIXES APLICADOS: (si hubo alguno — hash + archivos + qué se cambió y por qué; "ninguno" si ya estaba verde)
REVIEWS DESCARTADAS: (si aplicó el criterio del paso 3bis — para cada una: autor del bot, review_id, commit
que la corrigió, check que volvió a pasar; "ninguna" si no había o si quedaron en pie sin descartar)
SI BLOCKED — DIAGNÓSTICO COMPLETO: (qué check, qué error exacto, qué se intentó, por qué se concluye que no
es arreglable por ti — este texto se usa tal cual para el correo de bloqueo; si el bloqueo es por una review
CHANGES_REQUESTED que no pudiste confirmar como obsoleta, dilo explícitamente así)
```
@@ -0,0 +1,51 @@
---
name: code-reviewer
description: Revisa el diff o los cambios producidos por uno o varios subagentes developer antes de que se commiteen — busca bugs de lógica, problemas de seguridad, y oportunidades de simplificación. Solo inspecciona y reporta, nunca edita código ni hace commit. Úsalo después de que un developer (o un lote de developers en paralelo) terminó de editar, antes de git add/commit.
model: inherit
effort: high
color: yellow
maxTurns: 20
---
Eres un `code-reviewer`: revisas cambios de código YA HECHOS en disco (todavía sin commitear) por uno o
varios subagentes `developer`. No editas nada — solo inspeccionas y reportas. No tienes memoria de otras
conversaciones: todo el contexto que necesitas debe venir en el prompt o ser obtenible con `git diff`.
## Cómo trabajar
1. Identifica qué revisar: si quien te invocó te dio una lista de archivos, revisa el diff de esos archivos
exactos (`git diff -- <archivos>` o `git diff HEAD -- <archivos>` si ya hay staging previo). Si no te dio
lista, usa `git status --short` y `git diff` para ver todo lo pendiente de commitear.
2. Lee cada archivo cambiado con suficiente contexto alrededor (no solo las líneas del diff) para entender
si el cambio es correcto en su entorno real.
3. Busca, en este orden de prioridad:
- **Bugs de lógica**: casos borde no manejados, condiciones invertidas, off-by-one, null/undefined no
considerados, promesas no esperadas, errores no propagados/silenciados.
- **Seguridad**: inyección (SQL/comandos/HTML), secretos o credenciales hardcodeados, validación de
input faltante, exposición de datos sensibles en logs.
- **Simplificación / duplicación**: código repetido que podría reusar algo existente, complejidad
innecesaria, patrones inconsistentes con el resto del proyecto.
4. No corrijas nada tú mismo. Tu output es un informe para que quien te invocó decida si commitea tal cual
o pide una corrección (a un `developer`).
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
ARCHIVOS REVISADOS
- path/a/archivo1.ts
- path/a/archivo2.ts
HALLAZGOS BLOQUEANTES (deben corregirse antes de commitear)
- archivo:línea — descripción del problema y por qué es bloqueante
HALLAZGOS RECOMENDADOS (no bloquean, pero conviene corregir)
- archivo:línea — descripción
NITS (opcionales, estilo/preferencia)
- archivo:línea — descripción
VEREDICTO: APTO PARA COMMIT / REQUIERE CORRECCIÓN
```
Si no hay hallazgos en una categoría, escribe "ninguno". Sé concreto y accionable — cada hallazgo debe
poder convertirse directamente en una instrucción para un `developer` de corrección.
+71
View File
@@ -0,0 +1,71 @@
---
name: developer
description: Implementa una porción acotada y bien definida de trabajo (una función, módulo, componente o fix) restringida a archivo(s) específicos que el agente que lo invoca le asigna explícitamente. Úsalo para escribir o modificar código dentro de un alcance de archivos ya decidido. Se pueden lanzar varias instancias en paralelo (varias llamadas Task en un mismo turno) siempre que cada una reciba una lista de archivos DISJUNTA de las demás. Nunca hace git add ni commit, ni gestiona procesos de servidor de larga duración.
model: inherit
effort: medium
color: blue
maxTurns: 40
---
Eres un `developer`: implementas una porción acotada de código dentro de los archivos EXACTOS que te asignó
quien te invocó. No tienes memoria de otras conversaciones ni de otros subagentes — todo lo que necesitas
debe venir en el prompt que recibiste; si algo imprescindible falta, dilo como bloqueo en tu resumen final
en vez de asumirlo.
## Alcance (léelo primero)
- Toca ÚNICAMENTE los archivos que se te listaron explícitamente (rutas exactas). Si para completar la
tarea necesitas tocar un archivo fuera de esa lista, NO lo hagas por tu cuenta: detente, documenta por
qué en tu resumen final como "bloqueo/duda" y deja claro qué archivo adicional haría falta y por qué.
- Si varios `developer` corren en paralelo, tus archivos son DISJUNTOS de los suyos — no hay razón para que
toques nada fuera de tu lista.
## Cómo trabajar
1. Explora antes de escribir: usa Read/Grep/Glob sobre los archivos asignados y su contexto inmediato
(imports, tipos, tests existentes) para seguir las convenciones ya presentes en el proyecto (estilo,
nombres, patrones de manejo de errores, etc.). No inventes un estilo nuevo si ya hay uno establecido.
2. Implementa el cambio de forma incremental y verificable. Si el proyecto tiene comandos de verificación
que TERMINAN (typecheck, lint, build, test unitario puntual), puedes correrlos para autovalidar tu
propio trabajo.
3. **Nunca dejes procesos de servidor corriendo en background** (dev server, watch mode, `npm run dev`,
etc.) — no es tu responsabilidad gestionar el ciclo de vida del servidor; eso lo hace quien te invocó o
el subagente `qa-validator` más adelante. Si necesitas ejecutar algo para verificar, usa comandos que
terminen solos.
4. **Nunca hagas `git add`, `git commit`, `git push` ni crees ramas.** Tu trabajo termina en el código
editado en disco; el commit lo hace siempre quien te invocó, de forma secuencial junto con los demás
developers del mismo lote, para evitar carreras en el índice de git.
5. Si tienes una duda de diseño que cambia el alcance (ambigüedad real, no una decisión técnica menor),
toma la decisión más razonable tú mismo, documenta por qué en tu resumen, y sigue — no te bloquees por
decisiones de implementación rutinarias.
- **Ejemplo de decisión rutinaria (resuélvela tú, no la reportes como duda)**: el nombre de una variable
interna, en qué orden van los parámetros de una función nueva, si usar un `for` o un `.map()`.
- **Ejemplo de ambigüedad real (repórtala en "DUDAS / BLOQUEOS", no decidas por tu cuenta)**: la tarea
pide dos comportamientos mutuamente excluyentes, o para completarla necesitarías tocar un archivo
fuera de tu lista asignada.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
Tu respuesta final de texto debe ser un resumen completo y autocontenido, con esta estructura:
```
ESTADO: completo / parcial / bloqueado
QUÉ IMPLEMENTASTE
- (descripción concreta de los cambios)
ARCHIVOS TOCADOS (rutas exactas, todas dentro de tu alcance asignado)
- path/a/archivo1.ts — qué cambió
- path/a/archivo2.ts — qué cambió
DECISIONES DE DISEÑO Y POR QUÉ
- (cualquier decisión no trivial que tomaste y su justificación)
COMANDOS EJECUTADOS (si corriste build/lint/test para autovalidar)
- comando → resultado
DUDAS / BLOQUEOS
- (archivos fuera de tu alcance que hicieron falta, ambigüedades sin resolver, o "ninguno")
```
No omitas ninguna sección aunque esté vacía (usa "ninguno").
@@ -0,0 +1,54 @@
---
name: docmost-reporter
description: Actualiza la subpágina de Docmost del agente-hijo con un checkpoint de avance (fase completada, hash de commit, archivos cambiados, decisiones, bloqueos) usando el MCP mcp__docmost__*. Úsalo después de cada commit o fase importante — no solo al final — pasándole todo el contexto del checkpoint en el prompt, ya que no tiene memoria de invocaciones anteriores.
model: inherit
effort: low
color: cyan
maxTurns: 10
---
Eres un `docmost-reporter`: actualizas la subpágina de Docmost de un agente-hijo con un checkpoint de
avance. No tienes memoria de otras conversaciones ni de invocaciones anteriores tuyas: todo lo que debes
escribir (pageId, spaceId, y el contenido del checkpoint) debe venir completo en el prompt.
## Cómo trabajar
1. Lee primero la página con `mcp__docmost__get_page({pageId})` para ver su contenido actual antes de
escribir nada encima — nunca sobrescribas a ciegas.
2. Actualiza con `mcp__docmost__update_page` incorporando el checkpoint que te pasaron: fase/commit
completado, hash del commit (si aplica), archivos cambiados, decisiones de diseño y por qué, resultado
de validación funcional (si te lo pasaron), y estado.
3. **Usa tablas siempre que puedas, incluso en `update_page`**: son más fáciles de leer de un vistazo que
listas. La causa real de que una tabla se vea "colapsada" en Docmost es enviarla como un string de una
sola línea — **cada fila debe ir en su propia línea** (incluida la fila separadora `| --- |`), nunca todo
el markdown de la tabla concatenado. Siguiendo ese formato, las tablas renderizan igual de bien en
`update_page` que en `create_page`. Solo si, tras respetar ese formato, una tabla puntual sigue sin
renderizar bien, usa una lista (`- item`) como alternativa para esa sección concreta.
**No confíes en el formato del checkpoint tal como te llegó en el prompt.** Quien te invoca a veces te
pasa el contenido de una tabla ya colapsado en una sola línea (por ejemplo, todo junto así:
`| Campo | Valor | | Nombre | X | | Estado | Y |`). Si ves este patrón — varios pares `campo | valor`
pegados uno tras otro sin salto de línea real entre filas, o cualquier tabla cuyas filas no estén
separadas por saltos de línea — **es un error de formato que debés corregir vos, no repetir**:
reconstruí la tabla con sus filas reales (encabezado, fila `| --- | --- |`, y cada fila de datos en su
propia línea) antes de escribir con `update_page`. Nunca copies/pegues una tabla colapsada tal cual
venía en el prompt — tu trabajo incluye detectar y arreglar esto, no solo evitarlo cuando redactás vos
desde cero.
4. **NUNCA borres ni sobrescribas contenido que no te pidieron tocar.** Cuando la página tiene varias
secciones (por ejemplo "Agentes Activos" tiene una sección **"Historial"** con la tabla de agentes ya
archivados), conserva TAL CUAL cualquier sección fuera de tu checkpoint — en particular, la tabla
"Historial" es un registro permanente: bajo ninguna circunstancia elimines o vacíes filas que ya existían
en ella, aunque tu tarea no tenga nada que ver con el historial. Si te pidieron actualizar la fila del
agente en "Agentes Activos" (otro `pageId` distinto), toca solo esa fila y deja todo lo demás — incluido
"Historial" — exactamente como estaba.
5. Si el checkpoint indica un bloqueo, pon el campo Estado = "esperando-aprobación" y describe exactamente
qué se necesita, tal como te lo pasaron.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
PÁGINA(S) ACTUALIZADA(S): pageId(s) y nombre
CONTENIDO ESCRITO: (resumen fiel de lo que quedó en la página — si el MCP falló, el texto completo que
se intentó escribir, para que quien te invocó pueda reintentar)
RESULTADO DE LAS LLAMADAS MCP: éxito / error (con el mensaje de error si lo hubo)
```
+46
View File
@@ -0,0 +1,46 @@
---
name: mailer
description: Envía el correo SMTP de bloqueo o finalización del agente-hijo, reusando el comando curl estándar con las env vars AGENT_NAME/GMAIL_ADDRESS/GMAIL_APP_PASSWORD/PROJECT_NAME ya presentes en la sesión tmux. Úsalo en vez de correr el curl directamente, cada vez que el agente-hijo necesite notificar al usuario (bloqueo, necesita input, o finalización de tarea).
model: inherit
effort: low
color: orange
maxTurns: 5
---
Eres un `mailer`: tu única función es enviar UN correo SMTP con el asunto y cuerpo que te pasaron. No
tienes memoria de otras conversaciones: el asunto y el cuerpo ya deben venir redactados en tu prompt.
## Cómo trabajar
1. Verifica que las env vars estén presentes antes de intentar enviar:
```bash
[ -z "$GMAIL_ADDRESS" ] && echo "FALTA GMAIL_ADDRESS"
[ -z "$GMAIL_APP_PASSWORD" ] && echo "FALTA GMAIL_APP_PASSWORD"
```
Si falta alguna, repórtalo como fallo — no inventes valores ni las pidas por otro canal.
2. Envía exactamente este comando, sustituyendo `Subject` y el cuerpo por lo que te pasaron (mantén el
formato HEREDOC):
```bash
curl --ssl-no-revoke -s --url 'smtps://smtp.gmail.com:465' \
--user "$GMAIL_ADDRESS:$GMAIL_APP_PASSWORD" \
--mail-from "$GMAIL_ADDRESS" --mail-rcpt "$GMAIL_ADDRESS" --upload-file - <<EOF
From: Claude Orchestrator <$GMAIL_ADDRESS>
To: $GMAIL_ADDRESS
Subject: {asunto que te pasaron}
{cuerpo que te pasaron}
EOF
```
3. **Nunca imprimas `$GMAIL_APP_PASSWORD` en texto plano** en tu reporte ni en ningún log — el comando lo
usa vía variable de entorno, no lo repitas literal.
4. Revisa el código de salida de `curl`. Si fue distinto de 0, repórtalo como fallo con el error mostrado.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
RESULTADO: enviado / falló (con motivo)
ASUNTO USADO: {asunto exacto}
DESTINATARIO: $GMAIL_ADDRESS
RECORDATORIO: quien te invocó NO debe cerrar su sesión tras este correo — debe quedarse esperando la
respuesta del usuario vía tmux send-keys.
```
+46
View File
@@ -0,0 +1,46 @@
---
name: pr-shipper
description: Abre el PR final invocando la skill aleleba-pr (commit final si hiciera falta, push, creación del PR). Nunca mergea. Úsalo como el último paso antes de notificar al usuario, una vez que todos los commits ya están hechos y la validación de qa-validator pasó (o se omitió justificadamente).
model: inherit
effort: low
color: purple
maxTurns: 15
---
Eres un `pr-shipper`: tu única función es invocar la skill `aleleba-pr` para llevar el trabajo ya
commiteado hasta un PR abierto. No tienes memoria de otras conversaciones: la descripción de la tarea y
cualquier contexto para el título/cuerpo del PR deben venir en el prompt de quien te invocó.
## Cómo trabajar
1. Invoca la skill `aleleba-pr` (con la herramienta Skill) pasándole como argumento la descripción de la
tarea que recibiste, para que la use como contexto extra del título/cuerpo del PR.
2. Sigue el pipeline de la skill tal cual está definido (verificación de rama protegida, detección de
plataforma GitHub/Gitea, commit de cualquier pendiente con HEREDOC y sin `git add -A`, push, creación
del PR con resumen en inglés).
3. **Sobre la confirmación de push**: corres dentro de un agente autónomo en background sin usuario
interactivo disponible en este momento — la autonomía para operaciones de rutina (incluyendo el push de
esta tarea) ya fue otorgada de antemano por quien te invocó. No te quedes esperando una confirmación que
nadie va a dar; procede con el push. La única excepción real es la regla de seguridad más fuerte de la
skill: antes de continuar, corre este chequeo explícito:
```bash
git branch --show-current
```
Si el resultado es `main`, `master` o `dev` → **DETENTE**, no continúes, repórtalo como bloqueo. Además,
si el diff incluye cambios claramente fuera del alcance de la tarea (archivos de andamiaje como
`TASK.md`, `PLAN.md`, `.claude/settings.local.json`), DETENTE y repórtalo como bloqueo en vez de
continuar.
4. Nunca hagas merge del PR ni de ninguna rama — tu trabajo termina en el PR creado.
5. Verifica antes de terminar que el mensaje de commit y el cuerpo del PR NO contienen ninguna atribución a
Claude (`Co-Authored-By: Claude`, "Generated with Claude Code", 🤖, etc.) — regla absoluta.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
PLATAFORMA: GitHub / Gitea
RAMA: {branch-name} → BASE: {base-branch}
COMMITS INCLUIDOS EN EL PR: (lista breve, hash + mensaje)
PR: {url del PR}
ARCHIVOS DE ANDAMIAJE EXCLUIDOS: (confirmar que TASK.md/PLAN.md/settings.local.json no se colaron)
ESTADO: DONE / BLOQUEADO (con motivo)
```
+57
View File
@@ -0,0 +1,57 @@
---
name: qa-validator
description: Valida funcionalmente en un navegador real headless (envolviendo la skill web-ui-test) el flujo que se implementó, antes de abrir el PR. Úsalo cuando la tarea tocó una interfaz web, después de que el código relevante ya está commiteado o al menos estable. No lo uses para tareas puramente backend/CLI/librería sin interfaz web navegable.
model: inherit
effort: medium
color: green
maxTurns: 30
---
Eres un `qa-validator`: validas funcionalmente, en un navegador real headless, el flujo concreto que se
implementó. No tienes memoria de otras conversaciones: todo el contexto que necesitas (URL o descripción
del flujo, si el servidor de desarrollo ya está corriendo o hay que levantarlo, credenciales de prueba si
aplican) debe venir en el prompt de quien te invocó.
## Cómo trabajar
1. Invoca la skill `web-ui-test` (con la herramienta Skill) pasándole como argumento la URL o la
descripción del flujo a probar que recibiste en tu prompt.
2. Si quien te invocó indicó que el servidor de desarrollo YA está corriendo (y en qué puerto/URL), úsalo
directamente — no lo vuelvas a levantar. Si no hay servidor corriendo y el proyecto tiene un script de
desarrollo definible (`package.json` con `dev`/`start`), la skill `web-ui-test` ya sabe levantarlo; no
dupliques ese trabajo.
3. Sigue el ciclo de la skill: abrir → snapshot → interactuar (clicks/forms/navegación reales del flujo
concreto, no solo cargar la home) → volver a snapshot tras cada cambio de DOM → capturar evidencia →
cerrar la sesión SIEMPRE, incluso si algo falló.
4. Revisa errores de consola (`console error`) y compara el comportamiento observado contra lo que la tarea
debía lograr.
5. Si tu tarea recibida es explícitamente backend/CLI/librería sin interfaz web navegable, no ejecutes nada
de Playwright: repórtalo como "omitido — no aplica" y explica por qué.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
No vuelques snapshots crudos, HTML, ni el contenido de las capturas — solo rutas y resumen.
```
VEREDICTO: PASA / FALLA / OMITIDO (no aplica)
FLUJO PROBADO
- URL/flujo: ...
- Pasos ejecutados: ...
ARTEFACTOS
- .local/screenshots/...-viewport.png
- .local/screenshots/...-full.png
- .local/screenshots/...-snapshot.md
ERRORES DE CONSOLA
- (cantidad y detalle, o "ninguno")
HALLAZGOS (bugs de UX/funcionalidad si los hay)
- ...
ESTADO DE LA SESIÓN PLAYWRIGHT: cerrada / falló el cierre (requiere kill-all, ya ejecutado)
```
Si el veredicto es FALLA, sé específico sobre qué paso falló y qué se esperaba, para que quien te invocó
pueda corregirlo (con un `developer`) y volver a invocarte.
@@ -0,0 +1,102 @@
# Fix: los MCPs custom (gitea, docmost, github-personal, penpot, atlassian) dejaron de cargar
## Context
El usuario preguntó a qué MCPs tenía acceso Claude Code en esta sesión. Solo apareció el
conector cloud de Atlassian (vía claude.ai) — ninguno de los 5 servidores custom definidos en
`dev-environment/managed-mcp.json` (gitea, docmost, github-personal, penpot, atlassian-vía-npx)
está cargado. El usuario confirmó que **antes sí funcionaban** y no sabe por qué dejaron de
hacerlo.
Investigación (todo lectura, sin cambios):
1. `claude mcp list` solo lista los conectores cloud de claude.ai (Atlassian, Gmail, Google
Drive, etc.) — cero rastro de gitea/docmost/github-personal/penpot/atlassian-custom. Esto
confirma que Claude Code no está leyendo esos 5 servidores desde ninguna fuente.
2. Existe un plan previo del propio usuario
(`~/.claude/plans/creo-que-hay-un-rosy-unicorn.md`) que documenta la causa raíz genérica:
**`settings.json` no soporta la clave `mcpServers`** (se ignora). Los MCP solo se leen desde
`.mcp.json`, `~/.claude.json`, `--mcp-config`, o el mecanismo enterprise
**`/etc/claude-code/managed-mcp.json`** (ruta fija del sistema).
3. En [dev-environment/vscode-server/docker-compose.yaml](dev-environment/vscode-server/docker-compose.yaml#L60)
(y su análogo en `vscode-server-telus/docker-compose.yaml`), el volumen monta
`managed-mcp.json` en **`/home/aleleba/.claude/managed-mcp.json`** — una ruta que Claude Code
nunca lee. Esa línea se agregó en el commit `b81ffda` pero con la ruta equivocada; el plan
`rosy-unicorn` (que especifica la ruta correcta `/etc/claude-code/managed-mcp.json`) nunca se
llegó a aplicar en el compose.
4. La razón de que "antes funcionara": el commit HEAD de
[dev-environment/.claude.json](dev-environment/.claude.json) (mismo archivo que se monta como
`~/.claude.json` dentro del contenedor) sí tiene el bloque `mcpServers` con los 5 servidores.
Pero el archivo **actual en disco** (working tree, sin commitear) **perdió por completo esa
clave** — el resto del archivo también cambió drásticamente (`numStartups` bajó de 11 a 2,
aparecieron/desaparecieron decenas de feature flags `tengu_*`), lo que indica que Claude Code
reescribió ese archivo (por ejemplo tras una actualización de versión) y no preservó la clave
custom `mcpServers` que se había agregado manualmentente.
En resumen: el mecanismo que sí funcionaba (`mcpServers` dentro de `~/.claude.json`) es frágil
porque ese archivo lo administra/reescribe Claude Code y puede perder claves custom en cualquier
reinicio o actualización — que es exactamente lo que pasó. El mecanismo robusto
(`/etc/claude-code/managed-mcp.json`, que Claude Code no reescribe) está definido y documentado
pero nunca se aplicó realmente en el compose.
## Approach
Aplicar las dos capas de arreglo que el propio usuario ya había diseñado, pero que quedaron a
medias — decisión confirmada con el usuario: usar directamente la ruta enterprise correcta
(`/etc/claude-code/managed-mcp.json`) en vez de restaurar el `mcpServers` frágil dentro de
`~/.claude.json`.
### 1. Fix inmediato en este ambiente (sin recrear el contenedor)
Crear el archivo `/etc/claude-code/managed-mcp.json` directamente en el filesystem de este
contenedor ya corriendo, con el mismo contenido (5 servidores) que
[dev-environment/managed-mcp.json](dev-environment/managed-mcp.json). Al no venir de un volumen
todavía, este archivo puntual no persiste si el contenedor se recrea — por eso el paso 2 es
necesario para que sobreviva a futuros rebuilds/restarts.
### 2. Fix durable en ambos docker-compose (requiere recrear el contenedor — acción del usuario)
Corregir la ruta de montaje de `managed-mcp.json` en ambos docker-compose, de
`/home/aleleba/.claude/managed-mcp.json` a `/etc/claude-code/managed-mcp.json`, tal como
especifica `rosy-unicorn.md`:
- [dev-environment/vscode-server/docker-compose.yaml](dev-environment/vscode-server/docker-compose.yaml#L60)
- `dev-environment/vscode-server-telus/docker-compose.yaml` (línea equivalente, mismo patrón)
Esto hace que el origen de verdad para estos 5 MCP sea un archivo que Claude Code trata como
enterprise-managed (solo lectura para él) y por lo tanto no lo va a volver a pisar en una futura
actualización — eliminando la causa raíz de fondo, no solo el síntoma.
**Importante:** este cambio de docker-compose no toma efecto hasta que el contenedor se recree
(`docker-compose up -d --force-recreate` o equivalente), lo cual reinicia todo el entorno de
desarrollo (VS Code server, túnel, sesión actual). Esta acción disruptiva **no se ejecuta como
parte de este plan** — se deja documentada para que el usuario la corra cuando le convenga.
### Nota de seguridad (fuera de alcance, solo aviso)
`dev-environment/managed-mcp.json` y `dev-environment/.claude.json` contienen tokens en texto
plano (gitea, github, docmost, etc.) y **ambos ya están commiteados al repo** (no en
`.gitignore`). Esto es preexistente, no introducido por este fix. Se menciona como riesgo a
evaluar aparte (rotar tokens / gitignore / limpiar historial), no se toca en este plan.
## Files to change
1. `/etc/claude-code/managed-mcp.json` (dentro de este contenedor, fuera del repo) — crear con
el contenido de [dev-environment/managed-mcp.json](dev-environment/managed-mcp.json) (5
servidores). Fix inmediato, no persistente por sí solo.
2. `dev-environment/vscode-server/docker-compose.yaml` — cambiar el target del volumen de
`managed-mcp.json` de `/home/aleleba/.claude/managed-mcp.json` a
`/etc/claude-code/managed-mcp.json`.
3. `dev-environment/vscode-server-telus/docker-compose.yaml` — mismo cambio de ruta en la línea
análoga.
## Verification
1. Tras crear el archivo en `/etc/claude-code/managed-mcp.json`: reiniciar la sesión/proceso de
Claude Code (o `claude` CLI) dentro del contenedor y correr `claude mcp list` — deben aparecer
`gitea`, `docmost`, `github-personal`, `penpot` y `atlassian` (el custom, vía npx) como
`Connected`.
2. Tras el fix durable (cuando el usuario recree cada contenedor con el compose corregido):
`cat /etc/claude-code/managed-mcp.json` debe mostrar el JSON de los 5 servidores sin haber
sido creado a mano, y `claude mcp list` debe seguir mostrándolos `Connected` incluso después
de reinicios — confirmando que ya no depende de un paso manual ni de `~/.claude.json`.
@@ -0,0 +1,82 @@
# Fix CI: apt-get update falla tras actualizar la imagen base
## Contexto
El pipeline de `vscode-server` (`.gitea/workflows/main-workflow.yml`) construye la imagen a partir de
`FROM gitea.p-lao.com/aleleba/vscode:latest`, publicada manualmente desde el repo separado
`aleleba-vscode-dockerfile-configuration` (sin CI propia — se sube a mano).
Revisé el log del run fallido (run #34, job 1542,
https://gitea.p-lao.com/aleleba/vscode-server/actions/runs/34/jobs/0) y el primer paso que rompe es
`RUN sudo apt-get update` en [Dockerfile:14](Dockerfile#L14):
```
Err:4 https://packages.microsoft.com/repos/code stable InRelease
The following signatures couldn't be verified because the public key is not available: NO_PUBKEY EB3E94ADBE1229CF
E: The repository 'https://packages.microsoft.com/repos/code stable InRelease' is not signed.
```
**Causa raíz:** el Dockerfile de la imagen base agrega el repo de VS Code a mano vía
`add-apt-repository "deb [arch=...] https://packages.microsoft.com/repos/vscode stable main"` y confía la
clave de Microsoft con el método legacy `apt-key add` (keyring global `/etc/apt/trusted.gpg`). Pero el
propio paquete `.deb` de `code` (instalado sin versión fija, siempre "latest") sobreescribe
`/etc/apt/sources.list.d/vscode.list` apuntando a una ruta distinta —
`https://packages.microsoft.com/repos/code` — y añadiendo `signed-by=/etc/apt/keyrings/packages.microsoft.gpg`,
un keyring que el Dockerfile de la imagen base nunca crea. Como ese Dockerfile no vuelve a correr
`apt-get update` después de instalar `code`, el problema queda dormido en la imagen — y sólo se dispara la
próxima vez que alguien corre `apt-get update` sobre esa capa, que es justo lo primero que hace
`vscode-server` en su propio Dockerfile. Esto explica por qué el mismo commit (`62a2849`) tuvo corridas
exitosas (#32, #33) y luego falló (#34): la imagen `:latest` cambió por debajo (rebuild manual del usuario)
sin que cambiara el código de este repo.
Se optó por resolverlo del lado de `vscode-server` (en vez de tocar el repo de la imagen base, que no está
abierto en esta sesión) porque desbloquea el CI de inmediato y no depende de reconstruir/republicar la
imagen base.
## Cambio
En [Dockerfile](Dockerfile), antes del `RUN sudo apt-get update` inicial (línea 14), eliminar el archivo de
fuentes de VS Code que quedó roto — ya no hace falta: `code` ya está instalado en la imagen base, y este
repo no necesita volver a instalarlo ni actualizarlo.
```dockerfile
# Single apt update at the beginning
# The VS Code apt source baked into the base image points at a signed-by keyring
# that was never created, breaking `apt-get update` on any layer built on top of it.
# code is already installed in the base image, so this repo doesn't need the repo entry.
RUN sudo rm -f /etc/apt/sources.list.d/vscode.list /etc/apt/sources.list.d/vscode.sources
RUN sudo apt-get update
```
No se requieren más cambios en el Dockerfile: el resto de los pasos y el workflow de Gitea Actions no están
involucrados en el fallo.
## Bump de versión en README
El [README.md](README.md#L11) trae un `## Version` (actualmente `1.2.1`) que se incrementa manualmente en
cada cambio publicable. Subir a `1.2.2` (patch, es un fix) junto con el cambio del Dockerfile.
## Commit, tag y push
Este repo no usa PRs: los cambios van directo a `master` y el push dispara el workflow
(`on: push: branches: [ master ]`). Además se tagea la versión para dejar un punto de referencia.
1. `git add Dockerfile README.md`
2. `git commit -m "Fix VS Code apt source breaking apt-get update on the base image"`
3. `git tag v1.2.2`
4. `git push origin master`
5. `git push origin v1.2.2`
## Verificación
1. Ejecutar `docker build --platform linux/amd64 -t vscode-server-test .` localmente (o `docker buildx
build --platform linux/amd64,linux/arm64 ...` para replicar el multi-arch del CI) y confirmar que el
paso `apt-get update` y la instalación de paquetes completan sin el error `NO_PUBKEY`.
2. Tras el push, confirmar en https://gitea.p-lao.com/aleleba/vscode-server/actions que el job
`build-and-push` termina en verde para ambas plataformas.
3. (Opcional, recomendado para evitar que el problema reaparezca en la imagen base) Actualizar el
Dockerfile de `aleleba-vscode-dockerfile-configuration` para usar el método moderno `signed-by` en vez de
`apt-key add`, de forma que la clave quede en el mismo keyring que el paquete `code` espera. Esto queda
fuera del alcance de este cambio ya que ese repo no está abierto en esta sesión.
@@ -0,0 +1,53 @@
# Plan: Verificar y actualizar Button component con estilos de Penpot
## Contexto
Se conectó el MCP de Penpot y se exportaron todas las variantes de botones del diseño. El worktree `agente-button-ds` ya tiene una implementación del componente Button con 9 variantes, pero hay que verificar que los estilos exportados de Penpot coincidan exactamente con lo implementado, y actualizar cualquier discrepancia.
## Verificación de estilos Penpot vs Implementación
### Colores exactos de Penpot:
- **Gold**: `#EABF2D` (fill) / stroke `#EABF2D`
- **Amber**: `#D4880F` (fill)
- **Black/Dark**: `#1A1A2E` (fill/text)
- **Danger**: `#DE3626` (fill)
- **White**: `#FFFFFF`
- **Muted text**: `#6B6B7B`
### Tamaños exactos de Penpot:
- Primary: 170×40px, rounded 6px, font 12px bold
- Outline Gold: 230×40px, rounded 6px, font 12px bold
- Outline Dark: 110×40px, rounded 0px, font 11px bold
- Outline Light: 140×60px, rounded 0px, font 11px bold
- Ghost: 110×40px, rounded 20px, font 11px bold
- Circular: 56×56px, rounded 28px, font 20px
- Icon Edit: 36×36px, rounded 4px
- Icon Delete: 36×36px, rounded 4px
- File Upload: 200×36px, rounded 4px
### Bordes:
- Todos usan `stroke-width: 3` en Penpot → `border-[1.5px]` en Tailwind (mitad del valor visual)
- Outline Dark y Ghost tienen stroke `#1A1A2E` (black)
### Hover states:
- Outline Dark: hover bg `#D4880F` (amber), hover text `#fff`
- Ghost: hover bg `#D4880F` (amber), hover text `#fff`
## Pasos
1. **Verificar** que el `tailwind.config.js` del worktree tenga los colores y tamaños correctos según Penpot
2. **Verificar** que `Button/index.tsx` tenga las clases Tailwind correctas para cada variante
3. **Actualizar** cualquier discrepancia encontrada
4. **Commit** los cambios en la rama `agente-button-ds`
5. **Push** al remote
6. **Actualizar** Docmost con el checkpoint de avance
### Archivos a revisar:
- `.worktrees/agente-button-ds/tailwind.config.js`
- `.worktrees/agente-button-ds/src/components/Button/index.tsx`
- `.worktrees/agente-button-ds/src/components/Button/Button.stories.tsx`
### Verificación:
- `npm run build` debe pasar sin errores
- `npm run test` debe pasar
- Storybook debe mostrar todas las variantes correctamente
@@ -0,0 +1,73 @@
# Crear `managed-mcp.json` para montarlo como volumen
## Contexto
Los MCP no se cargan desde `settings.json` porque **`settings.json` no soporta la clave `mcpServers`** (ni el de usuario ni el de proyecto; es ignorada). Las definiciones de MCP solo se leen de `.mcp.json`, `~/.claude.json`, `--mcp-config` o **`managed-mcp.json`** (mecanismo system-wide, ruta fija en `/etc`).
Como el username del SO (y `$HOME`) se asigna al arrancar el contenedor, no sirve sembrar `~/.claude`. La solución independiente del usuario es el archivo en `/etc/claude-code/managed-mcp.json`. En vez de hornearlo en la imagen, se **monta como volumen (bind-mount)** desde el host → los tokens quedan solo en el host, fuera de la imagen del registry.
- Nombre de archivo obligatorio: **`managed-mcp.json`**
- Ruta destino dentro del contenedor: **`/etc/claude-code/managed-mcp.json`**
- Se carga para cualquier usuario y **sin depender de workspace-trust** (las fuentes *managed* aplican en carpetas untrusted).
## Cambio a implementar
1. Crear **`managed-mcp.json`** en la raíz del repo (`/home/aleleba/projects/vscode-server/managed-mcp.json`) con esta configuración (5 servidores, réplica exacta de los actuales, tokens literales):
```json
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://gitea-mcp.p-lao.com/mcp",
"headers": { "Authorization": "Bearer <<SPARK_PASSWORD_1>>" }
},
"docmost": {
"type": "http",
"url": "https://docmost-mcp.p-lao.com/mcp",
"headers": { "Authorization": "Bearer <<BEARER_TOKEN_1>>" }
},
"github-personal": {
"type": "http",
"url": "https://github-mcp.p-lao.com",
"headers": { "Authorization": "Bearer <<SPARK_PASSWORD_1>>" }
},
"penpot": {
"type": "http",
"url": "https://penpot-mcp.p-lao.com/mcp",
"headers": { "Authorization": "Bearer <<SPARK_PASSWORD_1>>" }
},
"atlassian": {
"type": "stdio",
"command": "npx",
"args": ["mcp-remote", "https://mcp.atlassian.com/v1/mcp"]
}
}
}
```
2. Añadir `managed-mcp.json` a **`.gitignore`** (contiene tokens en texto plano; no debe commitearse al repo que va al registry). El repo hoy no tiene `.gitignore`, así que se crea uno con esa entrada.
3. `chmod 644 managed-mcp.json` para que el usuario dinámico del contenedor pueda leerlo cuando se monte.
## Cómo montar el volumen (referencia para el usuario)
- `docker run`:
```
-v /ruta/en/host/managed-mcp.json:/etc/claude-code/managed-mcp.json:ro
```
- `docker-compose`:
```yaml
services:
vscode-server:
volumes:
- ./managed-mcp.json:/etc/claude-code/managed-mcp.json:ro
```
Docker crea `/etc/claude-code/` automáticamente al montar el archivo. El `:ro` lo deja de solo lectura dentro del contenedor.
## Verificación
1. Dentro del contenedor con el volumen montado: `cat /etc/claude-code/managed-mcp.json` devuelve el JSON esperado.
2. `claude mcp list` muestra los 5 servidores como **connected/healthy** (no `⏸ Pending approval`), sin aceptar ningún diálogo de trust, en un `$HOME` limpio.
3. Una llamada trivial a `gitea`/`docmost` confirma que los tokens montados funcionan.
@@ -0,0 +1,124 @@
# Plan: Instalar Claude Code system-wide (username-agnostic, última versión) en el Dockerfile
## Context
El [Dockerfile](Dockerfile) instala Claude Code vía el repo apt oficial (`claude-code`,
líneas 147155) desde el commit `7096b11`. Eso provocó dos problemas:
1. **Versión vieja**: el canal apt `stable` va por detrás del canal `latest`. En este
contenedor hay `2.1.197`, mientras que `latest` ya está en `2.1.207`.
2. **MCPs no cargan**: verificado que es *estructural*, no de versión. El usuario declaró sus
MCPs (gitea, docmost, github-personal, penpot, atlassian) bajo la clave `mcpServers` dentro
de `~/.claude/settings.json`, pero **Claude Code no lee `mcpServers` desde `settings.json`**.
`claude mcp list` lo confirmó: solo aparecen los conectores de claude.ai, ninguno de los del
usuario. Los MCPs de usuario viven en `~/.claude.json` (archivo hermano de `.claude/`, no
dentro), en `.mcp.json` de proyecto, o en `/etc/claude-code/managed-mcp.json` (enterprise).
Intentos previos y por qué fallaron:
- `curl … install.sh | bash` (commit original): deja el binario en `~/.local/bin` del usuario que
instala (root) → inalcanzable para otros HOME_USER.
- `HOME=/usr/local/claude … install.sh` (`be1a141`): el binario resuelve su home por el registro
del SO, ignoró el override y volvió a caer en `/root`.
- apt `claude-code` (`7096b11`): quedó en `/usr/bin` (bien para multi-usuario) pero atado al canal
`stable` desactualizado.
**Objetivo**: que `claude` esté disponible por defecto para **cualquier nombre de usuario** del
SO, siempre en la **última versión**, con **auto-update en runtime** funcionando, y sin depender
de trucos de `$HOME`/`.bashrc`.
## Alcance (confirmado con el usuario)
- **Este repo (Dockerfile)**: SOLO arreglar la instalación de Claude Code.
- **MCPs**: NO se toca la imagen. Se documenta la guía para que el usuario los reubique vía sus
volúmenes (sección "Guía MCPs" abajo).
## Enfoque recomendado
Descargar el binario nativo standalone directamente del canal oficial `latest` (el mismo artefacto
que `install.sh` usa internamente), verificar su checksum, y colocarlo en `/usr/local/bin/claude`
(modo 0755, root). Esa ruta ya está en el PATH por defecto de todo usuario (igual que `gh`,
`kubectl`, `docker`), así que es completamente independiente del nombre de usuario y no necesita
exports en `.bashrc`.
El auto-update en runtime sigue funcionando **por usuario**: el updater nativo descarga nuevas
versiones en el propio `~/.local/share/claude/versions/` de cada usuario, independiente del binario
root de `/usr/local/bin`. La línea existente `export PATH="$HOME/.local/bin:$PATH"` (línea 176)
hace que esa copia actualizada por usuario tome precedencia. El binario base de la imagen se
refresca además en cada build de CI.
## Cambios en [Dockerfile](Dockerfile)
### 1. Reemplazar el bloque apt (líneas 147155) por descarga directa del binario `latest`
`python3` ya está disponible en esta etapa (viene de `python3-pip`), así que se usa para parsear el
`manifest.json` de forma robusta. El base es Debian/glibc → variante `linux-<arch>` (no musl).
```dockerfile
# Installing Claude Code (system-wide, latest native build, accessible by any user)
# Pull the standalone native binary straight from the official 'latest' release
# channel (the same artifact install.sh uses internally) and drop it on the
# system PATH at /usr/local/bin -- already on every user's PATH, like gh/kubectl.
# Avoids install.sh's per-user ~/.local/bin layout (unreachable for other users)
# and the apt 'stable' channel lag that shipped an outdated build. Runtime
# auto-update still works per user: the native updater writes new versions into
# each user's own ~/.local/share/claude/versions.
RUN set -eux; \
case "$(dpkg --print-architecture)" in \
amd64) CC_ARCH=x64 ;; \
arm64) CC_ARCH=arm64 ;; \
*) echo "unsupported architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac; \
CC_BASE="https://downloads.claude.ai/claude-code-releases"; \
CC_VERSION="$(curl -fsSL "${CC_BASE}/latest")"; \
CC_CHECKSUM="$(curl -fsSL "${CC_BASE}/${CC_VERSION}/manifest.json" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['platforms']['linux-${CC_ARCH}']['checksum'])")"; \
curl -fsSL "${CC_BASE}/${CC_VERSION}/linux-${CC_ARCH}/claude" -o /tmp/claude; \
echo "${CC_CHECKSUM} /tmp/claude" | sha256sum -c -; \
sudo install -m 0755 -o root -g root /tmp/claude /usr/local/bin/claude; \
rm -f /tmp/claude; \
claude --version
```
### 2. Eliminar la línea de auto-update específica de apt (líneas 177178)
```dockerfile
# Let Claude Code auto-upgrade its apt package in the background when a new version ships
RUN echo 'export CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1' | sudo tee -a /usr/bin/.bashrc
```
Ya no aplica (no es instalación apt). El auto-update nativo funciona por defecto sin esa env var.
Se **conserva** la línea 176 (`export PATH="$HOME/.local/bin:$PATH"`) porque es de propósito
general y ayuda a que el claude auto-actualizado por usuario tome precedencia.
### 3. Bump de versión en [README.md](README.md)
`1.4.1``1.4.2` (mismo patrón que los commits previos).
## Guía MCPs (fuera de la imagen — lo aplica el usuario en sus volúmenes)
Causa raíz: `mcpServers` en `~/.claude/settings.json` es ignorado. El resto de `settings.json`
(`permissions`, `hooks`, `model`, `enabledPlugins`, `env`, `theme`, etc.) **sí es válido y se
queda ahí**.
Pasos:
1. Mover el bloque `mcpServers` de `~/.claude/settings.json` al archivo `~/.claude.json`
(en el HOME, hermano del directorio `.claude/`), como clave top-level:
```json
{ "mcpServers": { "gitea": { "type": "http", "url": "…", "headers": { … } }, "docmost": { … } } }
```
2. Persistir `~/.claude.json` en el volumen por usuario, montado **read-write** (Claude reescribe
ese archivo con historial de proyectos; no debe ser read-only). El volumen actual que monta
`~/.claude/` no lo cubre porque `~/.claude.json` está fuera de ese directorio.
3. Borrar el bloque `mcpServers` obsoleto de `settings.json` para evitar confusión.
## Verificación
1. **Build**: `docker build -t vscode-server:test .` (o vía el workflow de Gitea). Confirmar que el
nuevo `RUN` imprime `claude --version` = `2.1.207` (o superior) sin error de checksum.
2. **Multi-arch**: el `case` cubre amd64/arm64; el build de CI es `linux/amd64,linux/arm64`.
3. **Username-agnostic**: correr el contenedor y, como el usuario por defecto y como otro usuario
distinto, verificar `which claude` → `/usr/local/bin/claude` y que `claude --version` responde.
4. **Auto-update runtime**: tras un rato de uso, confirmar que aparece `~/.local/share/claude/versions/`
en el home del usuario (update nativo por usuario) sin permisos root.
5. **MCPs** (tras aplicar la guía en el volumen): `claude mcp list` debe listar gitea/docmost/
github-personal/penpot/atlassian como `Connected`.
@@ -0,0 +1,57 @@
# Plan: Hacer que .bashrc cargue variables de entorno en shells no interactivos
## Context
The `.bashrc` file has an early-exit guard at lines 5-9:
```bash
case $- in
*i*) ;;
*) return;;
esac
```
This causes bash to skip the entire file when invoked in **non-interactive mode** (scripts, `bash -c "..."`, CI, Docker exec). All the `export` statements at the bottom of the file (PATH, JAVA_HOME, ANDROID_HOME, NVM, SDK, tokens) are therefore never loaded in non-interactive shells.
The `.profile` sources `.bashrc`, but `.profile` is only read by login shells. So login shells get the exports (via `.profile``.bashrc`), but non-interactive shells do not.
## Solution
Move the environment variable exports **above** the interactive guard so they are always sourced, regardless of shell mode. Keep interactive-only content (aliases, prompt, history, completion) **below** the guard.
### File to modify
- `/home/aleleba/projects/aleleba-vscode-dockerfile-configuration/.bashrc` (also copied to `~/.bashrc` by entrypoint.sh)
### Changes
1. **Keep the guard at the top** — it stays, but only guards interactive-only content.
2. **Move all `export` statements above the guard**:
- `export LS_COLORS=...`
- `export PATH=...` (includes Java, Android SDK, NVM paths)
- `export JAVA_HOME=...`
- `export ANDROID_HOME=...`
- `export ANDROID_SDK_ROOT=...`
- `export NVM_DIR=...` + `nvm.sh` source + `nvm use default`
- `export EMSDK_QUIET=...`
- `source /emsdk/emsdk_env.sh`
- `export PATH="$HOME/.local/bin:$PATH"`
- All token/credential exports (`GITEA_TOKEN`, `GMAIL_ADDRESS`, `NPM_TOKEN`, `SUPABASE_ACCESS_TOKEN`, etc.)
3. **Keep everything else below the guard** (aliases, prompt, history, completion, dircolors, etc.) — these are interactive-only and should not run in scripts.
4. **Keep `.profile` as-is** — it sources `.bashrc`, which will now correctly load exports even in login shells.
### Result
| Shell type | Exports loaded? | Aliases/prompt loaded? |
|---|---|---|
| Interactive login | Yes (via `.profile``.bashrc`) | Yes |
| Interactive non-login | Yes (direct `.bashrc` source) | Yes |
| Non-interactive (`bash -c`, scripts, CI) | **Yes** (exports are above guard) | No (correct) |
### Verification
1. `bash -c 'echo $JAVA_HOME $ANDROID_HOME $NVM_DIR'` — should print values
2. `bash -c 'echo $GITEA_TOKEN'` — should print the token
3. `bash -c 'alias ll'` — should fail (aliases not loaded in non-interactive, correct)
4. `bash -i -c 'alias ll'` — should work (interactive still gets aliases)
5. Build and run the Docker container, exec into it and check `printenv | grep -E 'JAVA|ANDROID|NVM|PATH'`
@@ -0,0 +1,45 @@
# Plan: Agregar SSL al deployment de luminarrh
## Contexto
La base de datos PostgreSQL de luminarrh ahora requiere conexión SSL obligatoria. El certificado SSL ya fue proporcionado. Hay que crear un Secret de Kubernetes con el certificado, montarlo en el pod y actualizar la connection string con los parámetros SSL.
## Cambios
### 1. Crear nuevo archivo: `00a-ssl-secret.yaml`
Crear un Kubernetes Secret con el certificado SSL:
- Tipo: `Opaque`
- Data: el certificado base64-encoded como `ca.crt`
- Namespace: `luminarrh`
### 2. Modificar: `01-luminarrh-deployment.yaml`
- Agregar un volume que monte el secret como archivo:
```yaml
volumes:
- name: ssl-cert
secret:
secretName: luminarrh-ssl-cert
```
- Agregar volumeMount al container:
```yaml
volumeMounts:
- name: ssl-cert
mountPath: /etc/ssl/certs/luminarrh-ca.crt
subPath: ca.crt
```
- Actualizar la connection string para incluir SSL:
```
Host=<<INTERNAL_IP_1>>;Port=5436;Database=sarh_db;Username=sarh2;Password=<<DB_PASSWORD_1>>;SslMode=require;SslRootCert=/etc/ssl/certs/luminarrh-ca.crt
```
### 3. No modificar:
- `00-ns-and-sa.yaml` — no cambios
- `02-luminarrh-svc.yaml` — no cambios
- `03-ingress.yaml` — no cambios
- `04-luminarrh-hpa.yaml` — no cambios
- `00-pullsecret.yaml` — no cambios
## Verificación
- Aplicar los cambios: `kubectl apply -f apps/luminarrh/`
- Verificar que el secret se creó: `kubectl get secret luminarrh-ssl-cert -n luminarrh`
- Verificar que el pod levanta: `kubectl get pods -n luminarrh`
- Verificar que el certificado está montado: `kubectl exec <pod> -n luminarrh -- cat /etc/ssl/certs/luminarrh-ca.crt`
@@ -0,0 +1,56 @@
# Plan: Agregar guard de `disabled` al handler `onClick` en Button
## Contexto
El componente `Button` (`src/components/Button/index.tsx`) ya expone una prop `onClick`, pero la implementación no verifica el estado `disabled` antes de ejecutar el handler. El test existente (`Button.test.tsx` línea 21-27) espera que `onClick` **no se llame** cuando el botón está disabled, pero la implementación actual no tiene esa protección.
## Cambios
### 1. `src/components/Button/index.tsx` — Agregar guard disabled en onClick
En el render del `<button>` (línea 80-88), envolver `onClick` para que no se ejecute si `disabled` es true:
```tsx
<button
className={`btn ${variantClasses[variant]} ${className}`.trim()}
onClick={disabled ? undefined : onClick}
type={type}
disabled={disabled}
>
{children}
</button>
```
También agregar el guard para el caso `file-upload` (línea 71), donde `onChange` del input file también debería respetar `disabled`.
### 2. `src/components/Button/__tests__/Button.test.tsx` — Agregar test positivo
Agregar un test que verifique que `onClick` **sí se llama** cuando el botón está habilitado y se hace click:
```tsx
it('calls onClick handler when enabled and clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
button.click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
```
### 3. `src/components/Button/Button.stories.tsx` — Story de onClick
Agregar una Story que demuestre el uso de `onClick` con una función callback simple, para que sea visible en Storybook.
## Archivos a modificar
- `src/components/Button/index.tsx`
- `src/components/Button/__tests__/Button.test.tsx`
- `src/components/Button/Button.stories.tsx`
## Verificación
```bash
npm test -- --testPathPattern="Button.test" --coverage
```
Confirmar que todos los tests de Button pasan y que el coverage se mantiene.
@@ -0,0 +1,46 @@
# Plan: Add `--no-sleep` to `code tunnel` and change `exec` to run tunnel directly
## Context
The user wants two changes in `entrypoint.sh`:
1. Add `--no-sleep` flag to the `code tunnel` invocations at the end of the file.
2. Change the `exec` at line 84 to run `code tunnel` directly instead of re-invoking the entrypoint script.
## Files to modify
- `entrypoint.sh` — lines 8386 and 173177
## Changes
### 1. Replace the exec re-invocation (line 84)
**Before:**
```bash
exec sudo -u $HOME_USER bash -c "source /etc/environment; /usr/bin/entrypoint.sh"
```
**After:**
```bash
exec sudo ${HOME_USER} -c "code tunnel --accept-server-license-terms --no-sleep --name ${VSCODE_TUNNEL_NAME}"
```
### 2. Add `--no-sleep` to the existing `code tunnel` calls (lines 173177)
**Before:**
```bash
if [[ -v VSCODE_TUNNEL_NAME && -n "${VSCODE_TUNNEL_NAME}" ]]; then
sudo su ${HOME_USER} -c "code tunnel --accept-server-license-terms --name ${VSCODE_TUNNEL_NAME}"
else
sudo su ${HOME_USER} -c "code tunnel --accept-server-license-terms"
fi
```
**After:**
```bash
if [[ -v VSCODE_TUNNEL_NAME && -n "${VSCODE_TUNNEL_NAME}" ]]; then
sudo su ${HOME_USER} -c "code tunnel --accept-server-license-terms --no-sleep --name ${VSCODE_TUNNEL_NAME}"
else
sudo su ${HOME_USER} -c "code tunnel --accept-server-license-terms --no-sleep"
fi
```
## Verification
- `grep -- '--no-sleep' entrypoint.sh` should show 3 matches (1 in exec line, 2 in the if/else block).
@@ -0,0 +1,64 @@
# Fix: attendance "Present" not saved automatically on login
## Context
GFIBER-649 shipped "auto-mark attendance Present on login" (merged to `dev` 2026-06-23, confirmed live in both `main` and `dev` branches — they differ by only 2 unrelated commits). The implementation, `markSelfPresentToday()`, has been silently failing for **every non-admin user** since it shipped. Since a missing attendance row degrades to the harmless-looking "Pending" state (see `utils/dashboard/attendance.ts` — no record = assumed present in aggregate counts), nobody saw an error; the bug likely surfaced through the admin "pending attendance" bell/digest email never clearing for otherwise-active agents, which matches what was reported: attendance never gets marked Present at login.
The confirmed root cause: **RLS blocks every regular user's self-insert.** `markSelfPresentToday()` (`app/(protected)/upsell-evaluator/attendance-actions.ts`) runs through the anon-key Supabase client (`utils/supabase/server.ts`), so Row-Level Security applies. The only policy on `public.attendance` (`supabase/migrations/20260605130000_create_attendance.sql`) is `FOR ALL ... USING/WITH CHECK (role IN ('admin','owner'))` — it was written for the admin-only dashboard grid and restricts *every* operation, including INSERT, to admins/owners. Regular agents default to `role = 'user'` (`20260603000000_add_role_to_profiles.sql`), so their self-upsert is rejected by RLS. The calling code never reads the `{error}` from `.upsert(...)` — it's discarded entirely — so the failure has been completely invisible. An existing test (`__tests__/dashboard-attendance-actions.test.ts:82-85`) even asserts this silence as current behavior. This can only be fixed with a DB-level policy change (a migration) — no amount of application code can grant a permission Postgres RLS denies.
> **Ruled out:** a CHECK-constraint drift between prod/dev on `attendance_status_valid` (migration history shows a prod rollback + dev-only re-narrowing that looked unreconciled). Confirmed with the team that admins can already set status manually without error, which proves the constraint already accepts `'P'`/`'A'` in both environments — no constraint migration needed. This plan sticks to the confirmed RLS fix only.
Fixing the RLS policy, plus moving the call site and adding error visibility, closes the bug and prevents this exact silent-failure pattern from recurring.
> **Update (post-deploy verification, 2026-07-01):** the first INSERT-only policy (below) was necessary but not sufficient. Live testing in DEV showed `markSelfPresentToday()` still failed to write for a real `role='user'` account even after the migration was applied. Root cause, found by reproducing the exact `INSERT ... ON CONFLICT (agent_id, date) DO NOTHING` statement directly against the DEV database: **Postgres RLS requires SELECT-level visibility into a potentially-conflicting row to resolve an `ON CONFLICT ... DO NOTHING` clause, even though DO NOTHING never reads that row.** With only an INSERT policy and no SELECT policy, Postgres rejects the whole statement with the same generic "violates row-level security policy" error — indistinguishable from the original bug without direct reproduction. A second migration (`20260701000001_attendance_self_checkin_select.sql`) adds a `FOR SELECT` policy scoped to `agent_id = auth.uid()` (a user may see their own attendance rows). Verified end-to-end against the live DEV database: the exact upsert now succeeds and the row persists.
## Approach
### 1. New migration — self check-in RLS policy
`supabase/migrations/20260701000000_attendance_self_checkin_rls.sql`:
```sql
create policy "Users can self-check-in present today"
on public.attendance
for insert
to authenticated
with check (
agent_id = (select auth.uid())
and status = 'P'
and date = current_date
);
```
Postgres OR's all applicable permissive policies for a given command, so this adds an allowed INSERT path without touching the existing admin `FOR ALL` policy — admins keep full read/write/delete over every row; regular users still cannot SELECT/UPDATE/DELETE, or insert any row but their own today's `'P'`. Only `WITH CHECK` is needed (no `USING`) since `FOR INSERT` policies don't evaluate `USING`. `markSelfPresentToday()` calls `upsert(..., { ignoreDuplicates: true })`, which compiles to `INSERT ... ON CONFLICT DO NOTHING` — no UPDATE path — so an INSERT-only policy is sufficient and admin-set `'A'` rows are never overwritten.
### 2. `attendance-actions.ts` — stop swallowing the error
Destructure `{ error }` from the upsert result and `console.error("markSelfPresentToday: upsert failed:", error)` when present, matching the existing repo convention (`app/(protected)/actions.ts`). Function stays best-effort — never throws, return type unchanged. This is what turns the next silent RLS/constraint regression into something visible in logs instead of a months-long undetected bug.
### 3. Move the call site from the page to the shared protected layout
Today `markSelfPresentToday()` only runs when `app/(protected)/upsell-evaluator/page.tsx` renders — not literally "on login." `app/(protected)/layout.tsx` wraps *every* protected route (dashboard, admin, my-metrics, upsell-evaluator) and already gates on `getCurrentUserWithProfile()`. Move the call there (after the auth gate resolves, before the admin-only pending-agents fetch), and remove it from `page.tsx`. This actually matches the intent already documented in the code comment ("login = auto-present per the GFIBER-649 rule") regardless of which page a post-login redirect lands on.
### 4. Tests (TDD — write these alongside/before the code changes)
- `__tests__/dashboard-attendance-actions.test.ts`: add a case asserting `console.error` is called with the upsert error (using the existing `wireAnonDb` helper, no changes needed there), and a case asserting no log on success.
- `__tests__/protected-layout.test.ts`: **must** be updated or it breaks — it currently does not mock `@/app/(protected)/upsell-evaluator/attendance-actions`, confirmed by direct inspection. Add a `vi.fn()` mock (same pattern as `__tests__/upsell-evaluator-page.test.tsx`), assert it's called once for any authenticated role and not called when unauthenticated, add `mockClear()` to the existing `beforeEach`.
- `__tests__/upsell-evaluator-page.test.tsx`: no required changes (it mocks the whole module and never asserts the call happened).
## Risks / edge cases (validated)
- **Cross-agent / backdating abuse**: impossible — `WITH CHECK` binds `agent_id = auth.uid()` (server-derived, unspoofable) and `date = current_date` (evaluated server-side).
- **UTC timezone assumption**: client computes `new Date().toISOString()` (always UTC); Postgres `current_date` resolves in the DB session timezone, which is UTC by default and not overridden in `supabase/config.toml`. A future non-UTC DB timezone change could cause near-midnight false rejects (logged now, not silent) — not a data-safety issue, just worth a code comment.
## Verification
No pgTAP/DB-level test harness exists in this repo — RLS behavior must be verified against a live Supabase project:
1. `npm run test` — full suite green, including the updated `protected-layout` and `dashboard-attendance-actions` tests.
2. `supabase db push` to **dev** (`vshcimexazocttjmyrqy`); confirm the migration applies cleanly.
3. Inspect live state: `select policyname, cmd, with_check from pg_policies where tablename='attendance';` — confirm both the new self-check-in policy and the existing admin policy are present.
4. Log in as a real non-admin (`role='user'`) test account in dev, land on any protected page (not just Upsell Evaluator, to confirm the layout move), then confirm a `status='P'` row exists for that agent/today via the dashboard grid or a direct query.
5. As an admin, confirm the dashboard's manual attendance marking/clearing still works (the admin `FOR ALL` policy is untouched).
6. Reload the same session same-day — confirm no duplicate row or error (exercises `ON CONFLICT DO NOTHING`).
7. Push the same migration to **production** (`hwxkegbdnbrzvekrqxbd`) and repeat step 4 against a real prod account.
@@ -0,0 +1,76 @@
# Fix: VSCode se instala en versión vieja en la imagen ARM64
## Contexto
Este repo construye una imagen Docker multi-arquitectura (`linux/amd64` + `linux/arm64`) vía GitHub Actions (`.github/workflows/main-workflow.yml`), instalando VSCode (`code`) dentro de un `ubuntu:22.04` para exponerlo vía `code tunnel` (ver `entrypoint.sh`).
El usuario reportó que la build ARM instala una versión vieja de VSCode mientras que x86 sí instala la última. Investigación (Dockerfile completo, historial de git, y research web) confirmó:
- El bloque de instalación de VSCode (`Dockerfile:30-38`) usa `dpkg --print-architecture` de forma dinámica y correcta para ambas arquitecturas — **no hay hardcode ni bug de arquitectura en el código**.
- El bloque instala vía `apt-get install -y code` contra el repo apt `packages.microsoft.com/repos/vscode stable`. Ese repo **no publica los `.deb` de `amd64` y `arm64` de forma atómica/simultánea** — el proceso de firmado/publicación de Microsoft puede tener horas de desfase entre arquitecturas (documentado en issues del propio repo de `microsoft/vscode`, incluyendo un caso donde el build de amd64 faltaba mientras arm64 ya estaba publicado). Como el job de CI construye ambas plataformas en la misma corrida, si arm64 aún no tiene el paquete nuevo publicado en el índice apt en ese instante, la imagen arm64 termina con una versión más vieja que la de amd64.
- Hallazgo secundario: el workflow nunca configura `docker/setup-qemu-action` para registrar la emulación de arm64 en el runner amd64 de GitHub — actualmente funciona porque el runner probablemente trae binfmt registrado por defecto, pero es una dependencia implícita no documentada.
**Decisión ya tomada con el usuario:** en vez de pinnear una versión exacta, se reemplaza el mecanismo de instalación para descargar el `.deb` directamente desde el endpoint canónico de Microsoft (`update.code.visualstudio.com/latest/linux-deb-{arch}/stable`), que es la fuente "siempre la última" oficial que usa la propia página de descargas de VSCode, evitando el índice del repo apt que tiene el desfase documentado. Se mantiene el comportamiento actual de "rolling latest" para ambas arquitecturas, pero leyendo de la fuente correcta.
## Cambios
### 1. `Dockerfile` — reemplazar el bloque de instalación de VSCode (líneas 30-38)
Quitar la dependencia del repo apt de Microsoft (gnupg2, software-properties-common, import de key, `add-apt-repository`) y reemplazarla por descarga directa + `dpkg -i` + `apt-get install -f -y` para resolver dependencias faltantes (una `.deb` instalada con `dpkg -i` en una imagen mínima de Ubuntu típicamente falla por dependencias no resueltas — eso es normal y se arregla con el `apt-get install -f -y` inmediatamente después).
```dockerfile
#Instalando VSCode
RUN ARCH="$(dpkg --print-architecture)" \
&& case "${ARCH}" in \
amd64) VSCODE_ARCH="x64" ;; \
arm64) VSCODE_ARCH="arm64" ;; \
*) echo "Unsupported architecture: ${ARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://update.code.visualstudio.com/latest/linux-deb-${VSCODE_ARCH}/stable" -o /tmp/vscode.deb \
&& (sudo dpkg -i /tmp/vscode.deb || true) \
&& sudo apt-get update \
&& sudo DEBIAN_FRONTEND=noninteractive apt-get install -f -y \
&& rm -f /tmp/vscode.deb
```
Notas importantes sobre esta forma exacta (difiere levemente del primer borrador que salió del research, ya corregido aquí):
- El `case` mapea `amd64``x64` y `arm64``arm64` (así nombra Microsoft los paths de descarga), y falla explícito ante cualquier otra arquitectura en vez de descargar algo inválido silenciosamente.
- `(sudo dpkg -i /tmp/vscode.deb || true)` va **entre paréntesis** — así el `|| true` sólo absorbe el fallo esperado de dependencias de `dpkg -i`, sin enmascarar un fallo del `curl` anterior (si el `&&` completo se escribe sin paréntesis, un `curl` fallido también quedaría "perdonado" por el `|| true` y la imagen se armaría sin VSCode instalado, sin que el build falle — bug a evitar).
- `curl` ya está instalado antes en el Dockerfile (línea 8), no hace falta agregarlo.
- Ya no se necesitan `gnupg2`, `software-properties-common`, el `wget | apt-key add`, ni `add-apt-repository` — se eliminan del bloque. Confirmado que nada más adelante en el Dockerfile (DevTunnel, chmod, entrypoint) depende de esos paquetes.
- `rm -f /tmp/vscode.deb` limpia el `.deb` descargado en la misma capa.
### 2. `.github/workflows/main-workflow.yml` — agregar `setup-qemu-action` explícito
Antes del step "Set up Docker Buildx" (líneas 14-15), agregar:
```yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
```
Alcance acotado a propósito: **no** se actualizan las demás actions del workflow (`actions/checkout@v2`, `docker/setup-buildx-action@v1`, `docker/login-action@v1`, `docker/build-push-action@v2`), ya que son cambios no relacionados al bug de emulación/versión que se está resolviendo. Un bump general de versiones de actions sería un cambio deliberado aparte, si se quiere hacer.
### 3. `version.txt`
Bump de `3.2.23` a `3.2.24`, siguiendo la convención existente del repo de bumpear este archivo junto con cambios al Dockerfile/instalación de VSCode (ver historial de commits).
## Archivos a modificar
- `Dockerfile`
- `.github/workflows/main-workflow.yml`
- `version.txt`
## Verificación
1. Build local nativo (sin emulación) para confirmar que el flujo x86 sigue funcionando:
`docker buildx build --platform linux/amd64 -t test-amd64 --load .`
`docker run --rm test-amd64 code --version`
2. Build local con emulación arm64 (requiere QEMU registrado localmente, p.ej. Docker Desktop ya lo trae, o `docker run --privileged --rm tonistiigi/binfmt --install all`):
`docker buildx build --platform linux/arm64 -t test-arm64 --load .`
`docker run --rm --platform linux/arm64 test-arm64 code --version`
3. Comparar el output de `code --version` (versión + commit hash) entre ambos — deben coincidir, confirmando que el bug de desfase quedó resuelto.
4. Sanity check de que el binario resuelve sus dependencias en runtime (no solo que el archivo existe): `docker run --rm test-amd64 code tunnel --help`.
5. Si no es viable correr la build emulada localmente, hacer push a `master` para disparar `main-workflow.yml`, y luego comparar `docker run --rm --platform linux/arm64 aleleba/vscode:latest code --version` vs `--platform linux/amd64` contra la imagen recién publicada.
@@ -0,0 +1,61 @@
# Plan: Habilitar SSL en PostgreSQL para conexiones externas
## Context
Se quiere que PostgreSQL acepte tanto conexiones no encriptadas (para la app interna) como conexiones encriptadas SSL (para administración remota). PostgreSQL lo soporta nativamente: se habilita SSL en el servidor y cada cliente decide si lo usa o no.
## Cambios
### 1. Generar certificado SSL auto-firmado
Crear un script de setup que genere el certificado al iniciar el contenedor por primera vez:
```bash
# luminarrh/generate-ssl.sh
openssl req -new -x509 -days 3650 \
-nodes \
-text \
-out server.crt \
-keyout server.key \
-subj "/CN=postgres"
chmod 600 server.key
cat server.crt server.key > server-ssl.pem
rm server.crt server.key
```
### 2. Actualizar `postgresql.conf`
Agregar al final:
```conf
# SSL
ssl = on
ssl_cert_file = '/var/lib/postgresql/18/docker/server-ssl.pem'
ssl_key_file = '/var/lib/postgresql/18/docker/server-ssl.pem'
```
### 3. Actualizar `docker-compose.yaml`
```yaml
volumes:
- /volume1/docker/postgres/postgres-sarh/data:/var/lib/postgresql
- /volume1/docker/projects/synology-apps-containers/luminarrh/databases/postgresql.conf:/etc/postgresql/postgresql.conf
- ./luminarrh_demo.sql:/docker-entrypoint/initdb.d/luminarrh_demo.sql
- ./generate-ssl.sh:/docker-entrypoint/initdb.d/generate-ssl.sh # nueva línea
```
### 4. Crear `luminarrh/generate-ssl.sh`
Script ejecutable que se corre en el primer inicio del contenedor para generar el certificado SSL.
## Cómo funciona
- **App interna**: Se conecta sin SSL (sin cambiar nada en la app, funciona como antes)
- **Admin externo**: Se conecta con SSL usando `sslmode=require` o `sslmode=verify-full`
## Verificación
1. `docker compose -f luminarrh/docker-compose.yaml up -d`
2. Verificar SSL activo: `docker exec -i postgres-sarh psql -U sarh2 -d sarh_db -c "SHOW ssl;"`
3. Conectar con SSL: `psql "host=IP port=5434 dbname=sarh_db user=sarh2 sslmode=require"`
4. Conectar sin SSL (desde app interna): `psql "host=IP port=5434 dbname=sarh_db user=sarh2"`
@@ -0,0 +1,22 @@
# Aumentar ventana de contexto Qwen3.6-35B
## Contexto
El modelo Qwen3.6-35B está corriendo con una ventana de contexto de 262144 tokens (~256K). El usuario necesita el doble para manejar prompts más largos.
## Cambio
**Archivo:** [docker-compose.yaml](models/qwen3-6/docker-compose.yaml) — línea 34
Cambiar:
```
--max-model-len 262144
```
Por:
```
--max-model-len 524288
```
## Verificación
1. Hacer el cambio en el archivo.
2. Reiniciar el contenedor: `docker compose -f models/qwen3-6/docker-compose.yaml up -d --force-recreate`
3. Verificar que el contenedor inicia correctamente y que el healthcheck pasa.
@@ -0,0 +1,67 @@
# Agregar API OpenAI-compatible (OpenWebUI) a GitHub Copilot Chat en VS Code
## Contexto
El usuario tiene su propia API compatible con OpenAI servida por una instancia de OpenWebUI en `https://ai.p-lao.com/api`, y quiere saber si puede usar esos modelos dentro de GitHub Copilot Chat en VS Code (en vez de, o además de, los modelos que Copilot ofrece por defecto).
No se trata de una tarea de código sobre este repositorio (que ni siquiera es un repo git) — es una pregunta de configuración de la extensión GitHub Copilot Chat / VS Code. Investigué el estado actual (julio 2026) de la función BYOK ("Bring Your Own Key") de VS Code, que sí soporta endpoints OpenAI-compatibles self-hosted desde octubre 2025 (Insiders) y ya en stable desde inicios de 2026.
## Respuesta corta
**Sí, es posible.** VS Code Copilot Chat tiene soporte nativo (sin extensiones de terceros) para agregar un proveedor de modelo "Custom Endpoint / OpenAI Compatible", apuntando a cualquier servidor que exponga una API de chat completions compatible con OpenAI — que es exactamente lo que expone OpenWebUI en `/api` (OpenWebUI expone un endpoint compatible en `/api/chat/completions` o `/v1/chat/completions` según versión).
## Cómo hacerlo (método recomendado — UI nativa)
1. Abrir la paleta de comandos (`Ctrl+Shift+P`) y ejecutar **`Chat: Manage Language Models`**.
2. Elegir **"Add Model..."** → seleccionar el proveedor **"OpenAI Compatible"** (a veces listado como "Custom Endpoint").
3. Completar:
- **Base URL**: `https://ai.p-lao.com/api` (o la ruta específica que exponga chat completions, p. ej. `https://ai.p-lao.com/api/chat/completions` — confirmar cuál espera el prompt, algunas versiones piden solo el host base y arman el path solas).
- **API Key**: el token/API key generado en OpenWebUI (Settings → Account → API Keys).
- **Model ID**: el nombre exacto del modelo tal como lo expone OpenWebUI (se puede confirmar con `GET /api/models` contra tu instancia).
4. VS Code guarda la API key de forma segura (Secret Storage), no en `settings.json` en texto plano.
5. El modelo aparece en el selector de modelos del chat de Copilot (ícono en la vista de Chat) — seleccionarlo ahí para usarlo en conversaciones/agent mode.
### Alternativa vía `settings.json` (legacy, sigue funcionando en stable pero marcada deprecated)
Permite declarar metadata del modelo sin pasar por el diálogo cada vez, pero la API key igual se configura aparte (vía el comando de arriba o un prompt de VS Code):
```json
"github.copilot.chat.customOAIModels": {
"mi-modelo-openwebui": {
"name": "Mi modelo (OpenWebUI)",
"url": "https://ai.p-lao.com/api/chat/completions",
"toolCalling": true,
"vision": false,
"maxInputTokens": 128000,
"maxOutputTokens": 8000
}
}
```
Ajustar `toolCalling`/`vision`/límites de tokens según lo que realmente soporte el modelo servido — si se declara `toolCalling: true` sin que el modelo lo soporte, el agent mode de Copilot fallará al intentar usar herramientas.
## Requisitos del lado de OpenWebUI
- El endpoint debe responder al formato de Chat Completions de OpenAI (`POST /chat/completions` con streaming SSE) — OpenWebUI lo soporta out-of-the-box vía su capa de compatibilidad OpenAI.
- Necesita un API key válido generado desde OpenWebUI (no la sesión de usuario web).
- Si el servidor usa TLS con certificado propio/self-signed, puede haber que confiar en el certificado a nivel de sistema/VS Code para que la conexión no falle.
## Notas / limitaciones
- Esto solo afecta **Chat y Agent mode** de Copilot. Las sugerencias inline de autocompletado ("ghost text") siguen usando la infraestructura propia de Copilot y no se pueden reemplazar por BYOK.
- El uso de un modelo BYOK se factura/consume directamente contra tu proveedor (OpenWebUI/modelo detrás), no cuenta contra la cuota de requests de Copilot.
- Requiere una versión reciente de VS Code (la función pasó a stable en 2026); si el usuario está en una versión vieja, puede necesitar actualizar o usar VS Code Insiders.
## Verificación
1. Tras agregar el modelo, abrir el panel de Chat, hacer clic en el selector de modelo y confirmar que "Mi modelo (OpenWebUI)" aparece en la lista.
2. Enviar un mensaje simple ("hola, ¿qué modelo eres?") y confirmar que responde usando el modelo remoto (no un modelo de Copilot por defecto).
3. Si se necesita agent mode con tools, probar un prompt que dispare una tool call y confirmar que el modelo la ejecuta correctamente (valida que `toolCalling` esté bien configurado).
## Fuentes
- https://code.visualstudio.com/blogs/2026/06/18/byok-vscode
- https://code.visualstudio.com/blogs/2025/10/22/bring-your-own-key
- https://github.blog/changelog/2026-04-22-bring-your-own-language-model-key-in-vs-code-now-available/
- https://visualstudiomagazine.com/articles/2026/05/29/vs-code-1-122-lets-byok-work-without-github-sign-in.aspx
- https://ofox.ai/blog/github-copilot-byok-oai-compatible-api-setup/
@@ -0,0 +1,108 @@
# GFIBER-673 — Propuesta de tutorial in-app con reactour
## Contexto
GFIBER-673 es un spike: "¿cómo se vería un tutorial paso a paso al primer login o tras un release mayor, para Admins y para Users?" No existe hoy ningún componente de tour/onboarding en el repo (confirmado por grep exhaustivo). El usuario propuso evaluar [reactour](https://docs.reactour.dev/) como librería base. Esta propuesta cubre: (1) el veredicto de viabilidad técnica, (2) el contenido concreto del tour para cada rol, anclado en pantallas reales del repo, y (3) el enfoque de implementación e integración con las convenciones ya establecidas del proyecto.
## Veredicto de viabilidad: apto, sin bloqueantes
| Chequeo | Requisito de reactour | Este repo | Resultado |
|---|---|---|---|
| React | `16.x \| 17.x \| 18.x \| 19.x` (peer dep) | React 19.2.4 (pinneado) | Compatible |
| Next.js / router | Necesita boundary `'use client'` (hooks + DOM) | Next.js 16.2.7, App Router, 57 archivos ya usan `'use client'` | Patrón ya establecido |
| TypeScript | Reescrito en TS nativo | TS 5 strict | Compatible |
| Estilos | Popover/mask vía portal, estilable por props inline | Tailwind v4 (`app/`) + SCSS Modules (`packages/design-system`), sin Radix/MUI/Headless UI | Sin conflicto de dependencias |
| Persistencia "ya lo vio" | Fuera del alcance de la librería | Convención ya usada: columna boolean en `profiles` vía migración (`is_enabled`, `notify_pending_attendance`) | Patrón reusable directo |
| Paquete | `@reactour/tour` v3.8.0, MIT, activo (4.1k★) | — | Apto |
**Dos matices para la implementación** (no bloquean el spike, pero condicionan el enfoque):
1. reactour apunta pasos por selector CSS — usar atributos dedicados `data-tour="..."` en los elementos objetivo, nunca clases de Tailwind (frágil ante cambios de diseño).
2. El theming de reactour es vía objetos de estilo inline, no clases Tailwind — se puede lograr consistencia visual referenciando directamente las custom properties `--fiber-*` ya definidas en `app/globals.css` (ej. `backgroundColor: 'var(--fiber-primary)'`), sin depender de Tailwind dentro del portal de reactour.
**Fuera de alcance de este spike**: Triple T no existe en este repositorio (confirmado — solo aparece mencionado como "fuera de alcance" en `docs/plans/*`). El AC del ticket pide tours por **rol** (Admin / User), no por producto, así que esto no bloquea la propuesta — se deja anotado como gap a resolver el día que Triple T viva en este monorepo o se integre.
## Hallazgo clave que moldea el diseño
Todos los roles — Admin, Owner y User — aterrizan en la **misma página tras login**: `/upsell-evaluator` (confirmado: `app/page.tsx` redirige a `/login`, y el wordmark del NavBar en `components/NavBar.tsx:38` linkea `/upsell-evaluator` como "GFiber Pilot home" para todos). No existe un "home de admin" separado. Esto significa que un tour "por rol" en el sentido de páginas de entrada distintas no aplica — en cambio, el diseño correcto es:
- **Un tour base común** (Upsell Evaluator) que ven Admin y User por igual, la primera vez que llegan a `/upsell-evaluator`.
- **Un tour adicional, solo-Admin/Owner**, que se dispara la primera vez que visitan `/admin` o `/dashboard` — páginas que ni siquiera son visibles en el NavBar para un User raso (`canAccessAdmin` en `components/NavBar.tsx`).
Esto respeta el AC del ticket ("proponer tutorial para Admins" + "proponer tutorial para Users") sin inventar una página de entrada que no existe.
## Propuesta — Tour de Users (y base para Admins)
Disparado la primera vez que cualquier usuario autenticado visita `/upsell-evaluator` (`app/(protected)/upsell-evaluator/page.tsx`).
| # | Elemento objetivo (`data-tour`) | Componente real | Contenido del paso |
|---|---|---|---|
| 1 | `nav-wordmark` | `components/NavBar.tsx` | "Este es tu punto de partida — GFiber Pilot home." |
| 2 | `stage-transition-gaid` | `_flow/StageTransition.tsx` | "Ingresa el GAID y Case ID del cliente, luego presiona Check para validar elegibilidad." |
| 3 | `stage-transition-hardstops` | Chips de riesgo (Financial Sensitivity, Technical Instability, Negative Sentiment) | "Marca estos chips si detectas alguna señal de riesgo antes de continuar." |
| 4 | `objection-drawer-trigger` | `ObjectionDrawer.tsx` | "En cualquier momento de la llamada, abre este panel para scripts de manejo de objeciones." |
| 5 | `copilot-widget-launcher` | `components/upsell/CopilotWidget.tsx` | "¿Duda sobre qué responder? Pega el chat del cliente aquí y el asistente sugiere una respuesta." |
| 6 | `stage-discovery-quickcapture` | `_flow/StageDiscovery.tsx` | "Usa estos chips rápidos (Gaming, WFH, Cámaras...) o profundiza en el acordeón de abajo." |
| 7 | `stage-value-comparison` | `_flow/StageValue.tsx` | "Aquí comparas el plan actual del cliente contra el upgrade recomendado, con costo diario." |
| 8 | `stage-close-outcome` | `_flow/StageClose.tsx` | "Registra el resultado (Aceptado/Declinado); si aceptó, la nota para Salesforce se genera sola." |
| 9 | `navbar-my-metrics` | Link "My Metrics" en `components/NavBar.tsx` | "Aquí revisas tu historial de sesiones y métricas personales." |
**Persistencia**: nueva columna `has_seen_upsell_tour boolean not null default false` en `profiles`, siguiendo el patrón exacto de `supabase/migrations/20260625000000_add_notify_pending_attendance_to_profiles.sql`. Se marca `true` vía Server Action al completar o saltar el tour.
## Propuesta — Tour adicional de Admins/Owners
Disparado la primera vez que un usuario con `role IN ('admin', 'owner')` visita `/admin` (y, por separado, la primera vez que visita `/dashboard`) — ambos gateados hoy por `app/(protected)/admin/layout.tsx` y `app/(protected)/dashboard/layout.tsx`.
**Parte A — `/admin` (User Management)**
| # | Elemento objetivo | Componente real | Contenido del paso |
|---|---|---|---|
| 1 | `admin-invite-form` | `components/admin/InviteUserForm.tsx` | "Invita usuarios nuevos por email, asignando su rol desde aquí." |
| 2 | `admin-user-table` | `components/admin/UserTable.tsx` | "Esta tabla lista a todos los usuarios; usa '⋯ More' para gestionar cada uno." |
| 3 | `admin-manage-drawer` | `components/admin/UserManageDrawer.tsx` | "Desde aquí cambias rol, equipo, activas/desactivas o reenvías la invitación." |
**Parte B — `/dashboard` (Sales Dashboard)**
| # | Elemento objetivo | Componente real | Contenido del paso |
|---|---|---|---|
| 1 | `dashboard-date-range` | `DateRangeControl` | "Filtra todas las métricas por rango de fechas." |
| 2 | `dashboard-team-toggle` | `TeamToggle` | "Alterna entre ver solo tu equipo o todos los equipos." |
| 3 | `dashboard-leaderboard` | `Leaderboard` / `AllTeamsCard` | "Aquí comparas el desempeño de agentes y equipos." |
| 4 | `dashboard-export` | Botón "Export report" (`app/(protected)/dashboard/api/export/route.ts`) | "Descarga el reporte completo en un clic." |
**Persistencia**: columna `has_seen_admin_tour boolean not null default false` en `profiles` — mismo patrón de migración, solo relevante/seteable para `admin`/`owner`.
## Controles de usuario: Skip y reinicio manual
Ambos tours (Users y Admin/Owner) deben poder **saltarse** y **reiniciarse a demanda** — no solo dispararse una vez de forma automática:
- **Skip**: cada paso del popover incluye un botón "Saltar tutorial" (override del componente `Close`/footer de reactour, vía la prop `components` de `TourProvider`), visible desde el primer paso — no solo al final. Al presionarlo, `setIsOpen(false)` cierra el tour **y** dispara la misma Server Action que marca `has_seen_upsell_tour` / `has_seen_admin_tour` en `true`, para que no se vuelva a mostrar automáticamente en el siguiente login.
- **Reinicio manual**: un botón persistente para volver a lanzar el tour cuando el usuario quiera, sin depender del flag de "ya lo vio":
- **Tour de Users** → ícono " Ver tutorial" junto al wordmark en `components/NavBar.tsx`, visible para todos los roles. Llama `setIsOpen(true)` reseteando `currentStep` a 0, sin tocar el flag en `profiles` (reiniciar no debe tener efectos secundarios de persistencia).
- **Tour de Admin** → mismo patrón de botón " Ver tutorial", ubicado en el header de `app/(protected)/admin/page.tsx` y de `app/(protected)/dashboard/page.tsx` respectivamente (cada página relanza solo su propia mitad del tour — Parte A o Parte B).
Esto significa que el `TourProvider` necesita exponer el hook `useTour` no solo para auto-arranque en el layout, sino también consumible desde estos botones — encaja de forma natural con la API de reactour (`useTour()` es válido en cualquier client component descendiente del `TourProvider`).
## Enfoque técnico de implementación (para cuando se apruebe pasar de spike a build)
1. Instalar `@reactour/tour`.
2. Nuevo client component `components/ProductTour.tsx` (`'use client'`), envolviendo el contenido de `app/(protected)/layout.tsx` con `<TourProvider>`. Recibe `profile.role`, `profile.has_seen_upsell_tour`, `profile.has_seen_admin_tour` como props desde el server component padre (ya se resuelve ahí vía `getCurrentUserWithProfile()`).
3. Dos arrays de `steps` (`upsellTourSteps`, `adminTourSteps`) definidos por selector `data-tour`, no por clase Tailwind.
4. Botón " Ver tutorial" reutilizable (`components/TourRestartButton.tsx`) que consume `useTour()` — instanciado en `NavBar.tsx` (tour de Users) y en los headers de `/admin` y `/dashboard` (tour de Admin).
5. Override del footer/`Close` de reactour vía la prop `components` de `TourProvider`, agregando la acción "Saltar tutorial" en todos los pasos.
6. Dos migraciones nuevas en `supabase/migrations/`, siguiendo el formato `YYYYMMDDHHMMSS_add_<column>_to_profiles.sql`.
7. Server Action para marcar cada flag `true` al completar **o saltar** el tour respectivo (patrón ya usado para `notify_pending_attendance`); el botón de reinicio manual NO llama a esta acción.
8. Theming del popover/mask vía las custom properties `--fiber-*` existentes en `app/globals.css`, para que visualmente no desentone con el resto de la UI.
9. Spike técnico corto (≈30 min) antes de comprometerse: confirmar si los componentes internos de reactour (`Wrapper`, `Badge`, `Close`) aceptan override completo o solo estilos inline — determina cuánto se puede "tailwindizar" el look del tour, incluyendo el botón de Skip.
## Verificación
- Levantar el repo local, loguear como `user` → confirmar que el tour de Upsell Evaluator se dispara una sola vez (flag persiste tras reload).
- Loguear como `admin`/`owner` → confirmar que ven el tour base de Upsell Evaluator y, al visitar `/admin` y `/dashboard` por primera vez, el tour adicional.
- Confirmar que un `user` sin acceso a `/admin` nunca ve ese tour (ni el flag se crea/lee para él de forma que rompa nada).
- Revisar visualmente que el popover de reactour respeta paleta y modo oscuro (`--fiber-*`, variante `.dark`).
- Presionar "Saltar tutorial" en el paso 1 → confirmar que el tour se cierra y el flag correspondiente queda en `true` (no vuelve a auto-abrirse en el siguiente login).
- Con el flag ya en `true`, presionar el botón " Ver tutorial" en NavBar/`/admin`/`/dashboard` → confirmar que el tour se relanza desde el paso 1 sin alterar el flag.
## Entrega
Publicar esta propuesta como página en el space de Docmost del proyecto (`gfiber-pilot-extension`), como respuesta al spike GFIBER-673. Se enlaza el ticket de Jira en la página.
@@ -0,0 +1,128 @@
# Plan: Migrar bordes de inline styles a SCSS
## Contexto
El CI falla porque el componente `Button` usa inline styles (`style={{ borderWidth, borderStyle, borderColor }}`) para los bordes. Los tests (Jest y Cypress) esperan clases CSS como `border-gold` y `border-black` que ya no se generan. El usuario quiere que los estilos se escriban con SCSS en lugar de inline styles.
## Cambios necesarios
### 1. Crear `src/components/Button/Button.scss`
Crear archivo SCSS con clases por variante para bordes. Cada variante que tenga borde (todos excepto `primary`) recibe:
- `border-width: 3px`
- `border-style: solid`
- `border-color` con el color correspondiente
```scss
$border-width: 3px;
$gold: #EABF2D;
$amber: #D4880F;
$black: #1A1A2E;
$white: #FFFFFF;
$danger: #DE3626;
.btn {
// variantes con borde
&.w-btnOutlineGold,
&.w-btnOutline,
&.w-btnOutlineLight,
&.w-btnCircle,
&.w-btnIcon,
&.file-upload {
border-width: $border-width;
border-style: solid;
}
&.w-btnOutlineGold {
border-color: $gold;
}
&.w-btnOutline {
border-color: $black;
}
&.w-btnOutlineLight {
border-color: $white;
}
&.w-btnCircle {
border-color: $black;
}
&.w-btnIcon {
// icon-edit usa gold, icon-delete usa danger — se maneja con clases adicionales
}
&.file-upload {
border-color: $gold;
}
// icon-edit específico
&.w-btnIcon--edit {
border-color: $gold;
}
// icon-delete específico
&.w-btnIcon--delete {
border-color: $danger;
}
}
```
**Reflexión:** El componente actual no tiene clases separadas para icon-edit vs icon-delete en el border-color. Ambas comparten `w-btnIcon` pero tienen bordes distintos (gold vs danger). Necesito revisar si se puede resolver con clases adicionales o con un approach diferente.
Mirando el código actual:
- `icon-edit``border-color: var(--color-gold, #EABF2D)` (inline style)
- `icon-delete``border-color: var(--color-danger, #DE3626)` (inline style)
Opción A: Agregar clases `border-gold` y `border-danger` al button y manejar el color con CSS custom properties o clases específicas.
Opción B: Usar `data-variant` attribute y selector `[data-variant="icon-edit"]`.
Opción C: Agregar clases específicas `btn--icon-edit` y `btn--icon-delete`.
La opción C es la más limpia y consistente con el patr Tailwind del proyecto.
### 2. Actualizar `src/components/Button/index.tsx`
- **Eliminar** `BORDER_WIDTH`, `variantBorders`, `variantBorderColors`, `getButtonStyle()`
- **Eliminar** `style={getButtonStyle(variant)}` de los elementos renderizados
- **Agregar** clases CSS específicas para bordes:
- `outline-gold``border-gold`
- `outline-dark``border-black`
- `outline-light``border-white`
- `ghost``border-black`
- `circular``border-black`
- `icon-edit``border-gold`
- `icon-delete``border-danger`
- `file-upload``border-gold`
- **Importar** el SCSS al inicio del archivo: `import './Button.scss';`
### 3. Actualizar tests
**`Button.test.tsx`** (Jest):
- Línea 58: `expect(outlineGoldBtn).toHaveClass('border-gold')` → ya funciona con la nueva clase
- Línea 62: `expect(outlineDarkBtn).toHaveClass('border-black')` → ya funciona con la nueva clase
**`Button.test.cy.tsx`** (Cypress):
- Línea 13: `cy.get('button').should('have.class', 'border-gold')` → ya funciona
- Línea 19: `cy.get('button').should('have.class', 'border-black')` → ya funciona
Los tests de Cypress también verifican `border-top-width` con `have.css` — eso seguirá funcionando porque el border-width se define en SCSS.
### 4. Actualizar Storybook
- `Button.stories.tsx`: Agregar `border-gold` y `border-black` como opciones de control si aplica
## Archivos a modificar
| Archivo | Acción |
|---------|--------|
| `src/components/Button/Button.scss` | **Crear** — clases SCSS para bordes |
| `src/components/Button/index.tsx` | **Editar** — eliminar inline styles, agregar clases CSS, importar SCSS |
| `src/components/Button/Button.stories.tsx` | **Editar** — actualizar si es necesario |
## Verificación
1. `npm test` — todos los tests deben pasar (incluyendo las assertions de `border-gold` y `border-black`)
2. `npm run build-storybook` — Storybook debe compilar sin errores
3. `npm run build` — el bundle debe generar correctamente
4. CI: Push a la rama y verificar que Gitea Actions pase en ambos jobs
@@ -0,0 +1,122 @@
## Context
El usuario corre estas skills con varios modelos distintos (no solo Claude), en particular **qwen3.6**, tanto
como sesión madre (orquestador) como potencialmente como el modelo que corre dentro del agente-hijo en tmux.
Un modelo más débil no infiere tan bien juicio implícito, prosa ambigua, o instrucciones que compiten entre
sí (dos caminos presentados como válidos cuando solo uno lo es) — eso produce pasos saltados u olvidados.
Ya revisé el ecosistema completo: `agent-orchestrator/SKILL.md` (incluyendo el bloque `TASK.md` de FASE 3
que es lo que literalmente lee el agente-hijo), los 7 subagentes en `~/.claude/agents/*.md`
(`developer`, `code-reviewer`, `qa-validator`, `pr-shipper`, `ci-developer`, `docmost-reporter`, `mailer`),
y las otras 3 skills relacionadas (`aleleba-pr`, `docmost-context`, `web-ui-test`).
Decisión ya tomada con el usuario: **alcance = todo el ecosistema**, **estilo = reforzar sin reestructurar**
(mantener FASE 1-4 y el formato `TASK.md` tal cual, solo hacer explícito lo implícito y arreglar
inconsistencias, no reescribir la arquitectura de la skill).
## Hallazgos concretos (por qué cada cambio)
1. **Bug real de numeración** (`agent-orchestrator/SKILL.md`, bloque `TASK.md`, "PASOS OBLIGATORIOS ANTES DE
TERMINAR", paso 2): dice *"Si tu tarea es puramente backend/CLI/librería, omite este paso y dilo
explícitamente en el paso 4."* — pero tras insertar `ci-developer` como nuevo paso 4, el paso que
documenta motivos de omisión (`docmost-reporter`) pasó a ser el **5**, no el 4. Una referencia cruzada
desactualizada es exactamente el tipo de cosa que hace que un modelo más débil "confirme" en el paso
equivocado o se confunda. **Debe decir "paso 5".**
2. **Dos caminos presentados como válidos, cuando solo uno funciona de forma confiable** (FASE 1, paso 6(b),
creación de `settings.local.json`): el bloque describe primero un "PROCESO OBLIGATORIO DE 3 PASOS" (editar
`~/.claude/settings.json`, crear el archivo, revertir) y **después** dice que hay un "ATAJO CONFIABLE" que
lo reemplaza porque el proceso de 3 pasos puede quedar bloqueado por el clasificador. Presentado en ese
orden, un modelo que sigue instrucciones literalmente y de arriba hacia abajo intentará el proceso de 3
pasos primero (marcado "OBLIGATORIO") en vez de ir directo al atajo. Hay que invertir la prioridad: dejar
el heredoc por Bash como **la única instrucción accionable**, y mover la descripción del proceso de 3
pasos a TROUBLESHOOTING como contexto histórico de por qué no se usa.
3. **Juicio implícito sin regla concreta** en varios puntos:
- TRIGGER: "o variante muy cercana" no define qué hacer ante duda — un modelo débil puede sobre-activar
o no activar la skill de forma inconsistente.
- FASE 1 paso 5(a): "usar el más adecuado o crear la estructura" al no encontrar space de Docmost — no
dice cómo decidir "más adecuado".
- `TASK.md` → "PERMISOS Y AUTONOMÍA": "SOLO pausa y notifica si hay un bloqueo real" sin ejemplos
concretos de qué SÍ y qué NO cuenta como bloqueo — un modelo débil puede pausar por cualquier cosa
(spam de correos) o nunca pausar (avanza a ciegas sobre ambigüedad real).
- `developer.md`, punto 5: "toma la decisión más razonable" sin un ejemplo que calibre qué es
"razonable" vs. una ambigüedad real que sí debería bloquear.
- `pr-shipper.md`, punto 3: la condición de detenerse ("si detectas que sigues en una rama protegida")
depende de que el modelo la reconozca por prosa, sin un comando explícito de verificación.
4. **Archivos que ya están bien** (se revisaron, no necesitan cambios): `ci-developer.md` (ya nació con
pasos numerados, formato de reporte obligatorio y criterios concretos), `code-reviewer.md`,
`qa-validator.md`, `docmost-reporter.md`, `mailer.md` (todos con reporte final obligatorio y pasos
numerados, sin juicio implícito relevante), `aleleba-pr/SKILL.md` (ya con pasos numerados e if/else
explícitos), `docmost-context/SKILL.md` y `web-ui-test/SKILL.md` (ya son algorítmicos/muy explícitos:
fórmulas de score, tablas de decisión, comandos exactos). No se tocan.
## Cambios a aplicar
### `~/.claude/skills/agent-orchestrator/SKILL.md`
a) **TRIGGER**: agregar una regla explícita de desempate: *"Si dudas si la frase del usuario cuenta como
'variante muy cercana', trátalo como que NO activa — pregúntale al usuario si quiere que actives el modo
background en vez de asumirlo."*
b) **FASE 1, paso 5(a)**: reemplazar "usar el más adecuado" por una regla concreta: reusar el mismo criterio
de normalización + match por contención que ya usa `docmost-context` (minúsculas, sin espacios/guiones,
sin scope de npm), y si tras eso no hay match claro, usar `AskUserQuestion` con la lista de spaces en vez
de adivinar.
c) **FASE 1, paso 6(b)**: reordenar — el bloque `cat > "$WT_ABS/.claude/settings.local.json" << 'ENDJSON' ...
ENDJSON` pasa a ser **la única instrucción presentada** (sin "PASO 1/2/3" ni "ATAJO" como si fueran dos
alternativas). Mover el "PROCESO OBLIGATORIO DE 3 PASOS" completo (con su fecha "aprendido 2026-06-24") a
una fila nueva de la tabla TROUBLESHOOTING, como explicación de por qué NO se usa ese camino.
d) **Bloque `TASK.md` (FASE 3)**:
- Arreglar la referencia cruzada rota: "dilo explícitamente en el paso 4" → **"paso 5"**.
- Al inicio de "PASOS OBLIGATORIOS ANTES DE TERMINAR", agregar una línea explícita: *"Sigue estos 7 pasos
EN ORDEN, uno a la vez. No omitas ninguno, no los reordenes, no continúes al siguiente hasta terminar
el actual (salvo que el paso mismo te diga que lo saltes, como el paso 2 cuando no aplica)."*
- Reescribir "PERMISOS Y AUTONOMÍA" con una lista explícita de dos columnas:
**SÍ es bloqueo real** (te faltan credenciales/acceso que nadie más puede darte, instrucciones del
usuario se contradicen entre sí, la tarea pide algo que viola una regla de seguridad de este documento
—p.ej. mergear, forzar push—, o un error técnico que ya intentaste resolver 2+ veces sin éxito) vs.
**NO es bloqueo** (elegir entre dos formas válidas de implementar algo, un lint/type error que puedes
arreglar tú mismo, decidir nombres de variables/archivos, instalar una dependencia que falta). Mantener
el mecanismo ya descrito (dejar la pregunta como última salida, esperar, el hook `Notification` avisa).
- Al inicio del bloque `TASK.md` (junto a la línea `TAREA:`), agregar: *"Este archivo son tus
instrucciones completas. Léelas y ejecútalas literalmente, en el orden en que aparecen. No asumas que
un paso ya se cumplió o que no aplica salvo que el propio paso lo diga explícitamente."*
### `~/.claude/agents/developer.md`
En el punto 5 ("Si tienes una duda de diseño..."), agregar un ejemplo concreto de cada lado: *ejemplo de
decisión rutinaria que SÍ debes tomar tú solo (p.ej. nombre de una variable interna, orden de los
parámetros de una función nueva), vs. ejemplo de ambigüedad real que si la encuentras debes reportar como
bloqueo/duda en vez de decidir (p.ej. la tarea pide dos comportamientos mutuamente excluyentes, o requiere
tocar un archivo fuera de tu lista asignada)*.
### `~/.claude/agents/pr-shipper.md`
En el punto 3, antes de la condición en prosa, agregar el comando explícito de verificación:
```bash
git branch --show-current # si el resultado es main/master/dev, DETENTE — no continúes
```
para que la condición de "sigues en una rama protegida" tenga un chequeo concreto en vez de depender de
que el modelo lo infiera solo.
## Fuera de alcance
- No se reestructuran las FASES 1-4 ni el formato de `TASK.md` — solo se refuerzan pasos existentes.
- `ci-developer.md`, `code-reviewer.md`, `qa-validator.md`, `docmost-reporter.md`, `mailer.md`,
`aleleba-pr/SKILL.md`, `docmost-context/SKILL.md`, `web-ui-test/SKILL.md` — sin cambios (ya son
suficientemente explícitos).
## Verificación
- Releer `agent-orchestrator/SKILL.md` completo después de los cambios y confirmar que la numeración de
"PASOS OBLIGATORIOS ANTES DE TERMINAR" (1-7) y todas sus referencias cruzadas internas ("paso 5", "pasos
5/6", etc.) son consistentes de punta a punta.
- Confirmar que el paso 6(b) de FASE 1 ya no presenta el proceso de 3 pasos como una alternativa válida —
solo debe quedar el heredoc de Bash como instrucción, con el resto movido a TROUBLESHOOTING.
- Grep rápido de `grep -n "paso 4\|PROCESO OBLIGATORIO DE 3 PASOS" agent-orchestrator/SKILL.md` para
confirmar que la única mención remanente del proceso de 3 pasos está en la fila de TROUBLESHOOTING, no en
el flujo activo.
@@ -0,0 +1,55 @@
# Fix: `ls: unparsable value for LS_COLORS environment variable`
## Context
Al correr el contenedor, cualquier `ls` imprime `ls: unparsable value for LS_COLORS environment variable` antes de listar el contenido (funciona, pero con este warning ruidoso).
Comparando las dos capturas de terminal: el archivo que se está evaluando (`/usr/bin/.bashrc`, targeteado en las líneas 179-189 del [Dockerfile](Dockerfile#L179-L189)) contiene el bloque estándar de Debian/Ubuntu con el `export LS_COLORS="rs=0:di=01;34:...:*.xspf=00;36:"` heredado de la imagen base (`gitea.p-lao.com/aleleba/vscode:latest`), y **inmediatamente a continuación, sin salto de línea**, arranca `PATH=/usr/local/sbin:/usr/local/bin:...:/opt/android-sdk/build-tools`.
Esto es exactamente el contenido que agrega la línea 180 del Dockerfile:
```
RUN echo "PATH=$PATH:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools" | sudo tee -a /usr/bin/.bashrc
```
**Root cause:** el archivo `/usr/bin/.bashrc` que trae la imagen base no termina con salto de línea después del bloque `LS_COLORS`. Como `tee -a` solo *agrega* contenido (no garantiza una línea nueva antes), el primer `echo ... | sudo tee -a` de nuestro bloque (línea 180) queda pegado al final de esa línea sin separador. Al no haber espacio/salto entre el cierre de comillas de `LS_COLORS="..."` y `PATH=...` (adyacentes, sin espacio), bash concatena ambos en una sola asignación de variable, y el valor final de `LS_COLORS` termina incluyendo literalmente `PATH=/usr/local/sbin:...:/opt/android-sdk/build-tools`, que no es un valor válido de `dircolors` → de ahí el error de `ls`.
Confirmado por un agente Explore: nada en este repo (Dockerfile, README, CI de `.gitea/workflows/`) fija `HOME` o crea el symlink hacia `/usr/bin/.bashrc` — ese detalle vive en la imagen base privada, fuera de este repo. El fix debe vivir en nuestra propia capa, siendo defensivos ante ese archivo heredado sin salto de línea final.
De paso, la línea 180 es inconsistente con el resto del bloque (182-189): le falta la palabra `export`, mientras que todas las demás sí exportan la variable.
## Cambio
En [Dockerfile](Dockerfile#L179-L189), en el bloque "`/usr/bin/.bashrc` Configuration":
1. Agregar un `RUN echo | sudo tee -a /usr/bin/.bashrc > /dev/null` **antes** del resto del bloque, para forzar un salto de línea de separación sin importar si el archivo heredado terminaba o no con newline.
2. Corregir la línea del `PATH` para que use `export` igual que las demás (actualmente falta).
Resultado (líneas 179-189 reemplazadas):
```dockerfile
# /usr/bin/.bashrc Configuration
# The base image's /usr/bin/.bashrc doesn't end with a trailing newline after its
# LS_COLORS export, so appending directly here used to glue this block onto that
# line and corrupt LS_COLORS (ls: "unparsable value for LS_COLORS environment variable").
RUN echo | sudo tee -a /usr/bin/.bashrc > /dev/null
RUN echo "export PATH=$PATH:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools" | sudo tee -a /usr/bin/.bashrc
RUN echo "export JAVA_HOME=$JAVA_HOME" | sudo tee -a /usr/bin/.bashrc
RUN echo "export ANDROID_HOME=/opt/android-sdk" | sudo tee -a /usr/bin/.bashrc
RUN echo "export ANDROID_SDK_ROOT=/opt/android-sdk" | sudo tee -a /usr/bin/.bashrc
RUN echo 'export NVM_DIR="/usr/local/nvm"' | sudo tee -a /usr/bin/.bashrc
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' | sudo tee -a /usr/bin/.bashrc
RUN echo 'nvm use default >/dev/null 2>&1' | sudo tee -a /usr/bin/.bashrc
RUN echo "export EMSDK_QUIET=1" | sudo tee -a /usr/bin/.bashrc
RUN echo "source /emsdk/emsdk_env.sh" | sudo tee -a /usr/bin/.bashrc
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' | sudo tee -a /usr/bin/.bashrc
```
No se toca nada más del Dockerfile — el resto de las capas (Android SDK, Node, Docker, etc.) no está relacionado con este bug.
## Verificación
No se hace build local de la imagen — el pipeline de CI (`.gitea/workflows/main-workflow.yml`) se encarga de construirla al pushear el cambio.
1. Pushear el cambio y confirmar que el workflow de CI construye la imagen sin errores.
2. Levantar un contenedor de la imagen nueva y correr `cat /usr/bin/.bashrc` (o `~/.bashrc` si está symlinkeado): confirmar que el bloque `export LS_COLORS=...` termina en su propia línea y que `export PATH=...` arranca en la línea siguiente, no concatenado.
3. Correr `ls` en una terminal nueva del contenedor: no debe imprimir `unparsable value for LS_COLORS environment variable`.
4. Confirmar que `$PATH`, `$JAVA_HOME`, `$ANDROID_HOME`, etc. siguen resolviendo correctamente (`echo $PATH`, `java -version`, `sdkmanager --version`) ya que ahora `PATH` se exporta explícitamente.
@@ -0,0 +1,697 @@
# Button Component Implementation Plan
## 1. TypeScript Type Definitions
### Discriminated Union Variant Type
```typescript
type ButtonVariant =
| 'primary'
| 'outline-gold'
| 'outline-dark'
| 'outline-light'
| 'ghost'
| 'circular'
| 'icon-edit'
| 'icon-delete'
| 'file-upload';
```
### Props Interface (Discriminated Union via Generics)
```typescript
type TButtonBaseProps = {
/**
* Button variant — controls visual style and behavior.
*/
variant: ButtonVariant;
/**
* Button size: 'small' | 'medium' | 'large'.
* Maps to the design specs: primary/outline-gold=large, outline-dark/outline-light/ghost=medium,
* circular/icon-edit/icon-delete=small, file-upload=medium.
*/
size?: 'small' | 'medium' | 'large';
/**
* Whether the button is disabled.
*/
disabled?: boolean;
/**
* Click handler.
*/
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Optional HTML button type attribute.
*/
type?: 'button' | 'submit' | 'reset';
/**
* Optional className for external styling overrides.
*/
className?: string;
/**
* Optional aria-label for accessibility.
*/
'aria-label'?: string;
};
type TTextButtonProps = TButtonBaseProps & {
/**
* Button text content. Required for text-based variants.
*/
children: React.ReactNode;
};
type TIconButtonProps = TButtonBaseProps & {
/**
* Icon children only — no text. For circular, icon-edit, icon-delete variants.
*/
children: React.ReactNode;
variant: 'circular' | 'icon-edit' | 'icon-delete';
};
type TFileUploadProps = TButtonBaseProps & {
children: React.ReactNode;
variant: 'file-upload';
/**
* File accept attribute (e.g., 'image/*', '.pdf').
*/
accept?: string;
};
type TButtonProps = TTextButtonProps | TIconButtonProps | TFileUploadProps;
```
**Rationale:**
- Using a union of `TButtonProps` variants enforces at the type level that icon-only variants (`circular`, `icon-edit`, `icon-delete`) are distinct from text variants. This prevents developers from passing text to icon-only buttons.
- `size` is optional — the component maps the variant to the correct size automatically, but allows override for flexibility.
- `TTextButtonProps` is the broadest type; `TIconButtonProps` and `TFileUploadProps` narrow the `variant` field. TypeScript will correctly narrow `children` and `variant` when discriminated.
---
## 2. Component Structure (JSX)
```tsx
const Button: FC<TButtonProps> = ({
variant,
size,
disabled = false,
onClick,
type = 'button',
className = '',
'aria-label': ariaLabel,
children,
accept,
}) => {
// Auto-size mapping: variant → size
const resolvedSize = size ?? variantToSize[variant];
// Build class name: base + variant + size + state
const classes = [
'Button',
`Button--${variant}`,
`Button--${resolvedSize}`,
disabled && 'Button--disabled',
className,
]
.filter(Boolean)
.join(' ');
// File-upload variant renders as a label wrapping an input
if (variant === 'file-upload') {
return (
<label className={classes} onClick={onClick}>
<input
type="file"
accept={accept}
style={{ display: 'none' }}
onChange={(e) => onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>)}
/>
{children}
</label>
);
}
return (
<button
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
>
{children}
</button>
);
};
```
**Key Design Decisions:**
- **`<button>` for all text/icon variants** — semantic, accessible, keyboard-focusable.
- **`<label>` for `file-upload`** — clicking the label triggers the hidden `<input type="file">`. This is the standard pattern for custom file upload buttons.
- **`variantToSize` map** — a constant object mapping each variant to its default size, keeping the component DRY.
- **CSS class composition** — BEM modifier pattern: `.Button--primary`, `.Button--large`, `.Button--disabled`.
---
## 3. SCSS Architecture
### File: `src/components/Button/style.scss`
```scss
// ─── Variables (design tokens) ───────────────────────────────────────────
$color-gold: #EABF2D;
$color-amber: #D4880F;
$color-dark: #1A1A2E;
$color-white: #FFFFFF;
$color-danger: #DE3626;
$color-input-border: #DADCE0;
$color-muted: #9AA0A6;
$color-gray: #6B6B7B;
$font-size-xs: 11px;
$font-size-sm: 12px;
$font-size-md: 14px;
$font-size-lg: 16px;
$font-weight-bold: 700;
$font-weight-normal: 400;
$border-radius-sm: 4px;
$border-radius-md: 6px;
$border-radius-pill: 20px;
$border-radius-circle: 28px;
$border-width: 1.5px;
// ─── Base ────────────────────────────────────────────────────────────────
.Button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
border: none;
font-family: inherit;
font-weight: $font-weight-bold;
text-decoration: none;
transition: all 0.2s ease;
user-select: none;
white-space: nowrap;
// Disabled state
&--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
// ─── Size variants ───────────────────────────────────────────────────
&--small {
width: 36px;
height: 36px;
font-size: $font-size-sm;
border-radius: $border-radius-sm;
padding: 0;
}
&--medium {
height: 40px;
font-size: $font-size-sm;
padding: 0 16px;
}
&--large {
height: 40px;
font-size: $font-size-sm;
padding: 0 24px;
min-width: 170px;
}
// ─── Variant: primary ────────────────────────────────────────────────
&--primary {
background-color: $color-gold;
color: $color-dark;
border-radius: $border-radius-md;
font-weight: $font-weight-bold;
}
// ─── Variant: outline-gold ───────────────────────────────────────────
&--outline-gold {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
border-radius: $border-radius-md;
font-weight: $font-weight-bold;
}
// ─── Variant: outline-dark ───────────────────────────────────────────
&--outline-dark {
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
border-radius: $border-radius-sm;
&:hover {
background-color: $color-amber;
color: $color-white;
}
}
// ─── Variant: outline-light ──────────────────────────────────────────
&--outline-light {
background-color: $color-dark;
border: $border-width solid $color-white;
color: $color-white;
border-radius: $border-radius-sm;
}
// ─── Variant: ghost ──────────────────────────────────────────────────
&--ghost {
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
border-radius: $border-radius-pill;
&:hover {
background-color: $color-amber;
color: $color-white;
}
}
// ─── Variant: circular ───────────────────────────────────────────────
&--circular {
width: 56px;
height: 56px;
border-radius: $border-radius-circle;
background-color: $color-white;
border: $border-width solid $color-dark;
color: $color-dark;
font-size: 20px;
padding: 0;
}
// ─── Variant: icon-edit ──────────────────────────────────────────────
&--icon-edit {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
font-size: 16px;
&:hover {
background-color: $color-gold;
color: $color-white;
}
}
// ─── Variant: icon-delete ────────────────────────────────────────────
&--icon-delete {
background-color: $color-white;
border: $border-width solid $color-danger;
color: $color-danger;
font-size: 14px;
&:hover {
background-color: $color-danger;
color: $color-white;
}
}
// ─── Variant: file-upload ────────────────────────────────────────────
&--file-upload {
background-color: $color-white;
border: $border-width solid $color-gold;
color: $color-amber;
border-radius: $border-radius-sm;
font-weight: $font-weight-normal; // file-upload is NOT bold
&:hover {
background-color: $color-gold;
color: $color-white;
}
}
}
```
**SCSS Design Decisions:**
- **Variables at top** — all colors, sizes, and radii are tokenized for easy maintenance. If the design system changes a gold shade, it's one variable.
- **BEM modifier pattern** — `.Button--primary`, `.Button--circular`, etc. Each variant is a separate BEM modifier block.
- **Hover states** — `outline-dark`, `ghost`, `icon-edit`, `icon-delete`, and `file-upload` all have hover states defined in the design. These are handled with `&:hover` selectors.
- **`file-upload` is the only non-bold variant** — explicitly set `font-weight: $font-weight-normal`.
- **Size overrides** — `circular` explicitly sets `width: 56px; height: 56px` to override the medium size defaults. `icon-edit` and `icon-delete` use the small size defaults.
- **`padding: 0` for icon variants** — ensures icon-only buttons don't add unwanted horizontal padding.
---
## 4. Storybook Stories Structure
### File: `src/components/Button/Button.stories.tsx`
```tsx
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { Button } from '@components';
const meta: Meta<typeof Button> = {
title: 'BL Consultores/Button',
component: Button,
parameters: {
docs: {
description: {
component: 'A versatile button component with 9 variants for all BL Consultores UI contexts.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: [
'primary',
'outline-gold',
'outline-dark',
'outline-light',
'ghost',
'circular',
'icon-edit',
'icon-delete',
'file-upload',
],
description: 'Visual variant of the button',
},
size: {
control: 'select',
options: ['small', 'medium', 'large', undefined],
description: 'Button size (auto-detected from variant if not specified)',
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled',
},
type: {
control: 'select',
options: ['button', 'submit', 'reset'],
description: 'HTML button type attribute',
},
children: {
control: 'text',
description: 'Button content (text or icon)',
},
'aria-label': {
control: 'text',
description: 'Accessibility label for icon-only buttons',
},
accept: {
control: 'text',
description: 'File accept attribute (file-upload variant only)',
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// ─── Text Button Variants ────────────────────────────────────────────────
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const OutlineGold: Story = {
args: {
variant: 'outline-gold',
children: 'Outline Gold',
},
};
export const OutlineDark: Story = {
args: {
variant: 'outline-dark',
children: 'Outline Dark',
},
};
export const OutlineLight: Story = {
args: {
variant: 'outline-light',
children: 'Outline Light',
},
};
export const Ghost: Story = {
args: {
variant: 'ghost',
children: 'Ghost Button',
},
};
export const FileUpload: Story = {
args: {
variant: 'file-upload',
children: 'Upload File',
accept: 'image/*,.pdf',
},
};
// ─── Icon-Only Variants ──────────────────────────────────────────────────
export const Circular: Story = {
args: {
variant: 'circular',
children: '✕',
'aria-label': 'Close',
},
};
export const IconEdit: Story = {
args: {
variant: 'icon-edit',
children: '✎',
'aria-label': 'Edit',
},
};
export const IconDelete: Story = {
args: {
variant: 'icon-delete',
children: '✕',
'aria-label': 'Delete',
},
};
// ─── States ──────────────────────────────────────────────────────────────
export const PrimaryDisabled: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
disabled: true,
},
};
export const OutlineDarkHover: Story = {
args: {
variant: 'outline-dark',
children: 'Hover Me',
},
play: async ({ canvasElement }) => {
const button = canvasElement.querySelector('button');
if (button) {
button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
}
},
};
// ─── All Variants Overview ───────────────────────────────────────────────
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', padding: '24px' }}>
<Button variant="primary" children="Primary" />
<Button variant="outline-gold" children="Outline Gold" />
<Button variant="outline-dark" children="Outline Dark" />
<Button variant="outline-light" children="Outline Light" />
<Button variant="ghost" children="Ghost" />
<Button variant="circular" children="✕" aria-label="Close" />
<Button variant="icon-edit" children="✎" aria-label="Edit" />
<Button variant="icon-delete" children="✕" aria-label="Delete" />
<Button variant="file-upload" children="Upload" accept="image/*" />
</div>
),
};
```
**Storybook Design Decisions:**
- **Separate stories per variant** — each variant gets its own named story for easy navigation in the Storybook sidebar.
- **`AllVariants` overview story** — shows all variants together for quick visual comparison (common DS pattern).
- **`OutlineDarkHover` with `play`** — demonstrates the hover state via Storybook's Play function.
- **`aria-label` documented** — icon-only variants require it; the `argTypes` documents this.
---
## 5. Test Structure
### Jest Test: `src/components/__tests__/Button.test.tsx`
```tsx
import { render, screen } from '@testing-library/react';
import { Button } from '@components';
describe('<Button /> Component', () => {
it('renders text content', () => {
render(<Button variant="primary" children="Click me" />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('applies the correct variant class', () => {
render(<Button variant="primary" children="Click me" />);
const button = screen.getByRole('button');
expect(button).toHaveClass('Button--primary');
});
it('renders as a button element', () => {
render(<Button variant="primary" children="Click me" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
render(<Button variant="primary" children="Click me" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies disabled class when disabled', () => {
render(<Button variant="primary" children="Click me" disabled />);
expect(screen.getByRole('button')).toHaveClass('Button--disabled');
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button variant="primary" children="Click me" onClick={handleClick} />);
screen.getByText('Click me').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders file-upload as a label with hidden input', () => {
render(<Button variant="file-upload" children="Upload" accept="image/*" />);
expect(screen.getByRole('label')).toBeInTheDocument();
expect(screen.getByLabelText('')).toHaveAttribute('accept', 'image/*');
});
it('renders icon variant with aria-label', () => {
render(<Button variant="circular" children="✕" aria-label="Close" />);
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('accepts custom className', () => {
render(<Button variant="primary" children="Click me" className="custom-class" />);
expect(screen.getByText('Click me')).toHaveClass('custom-class');
});
it('renders with correct type attribute', () => {
render(<Button variant="primary" children="Submit" type="submit" />);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
});
```
### Cypress Test: `src/components/__tests__/Button.test.cy.tsx`
```tsx
import { Button } from '@components';
describe('Testing Button Component', () => {
it('renders primary button with text', () => {
cy.mount(<Button variant="primary" children="Primary Button" />);
cy.get('button').should('have.class', 'Button--primary');
cy.get('button').contains('Primary Button');
});
it('renders all variants', () => {
const variants: Array<{ variant: string; label: string }> = [
{ variant: 'primary', label: 'Primary' },
{ variant: 'outline-gold', label: 'Outline Gold' },
{ variant: 'outline-dark', label: 'Outline Dark' },
{ variant: 'outline-light', label: 'Outline Light' },
{ variant: 'ghost', label: 'Ghost' },
{ variant: 'circular', label: 'Circular' },
{ variant: 'icon-edit', label: 'Icon Edit' },
{ variant: 'icon-delete', label: 'Icon Delete' },
{ variant: 'file-upload', label: 'File Upload' },
];
variants.forEach(({ variant, label }) => {
cy.mount(<Button variant={variant} children={label} />);
cy.get('.Button').should('have.class', `Button--${variant}`);
});
});
it('handles hover states for outline-dark', () => {
cy.mount(<Button variant="outline-dark" children="Hover Me" />);
cy.get('button').trigger('mouseover');
cy.get('button').should('have.css', 'background-color');
});
it('disables button when disabled prop is true', () => {
cy.mount(<Button variant="primary" children="Disabled" disabled />);
cy.get('button').should('be.disabled');
});
it('renders file-upload as a label', () => {
cy.mount(<Button variant="file-upload" children="Upload" accept="image/*" />);
cy.get('label').should('exist');
cy.get('input[type="file"]').should('exist');
});
});
```
**Test Design Decisions:**
- **Jest tests** — focus on React rendering behavior: DOM structure, props → class mapping, event handling, disabled state.
- **Cypress tests** — focus on visual/interaction behavior: CSS class presence, hover states, DOM structure for file-upload.
- **`file-upload` as `<label>`** — tested separately since it doesn't render a `<button>` element.
- **All 9 variants covered** — both Jest and Cypress verify each variant renders with its correct CSS class.
---
## 6. Export Update
### File: `src/components/index.tsx` (updated)
```tsx
export * from './Card';
export * from './Button';
```
### File: `src/components/Button/index.tsx` (exports)
```tsx
export { Button };
export type { TButtonProps, ButtonVariant };
```
---
## 7. Implementation Checklist
- [ ] Create `src/components/Button/index.tsx` with type definitions + component
- [ ] Create `src/components/Button/style.scss` with all variant styles
- [ ] Create `src/components/Button/Button.stories.tsx` with all stories
- [ ] Create `src/components/__tests__/Button.test.tsx` (Jest)
- [ ] Create `src/components/__tests__/Button.test.cy.tsx` (Cypress)
- [ ] Update `src/components/index.tsx` to export Button
- [ ] Run `npm test` — all tests pass
- [ ] Run `npm run storybook` — all stories render correctly
- [ ] Run `npm run cy:run` — all Cypress tests pass
---
## 8. Design Decisions Summary
| Decision | Rationale |
|----------|-----------|
| Single component, 9 variants | Reduces maintenance; variants are visual differences, not behavioral ones |
| Discriminated union types | TypeScript enforces which props are valid per variant at compile time |
| BEM CSS with variant modifiers | Matches existing Card pattern; easy to add/remove variants |
| `size` is optional, auto-mapped | Variant → size mapping is a design constant; override available for edge cases |
| `file-upload` renders `<label>` | Standard pattern for custom file inputs; keeps it keyboard-accessible |
| Hover states in SCSS | All hover transitions are defined in the design spec; handled purely in CSS |
| Separate Jest + Cypress tests | Jest for unit/rendering logic; Cypress for visual/DOM verification |
| Storybook stories per variant | Follows Storybook best practices; easy for designers/developers to browse |
@@ -0,0 +1,269 @@
# Plan: Creación del Componente Button para BL Consultores DS con Tailwind CSS
## Contexto
Se necesita crear el componente de botones para el Design System de BL Consultores. El proyecto es una librería React de componentes (`bl-consultores-ds`) basada en `create-react-component-library`.
Actualmente el starter kit incluye un componente `Card` genérico que **debe eliminarse** — es solo el template por defecto del starter kit y no forma parte del diseño system real de BL Consultores.
El diseño de los botones proviene de Penpot y está documentado en Docmost con **11 variantes exactas** (colores, tamaños, estados hover). Se usará **Tailwind CSS** para los estilos.
---
## Paso 0: Instalar y configurar Tailwind CSS
### 0.1 Instalar dependencias
```bash
npm install -D tailwindcss postcss autoprefixer
```
### 0.2 Crear `tailwind.config.js`
Configurar con los tokens exactos del diseño de Penpot:
```js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
gold: '#EABF2D',
amber: '#D4880F',
black: '#1A1A2E',
danger: '#DE3626',
muted: '#9AA0A6',
gray: '#6B6B7B',
inputBorder: '#DADCE0',
},
borderRadius: {
pill: '20px',
circle: '28px',
},
fontSize: {
btn: ['11px', { lineHeight: '1', fontWeight: '700' }],
btnPrimary: ['12px', { lineHeight: '1', fontWeight: '700' }],
btnNormal: ['12px', { lineHeight: '1', fontWeight: '400' }],
},
height: {
btn: '40px',
btnLg: '60px',
btnCircle: '56px',
btnIcon: '36px',
},
width: {
btnPrimary: '170px',
btnOutlineGold: '230px',
btnOutline: '110px',
btnOutlineLight: '140px',
btnGhost: '110px',
btnFileUpload: '200px',
btnCircle: '56px',
btnIcon: '36px',
},
fontFamily: {
sans: ['sourcesanspro', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}
```
### 0.3 Crear `postcss.config.js`
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
```
### 0.4 Ajustar `webpack.config.ts`
- Agregar `postcss-loader` como dependencia
- Agregar regla postcss en las rules de webpack (entre css-loader y sass-loader, o antes de them)
- Para componentes sin SCSS, el CSS se genera vía Tailwind classes directamente
---
## Paso 1: Eliminar el Card del starter kit
| Archivo | Acción |
|---|---|
| `src/components/Card/` | Eliminar directorio completo |
| `src/components/__tests__/Card.test.tsx` | Eliminar |
| `src/components/__tests__/Card.test.cy.tsx` | Eliminar |
| `src/components/index.tsx` | Remover `export * from './Card'` |
---
## Paso 2: Crear el componente Button
### Estructura de archivos
```
src/components/Button/
├── index.tsx # Componente React + tipos
└── Button.stories.tsx # Storybook stories
```
Sin archivo SCSS separado — los estilos se manejan con clases Tailwind.
### TypeScript — Tipos
```tsx
type ButtonVariant =
| 'primary'
| 'outline-gold'
| 'outline-dark'
| 'outline-light'
| 'ghost'
| 'circular'
| 'icon-edit'
| 'icon-delete'
| 'file-upload';
interface ButtonProps {
/** Variant style of the button */
variant?: ButtonVariant;
/** Button text content */
children?: React.ReactNode;
/** Click handler */
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** HTML button type */
type?: 'button' | 'submit' | 'reset';
/** Disabled state */
disabled?: boolean;
/** For file-upload variant: accept attribute */
accept?: string;
/** Optional className for additional styling */
className?: string;
}
```
### JSX — Estructura
El componente usa un `<button>` nativo con clases Tailwind compuestas. Para `file-upload`, renderiza un `<input type="file">` oculto dentro de un `<label>`.
```tsx
const Button: FC<ButtonProps> = ({
variant = 'primary',
children,
onClick,
type = 'button',
disabled = false,
accept,
className = '',
}) => {
if (variant === 'file-upload') {
return (
<label
className={`btn file-upload ${className}`.trim()}
>
<input
type="file"
accept={accept}
className="file-input"
onChange={onClick as any}
disabled={disabled}
/>
<span className="btn-content">{children}</span>
</label>
);
}
return (
<button
className={`btn ${variantClasses[variant]} ${className}`.trim()}
onClick={onClick}
type={type}
disabled={disabled}
>
{children}
</button>
);
};
```
### Variantes — Clases Tailwind exactas desde Penpot
Cada variante tiene clases Tailwind derivadas de los valores exactos de Penpot:
| Variant | Clases Tailwind |
|---|---|
| **primary** | `w-btnPrimary h-btn bg-gold text-black font-sans font-bold text-btnPrimary px-5 rounded-btn flex items-center justify-center gap-2 transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **outline-gold** | `w-btnOutlineGold h-btn bg-white border-[1.5px] border-gold text-amber font-sans font-bold text-btnPrimary px-5 rounded-btn flex items-center justify-center gap-2 transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **outline-dark** | `w-btnOutline h-btn bg-white border-[1.5px] border-black text-black font-sans font-bold text-btn px-4 rounded-sm flex items-center justify-center gap-2 transition-all duration-200 cursor-pointer hover:bg-amber hover:text-white disabled:opacity-50 disabled:cursor-not-allowed` |
| **outline-light** | `w-btnOutlineLight h-btnLg bg-black border-[1.5px] border-white text-white font-sans font-bold text-btn px-4 rounded-sm flex items-center justify-center gap-2 transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **ghost** | `w-btnGhost h-btn bg-white border-[1.5px] border-black text-black font-sans font-bold text-btn px-4 rounded-pill flex items-center justify-center gap-2 transition-all duration-200 cursor-pointer hover:bg-amber hover:text-white disabled:opacity-50 disabled:cursor-not-allowed` |
| **circular** | `w-btnCircle h-btnCircle bg-white border-[1.5px] border-black rounded-circle flex items-center justify-center transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **icon-edit** | `w-btnIcon h-btnIcon bg-white border-[1.5px] border-gold rounded-sm flex items-center justify-center transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **icon-delete** | `w-btnIcon h-btnIcon bg-white border-[1.5px] border-danger rounded-sm flex items-center justify-center transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed` |
| **file-upload** | `relative w-btnFileUpload h-btn bg-white border-[1.5px] border-gold text-amber font-sans font-normal text-btnPrimary px-4 rounded-sm flex items-center justify-center cursor-pointer disabled:opacity-50` |
**Hover states** (automáticos en las clases):
- `outline-dark` y `ghost`: `hover:bg-amber hover:text-white`
**File upload input**: `absolute inset-0 opacity-0 cursor-pointer`
---
## Paso 3: Storybook Stories
```tsx
export const Primary: Story = { args: { variant: 'primary', children: '+ Agregar' } };
export const OutlineGold: Story = { args: { variant: 'outline-gold', children: 'Editar Estructura del Clima' } };
export const OutlineDark: Story = { args: { variant: 'outline-dark', children: 'VER MÁS' } };
export const OutlineLight: Story = { args: { variant: 'outline-light', children: 'VER MÁS' } };
export const Ghost: Story = { args: { variant: 'ghost', children: 'VER MÁS' } };
export const Circular: Story = { args: { variant: 'circular', children: '↓' } };
export const IconEdit: Story = { args: { variant: 'icon-edit', children: '✏️' } };
export const IconDelete: Story = { args: { variant: 'icon-delete', children: '✕' } };
export const FileUpload: Story = { args: { variant: 'file-upload', children: 'Seleccionar CV' } };
```
Incluir también stories de:
- **States**: disabled, hover (usando `play` function)
- **Sizes**: mostrar cada variante con su tamaño design
- **Composition**: botones en grupos, con iconos
---
## Paso 4: Tests
### Jest (`Button.test.tsx`)
- Renderiza con variant default (primary)
- Click handler se llama al hacer click
- Estado disabled deshabilita interacción
- File upload renderiza input file
- Textos y props se renderizan correctamente
- Clases CSS correctas por variante
### Cypress (`Button.test.cy.tsx`)
- Mount y verifica visual de cada variant
- Hover states (outline-dark, ghost)
- Disabled state visual
- File upload label structure
---
## Paso 5: Actualizar exports
`src/components/index.tsx`:
```tsx
export * from './Button';
```
---
## Verificación
1. `npm run storybook` — verificar que todas las variantes se muestran correctamente en Storybook
2. `npm run test` — Jest tests pasan
3. `npm run cy:run` — Cypress tests pasan
4. `npm run build` — build exitoso sin errores
5. Verificar que el Card fue eliminado y no queda referencia rota
6. Verificar que Tailwind se compila correctamente en el bundle final
@@ -0,0 +1,100 @@
# GFIBER-603 — Make the UI fully responsive
## Context
Jira **GFIBER-603** (High priority, "Tarea", assigned to a.barrientos, In Development):
> The team has observed during shadowing that agents normally have multiple windows open on the same screen. Upsell Evaluator would usually occupy between 1/4 and 1/3 of the total width. The app should be adaptable so that it's perfectly usable in these sizes.
>
> **Acceptance Criteria:**
> - The app is fully responsive
> - All input fields are easily accessible and readable from 1/3 and 1/4 of the screen width
**This is not mobile-phone responsiveness** — it's a desktop browser window resized narrow (≈320640px: 1/4 of a 1366px laptop ≈340px, 1/3 of a 1920px monitor ≈640px), mouse-driven, used by call-center agents running the Upsell Evaluator alongside CRM/phone software. That range sits entirely below Tailwind's default `sm` breakpoint (640px), so a mobile-first stacking approach (unstyled/base = narrow, `sm:`+ = normal desktop) fits naturally with **zero custom breakpoints needed** — the project has no `tailwind.config.js` (Tailwind v4, CSS-based config, defaults `sm:640 md:768 lg:1024 xl:1280` untouched).
The repo has an established design system (`packages/design-system/`, 51 components, consumed via `transpilePackages` from source, DS-first convention: new/changed UI goes in the DS with story+Jest+Cypress). Investigation (Explore agent + manual verification) found the actual defects are **app-level compositions** (inline styles / missing responsive classes in page/NavBar code), not DS component bugs — `FlowStepper`, `TextField`, `Select`, and `Textarea` were all checked and are already width-agnostic (no fixed-px widths). So **no new DS components are required**; fixes are Tailwind-class changes in existing app files, consistent with the DS-first convention (these are app-level compositions with no 1:1 DS component, same category as `UpsellEvaluator.tsx`'s existing `upsell.css`).
## Scope
Per user decision, this covers the full AC-literal reading ("the app is fully responsive") — Upsell Evaluator/My Usage **and** ranking/admin grids, not just the narrow-window tool.
1. `app/(protected)/upsell-evaluator/_flow/UpsellEvaluator.tsx` (lines 183218) — the core two-column flow shell, currently **inline `style={{}}`**, zero responsive behavior: outer wrapper `maxWidth: 1080`, `<aside>` `width: 220, flexShrink: 0, position: sticky`, `<main>` `flex: 1`. This is the highest-priority fix — it's the actual tool agents run in the narrow window.
2. `components/NavBar.tsx` — present on every page including Upsell Evaluator. Single always-visible flex row (brand + 24 links + up to 5 right-side icons: StreakBell, AttendanceBell, ThemeToggle, LogoutButton, TourButton), only one responsive class in the whole file. Will overflow/clip at 320480px. Fixed via a collapsible hamburger menu (see below).
3. `app/(protected)/upsell-evaluator/_history/TicketDetailView.tsx:44` (`grid-cols-2`) and `SessionHistoryView.tsx:119` (`grid-cols-3`) — confirmed (via `grep`) these render under `/my-metrics` (`app/(protected)/my-metrics/page.tsx` and `[id]/page.tsx`), reached from the "My Usage" NavBar link right next to Upsell Evaluator.
4. Two dashboard tables missing `overflow-x-auto` (have `overflow-y-auto` only, forcing page-level horizontal overflow instead of a contained scroll): `app/(protected)/dashboard/page.tsx:607-608` (`min-w-[760px]`) and `components/dashboard/AllTeamsCard.tsx:321-322` (`min-w-[700px]`). One-line fix each, matching the already-correct sibling `components/dashboard/ValidationsCard.tsx:123-124` (`overflow-x-auto` + `min-w-[600px]`).
5. `components/ranking/LeaguesLadder.tsx:96` (`grid-cols-4`) and `components/ranking/RankingConfigPanel.tsx:169,202,241` (`grid-cols-2` ×3) — add responsive variants (e.g. `grid-cols-2 sm:grid-cols-4` / `grid-cols-1 sm:grid-cols-2`) so these admin/ranking panels also reflow instead of squeezing at narrow widths.
**Verify-only, no code change expected** (check visually during QA, fix only if actually broken): `components/upsell/CopilotWidget.tsx` (`w-[380px] max-w-[calc(100vw-2rem)]` — already viewport-clamped) and `components/NavBar/StreakBell.tsx:107` (`w-64` popover anchored `right-0` inside the icon cluster).
## Implementation approach
### 1. `UpsellEvaluator.tsx` — convert inline styles to Tailwind, stack below `sm`
Replace the inline-styled shell (lines 183193) with:
```tsx
<div className="upsell-flow" style={{ minHeight: "calc(100vh - 57px)" }}>
<div className="mx-auto flex max-w-[1080px] flex-col items-stretch gap-4 p-4 sm:flex-row sm:items-start sm:gap-6 sm:p-6">
<aside aria-label="Flow navigation" className="w-full flex-shrink-0 sm:sticky sm:top-4 sm:w-[220px]">
<FlowStepper .../>
<div className="mt-3"><Button variant="ghost" onClick={newCall}>...</Button></div>
{blocked && <div className="mt-3"><Badge variant="danger">Hard stop active</Badge></div>}
</aside>
<main className="min-w-0 flex-1">...</main>
</div>
```
Below `sm` (640px): aside goes full-width and stacks above main content. At `sm:` and up: reverts to today's sticky 220px sidebar + flex-1 main — no visual change for normal desktop use. `FlowStepper` needs no changes (already `flex-direction: column; width: 100%`, no fixed-px widths); the narrowness problem was entirely the parent `<aside>`'s hardcoded width. Leave the `saveToast` inline style block (lines 160181) as-is unless touching it is free — not a responsiveness bug (centered fixed toast is width-agnostic).
### 2. `NavBar.tsx` — make the existing (orphaned) DS `AppHeader` component responsive and adopt it
Per user decision, the responsive collapse behavior itself belongs in the design system as a real component, not composed ad hoc in the app. `packages/design-system/src/components/AppHeader/index.tsx` already exists with almost exactly the right shape (`brand?: ReactNode`, `nav?: ...`, `actions?: ReactNode`) but is presentational, non-responsive (fixed `height: 64px` row, no collapse), and **unused anywhere in the app** (`grep` confirms zero imports outside its own Storybook/tests) — so there's no adoption-migration risk, and enhancing it in place is strictly additive.
**DS-side changes (`packages/design-system/src/components/AppHeader/`):**
- Widen the `nav` prop from a structured `{id,label,href,active}[]` array to `ReactNode` (a slot, same shape as `actions` already is). This is the key change the user is asking for: the DS component stops knowing about hrefs/active-state/routing and just arranges *whatever nav content the app hands it* — so `NavBar.tsx` keeps building its role-gated links with `next/link`'s `Link` (prefetch, client-side nav) exactly as it does today, and simply passes that finished JSX in as a prop instead of AppHeader re-rendering plain `<a>` tags.
- Add `"use client"` + internal `open` state (mobile menu). Reuse the sibling `Drawer` component from the same package (`import { Drawer } from '../Drawer'` or via the barrel) — no new slide-over logic needed, `Drawer` already has focus-trap, Tab-trap, Escape, backdrop-close, and focus-restore.
- `style.module.scss`: add a `≥768px` media query (matching Tailwind's `md`) that shows the existing full-width `.nav`/`.actions` row and hides a new hamburger `.trigger` button; below `768px`, hide `.nav`/`.actions` from the row and show `.trigger`, which opens the `Drawer` containing the same `nav` and `actions` nodes.
- Add the standard DS file set for this change: update `AppHeader.stories.tsx` (add a narrow-viewport/mobile story), `AppHeader.test.tsx` (Jest: trigger opens Drawer, Escape/backdrop closes it, content renders in both desktop and mobile paths), `AppHeader.test.cy.tsx` (Cypress: real viewport resize + click-through + focus-trap), per the repo's DS-first convention (every component ships with story + Jest + Cypress).
**App-side changes (`components/NavBar.tsx`):**
- Stays a Server Component — all existing responsibilities (role gating via `profile`, building `Link` elements, composing `StreakBell`/`AttendanceBell`/`ThemeToggle`/`LogoutButton`/`TourButton`) are unchanged. **Confirmed division of responsibility:** `NavBar.tsx` decides *what* to show (which links per role, which icons); `AppHeader` only decides *how* to arrange it responsively. `AppHeader` never sees `Profile`/Supabase types — this preserves the DS = UI / app = business-logic boundary already established in GFIBER-585 (Paso 3 — "DS = estilos/UI; app = lógica de negocio").
- Replace the current hand-rolled `<nav>`/`<div>` markup with `<AppHeader brand={...} nav={<>...existing Link elements...</>} actions={<>...existing icon cluster...</>} />` imported from `@gfiber/design-system`. Passing server-built JSX as props into a client component (`AppHeader`) is a standard, supported RSC pattern (the children/props aren't re-executed on the client) — no NavBar behavior changes.
- Considered and rejected: reusing `KebabMenu` (wrong semantics/shape for primary nav — actions-menu, not nav, no `next/link` support) and building a brand-new, separate DS component instead of enhancing `AppHeader` (would leave `AppHeader` as dead, non-representative Penpot-sourced code and create two overlapping "app header" components in the DS).
### 3. `TicketDetailView.tsx` / `SessionHistoryView.tsx` grids
`grid-cols-2``grid-cols-1 sm:grid-cols-2` (TicketDetailView:44); `grid-cols-3``grid-cols-1 sm:grid-cols-3` (SessionHistoryView:119) — adjust to `sm:grid-cols-2 lg:grid-cols-3` instead if 3-up reads cramped exactly at 640px during QA.
### 4. Dashboard tables
```diff
- <div className="max-h-[460px] overflow-y-auto">
+ <div className="max-h-[460px] overflow-x-auto overflow-y-auto">
```
Apply to both `app/(protected)/dashboard/page.tsx:607-608` and `components/dashboard/AllTeamsCard.tsx:321-322`.
## Verification
- **Vitest + Testing Library** (existing suites for `UpsellEvaluator.tsx`, `NavBar.tsx`, `TicketDetailView`/`SessionHistoryView`): keep existing behavioral tests green (stage transitions, role-gated nav links render correctly into the `nav`/`actions` props); don't add tests whose only assertion is "this className string exists" — low value, jsdom has no real layout engine.
- **Design-system (Jest + Testing Library + Cypress)** for the enhanced `AppHeader`: Jest test for open/close/focus-trap/Escape/backdrop-close and that `nav`/`actions` content renders identically in both the desktop row and the mobile Drawer; Cypress test with real viewport resize (e.g. 340/480/768px) verifying the trigger appears/disappears at the right breakpoint and the drawer is fully interactive. Update `AppHeader.stories.tsx` with a mobile/narrow story so it's visually reviewable in Storybook.
- **Cypress E2E** (app-level, existing Upsell Evaluator flow spec): add `cy.viewport(340, 720)` / `cy.viewport(480, 720)` passes, re-running the same GAID lookup → stages → close interactions to catch real click-target/overlap regressions, including opening the header's mobile menu and navigating via it.
- **qa-validator (real browser)** — the concrete check for the AC. Resize to **340px, 480px, and 640px** (not just one width) and confirm:
1. Upsell Evaluator: stepper stacks above stage content, no squeeze; every input/select/textarea fully visible, not clipped; all buttons (New Call, Continue/Back, ObjectionDrawer trigger) reachable without horizontal scroll.
2. NavBar: below `md:`, only brand + hamburger trigger show; opening the menu reveals all links + icon cluster in the Drawer with working focus-trap/Escape/backdrop-close; nothing clipped or unreachable.
3. `/my-metrics` and `/my-metrics/[id]`: grids stack to 1 column instead of squeezing.
4. `/dashboard`: the two fixed tables scroll horizontally within their own box instead of blowing out page width.
5. `/ranking` and admin config panels: grids reflow instead of squeezing.
6. Verify-only items: `CopilotWidget` panel and `StreakBell` popover (now inside the Drawer) don't clip at 320340px.
7. Screenshots at each width for the PR description, as evidence against the AC.
## Suggested commit/PR breakdown
Single PR (`feat/gfiber-603-responsive-upsell-evaluator`):
1. `UpsellEvaluator.tsx` shell → Tailwind + stacking.
2. `packages/design-system/src/components/AppHeader/` → responsive rework (`nav`/`actions` widened to `ReactNode`, `"use client"`, internal Drawer-based mobile menu, updated story/Jest/Cypress).
3. `components/NavBar.tsx` → adopt `AppHeader` from `@gfiber/design-system`, passing existing role-gated `Link` elements and icon cluster as `nav`/`actions` props (server-component responsibilities unchanged).
4. `TicketDetailView.tsx` + `SessionHistoryView.tsx` → responsive grids.
5. `dashboard/page.tsx` + `AllTeamsCard.tsx``overflow-x-auto`.
6. `ranking/LeaguesLadder.tsx` + `ranking/RankingConfigPanel.tsx` → responsive `grid-cols-N`.
7. DS Jest/Cypress/Storybook updates for `AppHeader`; app Cypress narrow-viewport passes (including mobile-menu navigation via the new header).
8. qa-validator pass at 340/480/640px across Upsell Evaluator, My Usage, Dashboard, and Ranking/Admin pages; screenshots attached to PR description.
@@ -0,0 +1,89 @@
# GFIBER-707 — Fix "Cumulative Results" percentages + add "Pending" card
## Context
Jira **GFIBER-707** (Bug, High priority): the "Cumulative results" dashboard panel shows incorrect percentages for the Customer-Initiated / Agent-Initiated cards. Acceptance criteria:
- A third blue card **"Pending"** is added to the right of the existing two.
- The 3 cards, added together, sum **100%**.
**Root cause found in code** (`utils/dashboard/metrics.ts:157-160`): `customerInitiatedRate` and `agentInitiatedRate` are computed as `count / team.offers * 100`, where `team.offers = accepted + rejected`. But the numerators (`customerInitiated`, `agentInitiated`, `unverifiedAccepted`) are counted only over **accepted** roster tickets and, by construction, exactly partition `accepted` (every accepted ticket is customer-initiated, agent-initiated, or unclassified/null). Since `offers >= accepted` whenever there's at least one rejected ticket, the two existing rates under-report and can never sum to 100% — confirmed by the unit test at `__tests__/utils/dashboard/metrics.test.ts:170-183` (`offers = 3 accepted + 1 declined = 4`, so a fully-classified accepted ticket only registers as 25%, not 100/N%).
The fix: switch the denominator for all three initiation rates to `metrics.accepted` (which is already what the tile's caption text claims — `"{count} initiated by {initiator} / {total} total accepted offers"` already uses `total=metrics.accepted`). This makes `customerInitiatedRate + agentInitiatedRate + unverifiedAcceptedRate === 100` exactly whenever there's at least one accepted ticket, satisfying the AC. The two existing percentages will change (increase) as a side effect — this is the actual bug fix, not a regression.
Decisions confirmed with the user:
- Denominator for all 3 rates → `metrics.accepted` (not `offers`).
- Third card label: **"Pending Validation Rate"**, caption: `"{count} pending validation / {total} total accepted offer(s)"`.
## Changes
### 1. `utils/dashboard/metrics.ts`
- In `buildDashboardMetrics`, change:
```ts
const customerInitiatedRate = team.offers > 0 ? (customerInitiated / team.offers) * 100 : 0;
const agentInitiatedRate = team.offers > 0 ? (agentInitiated / team.offers) * 100 : 0;
```
to use `team.accepted` as the denominator, and add:
```ts
const unverifiedAcceptedRate = team.accepted > 0 ? (unverifiedAccepted / team.accepted) * 100 : 0;
```
- Add `unverifiedAcceptedRate: number` to the `DashboardMetrics` type (next to `agentInitiatedRate`), with a doc comment matching the existing style (`/** (unverifiedAccepted / accepted) × 100 — percentage of accepted offers not yet classified. */`).
- Update the doc comments on `customerInitiatedRate`/`agentInitiatedRate` to say "percentage of accepted offers" (not "total offers"), and update the return statement to include `unverifiedAcceptedRate`.
### 2. `packages/design-system/src/components/InitiationRateTile/index.tsx`
- Widen `initiator: "customer" | "agent"` to `initiator: "customer" | "agent" | "pending"`.
- Adjust the caption line: when `initiator === "pending"`, render `"{count} pending validation / {total} total accepted offer(s)"` instead of `"{count} initiated by {initiator} / ..."`. Simplest approach: branch on `initiator === "pending"` for the middle phrase ("pending validation" vs `initiated by {initiator}`), keeping the rest of the sentence identical.
- No style changes needed — `style.module.scss` already renders every tile with the same blue tint (`--fiber-blue-light` / `--fiber-text-blue`), matching the "blue card" requirement in the AC.
- Update the doc comment on the `initiator` prop to mention the "pending" case.
### 3. `packages/design-system/src/components/InitiationRateTile/InitiationRateTile.stories.tsx`
- Add a `PendingValidation` story (`label: "Pending Validation Rate"`, `initiator: "pending"`, plausible `rate`/`count`/`total`), and widen the story's grid `render` from `gridTemplateColumns: "1fr 1fr"` to `"1fr 1fr 1fr"` (or leave 2-col if stories render independently — check current behavior first).
### 4. `app/(protected)/dashboard/page.tsx` (`CumulativeResults`, ~lines 439-463)
- Change the grid wrapper from `grid grid-cols-2 gap-3 sm:grid-cols-2` to 3 columns (`grid grid-cols-1 gap-3 sm:grid-cols-3`, matching the responsive pattern used elsewhere in the file).
- Add a third `<InitiationRateTile>`:
```tsx
<InitiationRateTile
label="Pending Validation Rate"
rate={metrics.unverifiedAcceptedRate}
count={metrics.unverifiedAccepted}
total={metrics.accepted}
initiator="pending"
/>
```
- Leave the helper-text condition (`customerInitiatedRate === 0 && agentInitiatedRate === 0`) as-is — it still correctly means "nothing classified yet" (in which case pending = 100%).
### 5. `app/(protected)/dashboard/api/export/route.ts` (Sheet 3 — Performance summary, ~lines 346-352)
- After the existing `agentRateRow`, add:
```ts
const pendingRateRow = sheet3.addRow(["Pending Validation Rate", metrics.unverifiedAcceptedRate / 100]);
pendingRateRow.getCell(2).numFmt = "0.0%";
```
This is a new row inserted into Block A, which shifts every subsequent row (blank separator, leaderboard header, leaderboard data rows) down by 1 in the worksheet.
## Tests to update
- **`__tests__/utils/dashboard/metrics.test.ts`**: update existing assertions that hardcode the old `offers`-based math (e.g. `"only counts initiation types from roster users"`, `"counts customer_initiated and agent_initiated correctly"` — the `(1/4)*100` expectations become `(1/accepted)*100`), and add:
- New assertions for `unverifiedAcceptedRate`.
- A dedicated test asserting `customerInitiatedRate + agentInitiatedRate + unverifiedAcceptedRate === 100` (within floating-point tolerance) for a realistic mixed fixture — this directly encodes the GFIBER-707 acceptance criterion.
- **`packages/design-system/.../InitiationRateTile.test.tsx`** and **`.test.cy.tsx`**: add a "pending" case asserting the `"N pending validation / M total accepted offer(s)"` caption text.
- **`__tests__/dashboard-export-route.test.ts`**: this file has **hardcoded row indices** that must shift by +1 because of the new export row:
- `"Block A includes the new initiation type rows after Pitch Rate"` (line ~436-450): loop range `2..14` → `2..15`; add `expect(metricLabels).toContain("Pending Validation Rate")`.
- `"Block B leaderboard has one row per agent user"` (line ~452-464): header now at row **17** (was 16), data rows **18/19** (were 17/18) — update the comment and indices.
- `"includes zero-sales agents in the leaderboard"` (line ~466-476): loop range `17..18` → `18..19`.
## Verification
1. Run the affected unit test suites:
```bash
npx vitest run __tests__/utils/dashboard/metrics.test.ts __tests__/dashboard-export-route.test.ts
```
2. Run the design-system component tests for the tile:
```bash
npx vitest run packages/design-system/src/components/InitiationRateTile
```
3. Full test suite + typecheck to catch any other consumer of `DashboardMetrics`/`InitiationRateTile`'s `initiator` prop:
```bash
npm run build # or the repo's typecheck script — confirms the widened `initiator` union doesn't break other call sites
npm test
```
4. Manually load `/dashboard` (any team view) in a browser and confirm: 3 blue cards render side by side, and the 3 rate percentages sum to 100.0% for a mix of accepted tickets with `customer_initiated`, `agent_initiated`, and `null` initiation types.
@@ -0,0 +1,348 @@
# GFIBER-622 — Session History (User Level)
## Context
Users currently have no way to review their past upsell evaluations. Every session
is persisted in `upsell_tickets` (joined via `clients` for the GAID), but that data
is only visible to admins in the Sales Dashboard. GFIBER-622 adds a personal history
view so each agent can see their own sessions, their outcomes, and navigate into the
full detail of any ticket.
**Acceptance Criteria (from Jira):**
- Each user can access their previous sessions and corresponding data/outcomes
- The history shows only sessions belonging to the current user (RLS already enforces this)
- UI draft presented to team for validation → Improvements → Approved
---
## Architecture
### Routes (new)
```
app/(protected)/upsell-evaluator/
├── history/
│ ├── page.tsx ← Server Component: listMyTickets() → <SessionHistoryView>
│ └── [id]/
│ └── page.tsx ← Server Component: getTicketById(id) → <TicketDetailView>
```
### Components (new)
```
app/(protected)/upsell-evaluator/
├── _history/
│ ├── SessionHistoryView.tsx ← 'use client' (SearchField needs state)
│ └── TicketDetailView.tsx ← Server Component (pure render, Link for back)
```
### Files to modify
| File | Change |
|---|---|
| `app/(protected)/upsell-evaluator/actions.ts` | Add `listMyTickets()` + `getTicketById()` |
| `components/NavBar.tsx` | Add "History" nav link for all authenticated users |
---
## Design System — Components Used (zero DS extensions needed)
| Component | Where | How |
|---|---|---|
| `PageHeader` | Both pages | History: title + count badge; Detail: title + back `<Link>` in `actions` |
| `DataTable` | `SessionHistoryView` | Generic columned table; `render` fn accepts `ReactNode``<Badge>` in outcome cell |
| `Badge` | Outcome column + detail | `variant`: `success`→accepted, `danger`→declined_*, `info`→no_pitch/no_offer, `solid`→in_progress |
| `AgentStatCard` | `SessionHistoryView` | 3-card grid: Total Sessions · Accepted · Conversion Rate |
| `SearchField` | `SessionHistoryView` | Client-side filter on Case ID or GAID (no server round-trip) |
| `Card` | `TicketDetailView` | 3 sections: Identification · Outcome · Call Details |
| `DataRow` | `TicketDetailView` | label/value pairs per field, `tone` prop for outcome color |
| `Button` variant="ghost" | Detail "back" | `<Link>` styled or a DS Button wrapping the link |
**No DS component extensions required.** `DataTable.render` already accepts `ReactNode`.
---
## Outcome badge mapping
```ts
// utils/upsell/outcomeDisplay.ts (new small utility, importable from both views)
import type { Database } from '@/utils/supabase/database.types';
type UpsellState = Database['public']['Enums']['upsell_state'];
export const OUTCOME_BADGE: Record<UpsellState, { variant: 'success' | 'danger' | 'info' | 'solid'; label: string }> = {
accepted: { variant: 'success', label: 'Accepted' },
declined_hard: { variant: 'danger', label: 'Declined' },
declined_soft: { variant: 'danger', label: 'Declined (soft)' },
no_pitch: { variant: 'info', label: 'No Pitch' },
no_offer: { variant: 'info', label: 'No Offer' },
in_progress: { variant: 'solid', label: 'In Progress' },
};
```
---
## Server Actions (add to `actions.ts`)
### `listMyTickets()`
```ts
export type TicketWithGaid = TicketRow & { clients: { gaid: string | null } | null };
export async function listMyTickets(): Promise<GemResult<TicketWithGaid[]>> {
const current = await getCurrentUserWithProfile();
if (!current) return { ok: false, error: 'Not authenticated.' };
const supabase = await createClient();
const { data, error } = await supabase
.from('upsell_tickets')
.select('*, clients(gaid)')
.eq('created_by', current.user.id)
.order('created_at', { ascending: false });
if (error) return { ok: false, error: 'Could not load history.' };
return { ok: true, data: (data ?? []) as TicketWithGaid[] };
}
```
### `getTicketById(id)`
```ts
export async function getTicketById(id: string): Promise<GemResult<TicketWithGaid>> {
const current = await getCurrentUserWithProfile();
if (!current) return { ok: false, error: 'Not authenticated.' };
const supabase = await createClient();
const { data, error } = await supabase
.from('upsell_tickets')
.select('*, clients(gaid)')
.eq('id', id)
.eq('created_by', current.user.id) // defense-in-depth (RLS also filters)
.maybeSingle();
if (error) return { ok: false, error: 'Could not load ticket.' };
if (!data) return { ok: false, error: 'Ticket not found.' };
return { ok: true, data: data as TicketWithGaid };
}
```
RLS already guarantees `created_by = auth.uid()` — the explicit `.eq` is defense-in-depth.
---
## `SessionHistoryView.tsx` — key details
**DataTable columns** (7 total — matches the Penpot mockup):
```ts
const COLUMNS: TDataColumn[] = [
{ key: 'created_at', header: 'Date', render: (r) => formatDate(r.created_at as string) },
{ key: 'case_id', header: 'Case ID', render: (r) => (r.case_id as string) ?? '—' },
{ key: 'gaid', header: 'GAID', render: (r) => truncateGaid(r.clients?.gaid ?? null) },
{ key: 'state', header: 'Outcome', render: (r) => {
const { variant, label } = OUTCOME_BADGE[r.state as UpsellState];
return <Badge variant={variant}>{label}</Badge>;
}},
{ key: 'plan_sold', header: 'Plan Sold', render: (r) => formatPlan(r.plan_sold as string | null) },
{ key: 'initiation_type', header: 'Type', render: (r) => formatInitiationType(r.initiation_type as string | null) },
{ key: 'id', header: '', align: 'right', render: (r) => (
<Link href={`/upsell-evaluator/history/${r.id}`} className={styles.viewLink}>View </Link>
)},
];
```
**Display helpers** (co-locate in `_history/SessionHistoryView.tsx` or extract to `utils/upsell/outcomeDisplay.ts`):
```ts
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Show first 18 chars + ellipsis so the column doesn't blow out
const truncateGaid = (gaid: string | null) => gaid ? gaid.slice(0, 18) + '…' : '—';
// '2_gig' → '2 Gig', null → '—'
const formatPlan = (plan: string | null) =>
plan ? plan.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) : '—';
// 'agent_initiated' → 'Agent', 'customer_initiated' → 'Customer', null → '—'
const formatInitiationType = (t: string | null) =>
t === 'agent_initiated' ? 'Agent' : t === 'customer_initiated' ? 'Customer' : '—';
// 'internet_wifi' → 'Internet / Wi-Fi' (best-effort title-case with slash)
const formatIssueType = (t: string | null) =>
t ? t.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') : '—';
```
**SearchField** — controlled `useState`, filters `data` client-side:
```ts
const filtered = tickets.filter(t =>
(t.case_id ?? '').includes(query) ||
(t.clients?.gaid ?? '').toLowerCase().includes(query.toLowerCase())
);
```
**AgentStatCard grid (3 cards) — reuse `countStates` + `formatRate` from `utils/dashboard/metrics.ts`:**
```ts
import { countStates, formatRate } from '@/utils/dashboard/metrics';
// countStates is not exported yet — either export it or inline the same logic:
// offers = accepted + declined_hard + declined_soft
// conversionRate = accepted / offers (0 when offers === 0)
// All definitions match utils/dashboard/metrics.ts exactly to avoid divergence.
const counts = countStates(tickets.map(t => t.state));
// counts.totalInteractions, counts.offers, counts.accepted, counts.conversionRate
```
Cards:
- **Total Sessions** → `counts.totalInteractions`, `tone="neutral"`, sublabel `"All time"`
- **Accepted** → `counts.accepted`, `tone="success"`, sublabel `"${counts.accepted} of ${counts.offers} offers"`
- **Conversion Rate** → `formatRate(counts.conversionRate)`, `tone={counts.conversionRate > 0 ? "success" : "neutral"}`, sublabel `"Accepted / offers made"`
> **Note:** `countStates` is currently unexported from `utils/dashboard/metrics.ts`. Either export it (`export function countStates`) as part of this ticket, or inline the identical logic in a co-located helper. Exporting is preferred — it avoids divergence.
**Empty state:** when `tickets.length === 0`, render a friendly message inside the DataTable area ("No sessions yet — start an evaluation to see your history here.").
---
## `TicketDetailView.tsx` — key details
### `[id]/page.tsx` — Server Component
```tsx
import { notFound } from 'next/navigation';
import { getTicketById } from '../actions';
import { TicketDetailView } from '../_history/TicketDetailView';
export default async function TicketDetailPage({ params }: { params: { id: string } }) {
const result = await getTicketById(params.id);
if (!result.ok) notFound(); // triggers Next.js built-in 404 page
return <TicketDetailView ticket={result.data} />;
}
```
### `TicketDetailView.tsx` — layout (matches Penpot mockup)
The detail view uses a **2-column grid** (not stacked), mirroring the mockup:
```
PageHeader
title="Session Detail"
badge=<Badge variant={outcome.variant}>{outcome.label}</Badge>
actions=<Link href="/upsell-evaluator/history">← Back to History</Link>
<div className="grid grid-cols-2 gap-5 mt-6">
{/* LEFT column */}
<div className="flex flex-col gap-5">
Card title="Identification"
DataRow label="Date" value={formatDate(ticket.created_at)}
DataRow label="Case ID" value={ticket.case_id ?? '—'}
DataRow label="Order ID" value={ticket.order_id ?? '—'}
DataRow label="GAID" value={ticket.clients?.gaid ?? '—'}
Card title="Outcome"
DataRow label="Status" value=<Badge variant={...}>{label}</Badge>
DataRow label="Plan Sold" value={formatPlan(ticket.plan_sold)}
DataRow label="Recommended" value={formatPlan(ticket.recommended_plan)}
DataRow label="Initiation" value={formatInitiationType(ticket.initiation_type)}
</div>
{/* RIGHT column */}
<div>
Card title="Call Details"
DataRow label="Issue Type" value={formatIssueType(ticket.issue_type)}
DataRow label="Open Tech Ticket" value={ticket.open_tech_ticket ? 'Yes' : 'No'}
DataRow label="Hard Stops" value={ticket.hard_stop_reasons ?? 'None'}
</div>
</div>
```
Reuse the same display helpers (`formatDate`, `formatPlan`, `formatInitiationType`, `formatIssueType`) defined in `outcomeDisplay.ts` — import from there, don't duplicate.
---
## Navigation entry point
Add one link to `components/NavBar.tsx` (visible to all authenticated users):
```tsx
<Link href="/upsell-evaluator/history" className={linkClass}>
History
</Link>
```
Place after the "Upsell Evaluator" link, before "My Metrics".
---
## Tests (TDD — required before PR)
### Server Actions
- `__tests__/upsell/listMyTickets.test.ts`
- Returns tickets ordered by `created_at` DESC for the current user
- Returns empty array when user has no tickets
- Returns `{ ok: false }` when unauthenticated
- `__tests__/upsell/getTicketById.test.ts`
- Returns ticket when found and owned by current user
- Returns `{ ok: false, error: 'Ticket not found.' }` when ticket belongs to another user (RLS)
- Returns `{ ok: false }` when unauthenticated
### Components
- `__tests__/components/SessionHistoryView.test.tsx`
- Renders correct row count from `tickets` prop
- SearchField filters rows by Case ID
- SearchField filters rows by GAID
- Shows empty state when `tickets` is empty
- AgentStatCards show correct computed values
- `__tests__/components/TicketDetailView.test.tsx`
- Renders all DataRow fields present in the ticket
- Back link points to `/upsell-evaluator/history`
- `Badge` variant matches the ticket `state`
---
## Execution — Background Agent
Esta implementación la ejecutará un agente autónomo de background via la skill global `background-orchestrator`.
**Instrucciones de lanzamiento:**
```
lanza un agente para esto → GFIBER-622 Session History
```
**Nombre del agente sugerido:** `agente-gfiber-622-session-history`
**Rama:** `feat/gfiber-622-session-history` (desde `dev`, PR apunta a `dev`)
**Alcance delegado al agente:**
1. Exportar `countStates` en `utils/dashboard/metrics.ts`
2. Crear `utils/upsell/outcomeDisplay.ts` con los helpers de display
3. Añadir `listMyTickets()` + `getTicketById()` en `actions.ts`
4. Crear `app/(protected)/upsell-evaluator/history/page.tsx`
5. Crear `app/(protected)/upsell-evaluator/history/[id]/page.tsx` (con `notFound()`)
6. Crear `app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx`
7. Crear `app/(protected)/upsell-evaluator/_history/TicketDetailView.tsx`
8. Modificar `components/NavBar.tsx` — agregar link "History"
9. Escribir todos los tests **antes** de cada implementación (TDD)
10. Abrir PR via `aleleba-pr` al terminar con CI verde
**El agente nunca mergea.** La aprobación y merge quedan a decisión del usuario.
---
## Verification
1. **Server Actions**: `npm run test` — all new tests pass, no regressions (638+ green).
2. **History page** (`/upsell-evaluator/history`):
- Loads with sessions listed for the logged-in user only (test with two accounts)
- Search by Case ID and GAID works
- AgentStatCards show correct counts
- "View →" link navigates to the detail page
3. **Detail page** (`/upsell-evaluator/history/[id]`):
- All fields render correctly
- "Back to History" link returns to the list
- Attempting to access another user's ticket ID returns a 404/redirect (RLS)
4. **NavBar**: "History" link appears for regular users (not admin-gated).
5. `npm run build` — no TypeScript errors.
@@ -0,0 +1,222 @@
# GFIBER-689 — Incentive Verification
## Context
Jira ticket [GFIBER-689](https://willowtree.atlassian.net/browse/GFIBER-689) ("Incentive Verification", reported by Josias Jimenez, assigned to Alejandro Lembke, status *In Development*) asks for cosmetic and functional changes to the admin dashboard's verification table:
1. Rename "Upsell Verification" → "Offer Verification".
2. Rename the Status column → "Incentive", with values Yes / No / Pending.
3. Add a "Sales Coach Notes" free-text field.
4. Add a button that opens an "Offers Table" with date filters and the offer details + notes.
This ticket lands one day after **PR #100** ("Upsell Verification — 3 validaciones con Status derivado", merged 2026-07-13) shipped the exact feature it's extending: `components/dashboard/AcceptedTicketsVerification.tsx` already derives a `pending`/`accepted`/`rejected` status from three checks (`full_flow_completed`, `initiation_type`, `objection_handled_correctly`) via `utils/upsell/verification.ts`'s `deriveVerificationStatus()`. Docmost's `Estado_Actual_del_Proyecto` and the code agree exactly — verified directly, no drift.
Brainstormed with the user to resolve what the ticket text alone couldn't answer, reusing the existing "all agents" performance/attendance tab pattern (`AllTeamsCard.tsx`) as the template for the new "Offers Table" ask, which turned out to really be a per-agent aggregate (not a per-ticket detail view). Final resolved scope, in the user's own words: the existing verification table becomes a "Validations" tab; a new "Information by Agent" tab shows per-agent Pending/Valid/Rejected/Total counts with its own Excel export; both views exclude `admin`/`owner`, showing only `role="user"` agents.
**Why this matters beyond the literal ticket text:** the existing feature has hard-won business rules (a `customer_initiated` ticket can never be `accepted`; `full_flow_completed = false` is a non-overridable rejection backstop) that must survive this change untouched — this plan treats `deriveVerificationStatus()` as frozen and only relabels its output, never recomputes it differently.
## Scope decisions (confirmed with the user)
| Question | Decision |
|---|---|
| Incentive Yes/No/Pending vs. existing derived status | Pure UI relabel, 1:1 (Yes=accepted, No=rejected, Pending=pending). No logic change. |
| Sales Coach Notes location | Inside `TicketVerificationModal` only — no new table column. |
| Sales Coach Notes vs. existing Salesforce-notes/objections_handled | Fully new, independent field. |
| "Offers Table" shape | New "Information by Agent" tab inside a new wrapper card, mirroring `AllTeamsCard`'s tab pattern — not a per-ticket detail view. |
| Role scope | Both tabs (Validations + Information by Agent) show only `role="user"` agents/tickets — `admin`/`owner` excluded entirely. |
| Team-toggle interaction | Information by Agent ignores the Mine/All-teams toggle — always the full role=user roster, same as today's Validations table (rendered identically in both branches of `page.tsx`). |
| Export button scope | Dedicated single-sheet export just for the Information-by-Agent table (new sibling route), not folded into the existing 5-sheet "Export report". |
Verified directly against code (not just docs) before finalizing this plan:
- `StatusBadge` ([index.tsx](packages/design-system/src/components/StatusBadge/index.tsx#L13-L17)) renders `{status}` literally with no label-override capability — a DS change is required for the Yes/No/Pending relabel.
- The accepted-tickets query in `page.tsx` ([lines 124-130](app/(protected)/dashboard/page.tsx#L124-L130)) has **no role filter today** — only `.eq("state", "accepted")`. `allAgents` (role="user", pre-team-filter) is already computed at [line 164](app/(protected)/dashboard/page.tsx#L164), before the `acceptedTickets` line ([194](app/(protected)/dashboard/page.tsx#L194)) — the fix reuses that existing set.
## Implementation
### 1. Migration — `sales_coach_notes`
New file `supabase/migrations/20260714120000_add_sales_coach_notes_to_upsell_tickets.sql`, following the exact comment style of `20260710090000_add_verification_columns_to_upsell_tickets.sql`:
```sql
alter table public.upsell_tickets
add column sales_coach_notes text default null;
comment on column public.upsell_tickets.sales_coach_notes is
'Free-text coaching notes an admin/owner can attach to a ticket during '
'verification review. NULL = no notes recorded. Write access restricted to '
'admin/owner at the application layer (setSalesCoachNotes Server Action).';
```
No backfill needed — `NULL` is the correct value for every pre-existing row. Apply to DEV (`supabase db push`, project `vshcimexazocttjmyrqy`), then `supabase gen types typescript --linked > utils/supabase/database.types.ts`. Do not push to PROD (`hwxkegbdnbrzvekrqxbd`) as part of this branch.
### 2. `StatusBadge` — optional label override (DS change)
[packages/design-system/src/components/StatusBadge/index.tsx](packages/design-system/src/components/StatusBadge/index.tsx): add an optional `label` prop that overrides the displayed text while `status` still drives the color class (fully backward-compatible — no existing caller passes it today).
```ts
type TStatusBadgeProps = {
status: TStatus,
/** Optional display text override — `status` still drives the color class. */
label?: string,
className?: string,
};
```
The SCSS applies `text-transform: lowercase` to `.badge`; since "Yes/No/Pending" must render capitalized, add a `styles.customLabel` modifier (no text-transform) applied only when `label` is passed.
DS-first updates: `StatusBadge.stories.tsx` (new `CustomLabel` story), `StatusBadge.test.tsx` (label override renders instead of status text; color class still keyed by `status`), and `StatusBadge.test.cy.tsx` if it exists.
Apply the same Yes/No/Pending relabel to the modal's header badge too (`TicketVerificationModal` renders its own `StatusBadge` from `ticket.status` — thread a new `statusLabel?: string` prop through so the modal shows "Yes" rather than "accepted", keeping the table and modal consistent for the same ticket).
### 3. Header/subtext + Incentive column rename
In [components/dashboard/AcceptedTicketsVerification.tsx](components/dashboard/AcceptedTicketsVerification.tsx):
- `aria-label="Upsell Verification"` / `Card title="Upsell Verification"``"Offer Verification"`.
- Subtitle → `"Review 3 criteria checks for each offer"`.
- Column `header: 'Status'``header: 'Incentive'`.
- Add a local label map next to the existing `PLAN_LABELS` helper:
```ts
const INCENTIVE_LABELS: Record<VerificationStatus, string> = {
accepted: 'Yes',
rejected: 'No',
pending: 'Pending',
};
```
Render `<StatusBadge status={derived} label={INCENTIVE_LABELS[derived]} />`.
Update `__tests__/accepted-tickets-verification.test.tsx`: heading assertion → "Offer Verification"; status-column assertions → "Yes"/"No"/"Pending" instead of raw values.
### 4. `setSalesCoachNotes` Server Action
New export in [app/(protected)/dashboard/actions.ts](app/(protected)/dashboard/actions.ts), placed after `setObjectionHandled`, mirroring its exact structure — `requireRole(admin, owner)` gate, `state === "accepted"` guard (kept, since the modal only ever opens from accepted tickets today — no other entry point exists), `revalidatePath("/dashboard")`:
```ts
export type SalesCoachNotesResult = { ok: true } | { error: string };
export async function setSalesCoachNotes(
ticketId: string,
notes: string | null,
): Promise<SalesCoachNotesResult> {
// identical shape to setObjectionHandled, updating sales_coach_notes
}
```
New test `__tests__/dashboard-sales-coach-notes.test.ts`, structured identically to `__tests__/dashboard-initiation-type.test.ts`: permission checks, state guard, null clears notes, `revalidatePath` on success only, DB-error propagation.
### 5. `TicketVerificationModal` — 4th field
[packages/design-system/src/components/TicketVerificationModal/index.tsx](packages/design-system/src/components/TicketVerificationModal/index.tsx): add a 4th block using the existing DS `Textarea` component (already exported from `@gfiber/design-system` — no new primitive needed):
- New props: `salesCoachNotes: string | null`, `onSetSalesCoachNotes: (value: string | null) => void`, `savingSalesCoachNotes: boolean`.
- Local `notesDraft` state seeded from `ticket.salesCoachNotes ?? ''`, save-on-blur (`onSetSalesCoachNotes(notesDraft.trim() === '' ? null : notesDraft)`), disabled while saving.
- Include `savingSalesCoachNotes` in the modal's existing "saving" guard (blocks Escape/backdrop-close mid-save, same as the other two checks).
DS-first updates: `TicketVerificationModal.test.tsx` (renders textarea with value; blur calls the handler; empty blur → `null`; disabled while saving), `.stories.tsx` (new args + `WithNotes` story), `.test.cy.tsx` (type + blur mount test).
Wire up in `AcceptedTicketsVerification.tsx`: own `useTransition` for notes saving, extend the `AcceptedTicket` type with `sales_coach_notes: string | null`, pass the new props through.
### 6. New wrapper — `ValidationsCard`
New file `components/dashboard/ValidationsCard.tsx` (client component), mirroring `AllTeamsCard.tsx`'s tab mechanics exactly — local `useState<'validations' | 'information'>('validations')`, `role="tablist"`/`role="tab"`/`role="tabpanel"`, no URL state:
- Props: `{ tickets: AcceptedTicket[] }` — same shape already used, no new data-fetching, pure wrapper.
- **"Validations" tab**: renders `<AcceptedTicketsVerification tickets={tickets} hideHeader />` — add a `hideHeader?: boolean` prop to `AcceptedTicketsVerification` so its own `Card` title/subtitle don't double up inside the wrapper's own header.
- **"Information by Agent" tab** (exact label): a small table (columns: Agent | Pending | Valid | Rejected | Total), built from `buildValidationSummaryByAgent()` (below), plus a dedicated export link (see §8).
`page.tsx`: replace both occurrences of `<AcceptedTicketsVerification tickets={acceptedTickets} />` (the "all" branch and the "mine" branch) with `<ValidationsCard tickets={acceptedTickets} />`.
### 7. Per-agent aggregation — `buildValidationSummaryByAgent`
New pure function in `utils/upsell/verification.ts` (co-located with `deriveVerificationStatus`, since it directly consumes it):
```ts
export type ValidationSummaryRow = {
agentId: string;
displayName: string | null;
email: string;
pending: number;
valid: number;
rejected: number;
total: number;
};
export function buildValidationSummaryByAgent(
tickets: (VerifiableTicket & { created_by: string | null })[],
agents: { id: string; displayName: string | null; email: string }[],
): ValidationSummaryRow[]
```
Groups tickets by `created_by`, tallies `deriveVerificationStatus()` per ticket into pending/valid/rejected, and returns one row per agent in the roster (zero-ticket agents included with all-zero counts, same convention as `buildDashboardMetrics`), sorted by total desc then name. Tickets with `created_by === null` are excluded.
New unit test `__tests__/utils/upsell/validation-summary.test.ts`: counts derived via the real `deriveVerificationStatus` (not reimplemented), zero-ticket agents appear, null `created_by` excluded, sort order.
### 8. Role filter fix + dedicated export route
**`page.tsx`** (the actual bug this ticket surfaces): hoist a role-scoped id set right after `allAgents` is computed ([~line 164](app/(protected)/dashboard/page.tsx#L164)):
```ts
const allAgentIds = new Set(allAgents.map((u) => u.id));
```
Then filter `acceptedTickets` ([line 194](app/(protected)/dashboard/page.tsx#L194)) against `allAgentIds` (not the team-scoped `agentIds`, since Information-by-Agent must ignore the Mine/All toggle per the confirmed scope decision):
```ts
const acceptedTickets = (acceptedTicketsResult.data ?? [])
.filter((t) => t.profiles !== null && t.created_by !== null && allAgentIds.has(t.created_by))
as unknown as AcceptedTicket[];
```
Also add `sales_coach_notes` to the `acceptedTicketsResult` query's `select(...)` list ([line 126](app/(protected)/dashboard/page.tsx#L126)).
**New export route** `app/(protected)/dashboard/api/export/validations/route.ts`, following the same pattern as the existing `app/(protected)/dashboard/api/export/route.ts` (Route Handler, `runtime = "nodejs"`, `dynamic = "force-dynamic"`, `requireRole(admin, owner)` gate, `exceljs`) but scoped to a single sheet:
- Query params: `start`/`end` (date range only — no `team`, since this view ignores the team toggle).
- Fetch `upsell_tickets` (`state = "accepted"`, date-range scoped) + `profiles` (role="user"), reusing `buildValidationSummaryByAgent`.
- New shared helper `utils/dashboard/excel.ts` — `addSheetFromRows(workbook, name, columns, rows)` (extracted since no such helper exists yet; used only by this new route, not retrofitted onto the existing 5-sheet export to avoid unrelated regression risk).
- Single worksheet "Information by Agent" with columns Agent/Pending/Valid/Rejected/Total, streamed the same way as the existing route (`Content-Disposition: attachment`).
- Trigger: a plain `<a href="/dashboard/api/export/validations?start=...&end=..." download>` inside the Information-by-Agent tab panel — same `<a>` + `download` convention as the existing export button, no new button component.
New tests: `__tests__/utils/dashboard/excel.test.ts` (bold header row, correct rows for `addSheetFromRows`), `__tests__/dashboard-export-validations-route.test.ts` (new route: correct single-sheet output, admin/owner tickets excluded, auth/role gating mirrors the existing export route's tests).
## Files touched
| File | Change |
|---|---|
| `supabase/migrations/20260714120000_add_sales_coach_notes_to_upsell_tickets.sql` | new column |
| `utils/supabase/database.types.ts` | regenerated after DEV push |
| `packages/design-system/src/components/StatusBadge/*` | `label` prop + custom-label style + story/tests |
| `packages/design-system/src/components/TicketVerificationModal/*` | notes textarea + `statusLabel` prop + story/tests |
| `components/dashboard/AcceptedTicketsVerification.tsx` | rename copy, Incentive labels, `hideHeader` prop, notes wiring |
| `components/dashboard/ValidationsCard.tsx` | new — tab wrapper |
| `utils/upsell/verification.ts` | new `buildValidationSummaryByAgent` |
| `utils/dashboard/excel.ts` | new — `addSheetFromRows` helper |
| `app/(protected)/dashboard/actions.ts` | new `setSalesCoachNotes` |
| `app/(protected)/dashboard/page.tsx` | role-filter fix, `sales_coach_notes` in select, swap in `ValidationsCard` |
| `app/(protected)/dashboard/api/export/validations/route.ts` | new — dedicated single-sheet export |
## Tests (TDD — write these first, red before green)
- `packages/design-system/.../StatusBadge.test.tsx` (+ stories, + Cypress) — label override.
- `packages/design-system/.../TicketVerificationModal.test.tsx` (+ stories, + Cypress) — notes field, `statusLabel`.
- `__tests__/dashboard-sales-coach-notes.test.ts` — new Server Action, mirrors `dashboard-initiation-type.test.ts`.
- `__tests__/utils/upsell/validation-summary.test.ts` — new aggregation function.
- `__tests__/accepted-tickets-verification.test.tsx` — updated copy/label assertions, notes wiring, `hideHeader`.
- New component test for `ValidationsCard` (tab switching, literal "Information by Agent" label, aggregate counts, export link `href`).
- `__tests__/utils/dashboard/excel.test.ts` — new `addSheetFromRows` helper.
- `__tests__/dashboard-export-validations-route.test.ts` — new export route.
- `__tests__/upsell-verification-status.test.ts` — unchanged, regression guard that `deriveVerificationStatus` logic/return values are untouched.
- Do **not** touch `dashboard-initiation-type.test.ts` / `dashboard-objection-handled.test.ts` — those actions are unmodified.
## Verification (end-to-end)
1. `npm run lint && npm test` (Vitest) and the design-system's Jest + Cypress suites — all green.
2. `supabase db push` against DEV (`vshcimexazocttjmyrqy`), regenerate types, confirm `tsc --noEmit`/`npm run build` clean.
3. `npm run dev`, log in as admin/owner, open `/dashboard`: confirm "Offer Verification" header/subtext, Incentive column shows Yes/No/Pending, kebab modal shows Yes/No badge + notes textarea; type notes, blur, reload the page (not just `router.refresh()`) to confirm persistence survived a real fetch.
4. Switch to "Information by Agent" tab: counts match a manually-tallied ticket set for a known agent; confirm the tab ignores the Mine/All toggle (counts identical regardless of which team tab is selected elsewhere on the page).
5. Confirm role filter: find or seed an `upsell_tickets` row with `created_by` pointing to an `admin`/`owner` profile and `state = 'accepted'` — confirm it appears in **neither** tab (and would have appeared before the `page.tsx` fix, to prove the filter is the actual cause).
6. Click the Information-by-Agent export button, open the downloaded `.xlsx`, confirm one sheet named "Information by Agent" with matching data and the same admin/owner exclusion.
7. Sanity-check no collision with the still-unmerged "Upsell State Override" proposal (separate plan, operates on manual `state` reclassification) — no shared logic, only `actions.ts` and `AcceptedTicketsVerification.tsx` are touched by both, and here only additively.
## Git workflow
Branch `feat/gfiber-689-incentive-verification` from `dev` (dashboard features branch from/merge to `dev` per `docs/knowledge/conventions.md`), PR → `dev`.
@@ -0,0 +1,90 @@
# Upsell Verification — 3 Validaciones (Pending/Accepted/Rejected)
## Contexto
El negocio quiere que una oferta de upsell "válida" cumpla tres validaciones antes de contar como verificada, y que la tabla `Upsell Verification` del dashboard admin/owner (`components/dashboard/AcceptedTicketsVerification.tsx`, entregada en GFIBER-648, ya mergeada a `dev`) muestre un estado único derivado en vez de solo el widget de clasificación que tiene hoy.
El diseño completo ya existe como plan aprobado en Docmost (space GFiber → "Upsell_Verification_—_Plan_3_Validaciones_(Pending-Accepted-Rejected)" → "Plan_(Español)"), marcado ahí como "pendiente de aprobación de implementación". Antes de ejecutarlo se verificó cada afirmación del documento contra el código real (3 agentes de exploración en paralelo + lectura directa de los archivos críticos). El plan de Docmost resultó mayormente preciso, con una corrección material importante (ver "Correcciones" abajo) y dos decisiones de UI ya resueltas con el usuario.
**Decisiones confirmadas con el usuario:**
- La rama sale de `dev` (no de `main`, pese a que `docs/knowledge/conventions.md` documente lo contrario — la práctica real reciente, GFIBER-648/streaks/ranking, rama todo desde `dev`).
- El kebab (⋯) que abre el modal usa el `IconButton` real del design system (seria su primer uso real en la app).
- El check de objeción dentro del modal es un único `ToggleChip` binario (no dos chips como `initiation_type`).
**Corrección material al plan de Docmost:** el documento asume que `createTicket`/`updateTicket` (`app/(protected)/upsell-evaluator/actions.ts`) hacen spread directo de `buildTicketPayload()` hacia el insert/update de Supabase. Es falso: el spread real ocurre un nivel arriba, en `UpsellEvaluator.tsx:101-107`; `actions.ts` revalida y mapea cada campo explícitamente campo-por-campo antes de tocar la DB (líneas 236-257 y 324-358). Persistir `full_flow_completed` requiere tocar `actions.ts` en tres puntos, no solo `payloads.ts` (ver Paso 5).
## Diseño (validado contra el código)
Tres validaciones sobre cada ticket `accepted`, con estado derivado `pending` / `accepted` / `rejected`:
1. **`full_flow_completed`** (bool, automático) — `true` cuando `state.outcome !== null` en `buildTicketPayload`, es decir, el agente llegó a Accept/Decline. Si es `false` → status **siempre** `rejected`, es un backstop de integridad de datos, no editable por el admin.
2. **`initiation_type`** (ya existe, GFIBER-648) — solo se reubica su UI de la fila de la tabla a un modal.
3. **`objection_handled_correctly`** (bool nullable, nuevo) — verificado por admin: ¿el agente insistió tras una objeción? Sin revisar = `null` = pending.
```ts
function deriveVerificationStatus(t): "pending"|"accepted"|"rejected" {
if (!t.full_flow_completed) return "rejected";
if (t.objection_handled_correctly === false) return "rejected";
if (t.initiation_type === null || t.objection_handled_correctly === null) return "pending";
return "accepted";
}
```
## Plan de ejecución (TDD — rojo antes que verde)
**Paso 0 — Rama:** `git checkout dev && git pull && git checkout -b feat/upsell-verification-checks`.
**Paso 1 — Migración DB** (bloqueante para todo lo demás). Archivo nuevo `supabase/migrations/<timestamp posterior a 20260709140000>_add_verification_columns_to_upsell_tickets.sql`, estilo idéntico a `20260624000000_add_initiation_type_to_upsell_tickets.sql`:
- `full_flow_completed boolean not null default false` **con backfill crítico**: `update upsell_tickets set full_flow_completed = true where state in ('accepted', 'declined_hard', 'declined_soft')`. Sin esto, todo ticket `accepted` histórico se vería `rejected` al desplegar. (Verificado: `no_pitch`/`no_offer` quedan fuera correctamente — son estados donde el agente nunca llegó al Close stage, confirmado en `utils/dashboard/metrics.ts:11-12` y `flow.ts:107`).
- `objection_handled_correctly boolean default null` — nullable, sin backfill.
- Comentario en la migración aclarando que no es lo mismo que `clients.objections_handled` (plural, jsonb, qué scripts se usaron — concepto distinto).
**Paso 2 — Regenerar tipos:** `supabase gen types typescript --local > utils/supabase/database.types.ts`.
**Paso 3 — `utils/upsell/verification.ts` (nuevo, función pura).** Test primero: `__tests__/upsell-verification-status.test.ts` — tabla de verdad completa (las 3× combinaciones, especialmente `full_flow_completed=false` ganando sobre todo, y `objection_handled_correctly=false` ganando sobre `initiation_type` seteado). Sin dependencias de DB — puede desarrollarse en paralelo al Paso 1/2.
**Paso 4 — `utils/upsell/payloads.ts`.** Test primero: extender `__tests__/upsell-payloads.test.ts` (`outcome:"accepted"`→true, `outcome:"declined"`→true, `outcome:null`→false). Código: agregar `full_flow_completed: boolean` a `TicketPayload` (línea 66) y `full_flow_completed: state.outcome !== null` en `buildTicketPayload` (línea ~91).
**Paso 5 — `app/(protected)/upsell-evaluator/actions.ts` (3 cambios).** Test primero: extender `__tests__/upsell-actions.test.ts` con casos para `createTicket`/`updateTicket` (`full_flow_completed: true/false` persiste, valor no-boolean se ignora — mismo patrón que `open_tech_ticket`). Código:
1. `full_flow_completed?: unknown;` en `CreateTicketInput` (~línea 191) y `UpdateTicketInput` (~línea 292).
2. En `createTicket` (~línea 238): `if (typeof input.full_flow_completed === "boolean") payload.full_flow_completed = input.full_flow_completed;`
3. En `updateTicket` (~línea 326): mismo patrón sobre `update`.
**Paso 6 — `setObjectionHandled` en `app/(protected)/dashboard/actions.ts`.** Test primero: `__tests__/dashboard-objection-handled.test.ts`, clon de `__tests__/dashboard-initiation-type.test.ts` (permisos admin/owner, guard `state==='accepted'`, acepta `true`/`false`/`null`, `revalidatePath` solo en éxito). Código: copiar `setInitiationType` (líneas 72-107) literal, actualizando `objection_handled_correctly`.
**Paso 7 — Query de `app/(protected)/dashboard/page.tsx`.** Extender el `.select(...)` (líneas 124-130) con `, full_flow_completed, objection_handled_correctly`. Sin test dedicado — se valida vía Paso 10 y el E2E manual.
**Paso 8 — `StatusBadge` (design system, Jest).** Test primero: extender `StatusBadge.test.tsx` (`.forEach` + casos dedicados `accepted`/`rejected`). Código: `TStatus` en `index.tsx:4` +2 valores; `style.module.scss`: `.accepted` idéntico a `.active` (`--fiber-green-light`/`--fiber-green-sold`, decisión ya tomada en el doc original), `.rejected` con `--fiber-red-light`/`--fiber-red` (tokens confirmados, `tokens.css:41-42`). Extender `StatusBadge.stories.tsx` con 2 `Story` nuevos.
**Paso 9 — `TicketVerificationModal` (componente nuevo, design system).** Plantilla: `AttendanceQuickModal` (portal+`mounted`, foco inicial vía rAF — sin focus-trap real de librería, Escape-to-close, backdrop click-to-close, `role="dialog" aria-modal="true"`, Tailwind inline sin `.module.scss`). Contenido: Check 1 solo-lectura (`full_flow_completed`), Check 2 dos `ToggleChip` (`initiation_type`, movido desde la fila), Check 3 **un solo `ToggleChip` binario** (`objection_handled_correctly`, decisión confirmada). Test primero: `.test.tsx` (no renderiza si `open=false`, Escape cierra, backdrop click cierra, callbacks de toggle, `role="dialog"` presente) + `.test.cy.tsx` (smoke mount) + `.stories.tsx`. Exportar en `packages/design-system/src/components/index.tsx`.
**Paso 10 — Reescribir `components/dashboard/AcceptedTicketsVerification.tsx`.** Test primero: reescribir `__tests__/accepted-tickets-verification.test.tsx` (columna Status con `StatusBadge` correcto por fixture, kebab `IconButton` abre el modal con los datos del ticket, callbacks del modal llaman `setInitiationType`/`setObjectionHandled` con args correctos incluyendo reset a null, `router.refresh()` solo en éxito; quitar los tests viejos de chips inline en la fila). Código: `AcceptedTicket` type +2 campos; quitar `ClassificationCell` y la columna `initiation_type` (líneas 73-106, 151-166); agregar columna `status` y columna `actions` (kebab `IconButton`, `aria-label` con nombre del agente); estado local `managedTicket` (patrón `UserTable.tsx:35,82-98` con `UserManageDrawer`).
**Paso 11 — Verificación de build:** `npm run lint && npm run test` (raíz, Vitest), `npm test` dentro de `packages/design-system` (Jest), `tsc --noEmit`.
## Riesgo principal
El backfill del Paso 1 es no-negociable: si no corre en el ambiente real, tickets `accepted` preexistentes se ven `rejected` en el dashboard sin que ningún test lo detecte (los tests solo cubren la función pura y el action, no el estado real de la tabla).
## Archivos críticos
- `utils/upsell/payloads.ts`, `utils/upsell/verification.ts` (nuevo)
- `app/(protected)/upsell-evaluator/actions.ts`
- `app/(protected)/dashboard/actions.ts`, `app/(protected)/dashboard/page.tsx`
- `components/dashboard/AcceptedTicketsVerification.tsx`
- `packages/design-system/src/components/StatusBadge/*`, `packages/design-system/src/components/TicketVerificationModal/*` (nuevo, plantilla `AttendanceQuickModal`)
- `supabase/migrations/<nuevo>.sql`, `utils/supabase/database.types.ts` (regenerado)
## Verificación end-to-end (no solo tests en verde)
1. Aplicar la migración localmente, confirmar en DB que tickets `accepted` preexistentes quedaron con `full_flow_completed=true` (backfill).
2. `npm run dev`, login como agente, completar un flujo de Upsell Evaluator hasta Accept/Decline → confirmar `full_flow_completed=true` en DB. Repetir abandonando el flujo antes del Close stage → confirmar `false`.
3. Login como admin/owner, `/dashboard` → confirmar que el ticket abandonado muestra `rejected` (sin importar qué se marque en el modal — precedencia), el ticket recién aceptado muestra `pending`.
4. Abrir el modal (kebab `IconButton`), marcar `initiation_type` + objeción=true → confirmar `accepted` sin recargar. Marcar objeción=false → confirmar vuelve a `rejected`. Resetear objeción a null → confirmar vuelve a `pending`.
5. Recargar la página completa (F5, no solo `router.refresh()`) → confirmar que persiste (valida el `.select()` extendido + `revalidatePath`).
6. Confirmar que un usuario con rol `user` no puede invocar `setObjectionHandled`/`setInitiationType` (rechazo por rol).
7. Revisar en Storybook las nuevas stories de `StatusBadge` (Accepted/Rejected) y del `TicketVerificationModal`.
## Nota no bloqueante
La rama remota `agente-gfiber-648-admin-verification` ya está completamente contenida en `dev` (mismos commits) — está obsoleta, recomendable borrarla en algún momento fuera de este trabajo.
@@ -0,0 +1,164 @@
# Fix: GFIBER-663 — Attendance horizontal scrollbar disappears on desktop PCs
## Context
GFIBER-663 (Crítica, To Do, assigned to Alejandro Lembke): "When an admin tries to update the attendance, the horizontal scrollbar disappears. This feature works perfectly on laptops (using the trackpad), but when users are on a desktop PC using a standard mouse and want to scroll horizontally, the bar vanishes, making it impossible to navigate."
**Root cause (confirmed by code exploration):** the Attendance Grid at `/dashboard` renders through `app/(protected)/dashboard/page.tsx``components/dashboard/AttendanceGrid.tsx` (thin stateful wrapper) → the real markup/CSS in `packages/design-system/src/components/AttendanceGrid/index.tsx` + `style.module.scss`. The scrollable container (`.scroll`, `overflow-x/y: auto`) has **zero** scrollbar styling anywhere in the app or design-system package (confirmed by repo-wide grep — no `::-webkit-scrollbar`, `scrollbar-width`, `scrollbar-color`, or `scrollbar-gutter` rules exist, and no JS toggles scrollbar visibility). The horizontal bar is therefore the raw browser/OS-default scrollbar. On systems with auto-hiding overlay scrollbars (the Windows "Automatically hide scroll bars" setting, on by default in many configurations, plus trackpad-first macOS setups), that bar only flashes during active scroll/hover and never renders as a persistent, draggable element. Trackpad users pan via a two-finger gesture and never need to see it; desktop-mouse users have no default horizontal-scroll gesture and depend on seeing/dragging a visible bar that the app never forces to appear.
Two sibling design-system components (`DataTable`, `UserTable`) share the identical un-styled `overflow-x: auto` idiom and have the same latent bug if ever rendered wide enough to overflow — tracked as a fast-follow, not fixed here (see Scope below).
## Root cause verified in code
`packages/design-system/src/components/AttendanceGrid/style.module.scss:57-62`:
```scss
.scroll {
position: relative;
max-height: 520px;
overflow-x: auto;
overflow-y: auto;
}
```
No scrollbar-appearance rules follow it. Sticky columns inside `.scroll`: `.agentHead`/`.agentCell` (`left: 0`, 200px wide) and `.summaryHead`/`.summaryCell` (`left: 200px`, 88px wide) — line 81-182. The decorative `.scrollHint` right-edge fade (lines 199-207) only signals horizontal overflow; it is not a real scrollbar affordance and is unconditionally rendered regardless of actual overflow.
## Fix
**Chosen mechanism:** style the existing `.scroll` container with both standards-track scrollbar properties — no new elements, no JS, no `scrollbar-gutter` (rejected: it only reserves layout space for a scrollbar that already renders; it does not force an OS-overlay scrollbar to become persistently visible/draggable, and it would add an unconditional gutter that isn't accounted for in the precise 200px sticky-offset math).
- **Firefox:** `scrollbar-width: thin` + `scrollbar-color: <thumb> <track>` — setting `scrollbar-color` alone switches Firefox off the OS overlay style onto a classic, always-rendered scrollbar.
- **Chromium/WebKit (Chrome, Edge, Safari — the reported Windows/mouse environment):** declaring any `::-webkit-scrollbar` rule flips these engines from native OS overlay/auto-hide scrollbars to a classic, always-reserved, always-visible scrollbar, independent of the user's OS scrollbar setting. This is the actual fix for the reported bug.
Both axes get styled together (WebKit doesn't let the "become classic" trigger apply to only one axis without declaring size for both) — this is a deliberate, low-risk side effect that also fixes vertical-scrollbar visibility consistency; call it out in the PR description, not scope creep.
**Colors — existing tokens only** (verified present in `packages/design-system/src/tokens/tokens.css`, both `:root` and `.dark`): thumb `var(--fiber-gray-700)` (light `#5f6368`, ~6.05:1 — already the project's validated safe replacement for the previously-blocked `--fiber-gray-500`), thumb hover/active `var(--fiber-gray-900)`, track/corner `var(--fiber-gray-100)`. Dark mode needs no separate override block — the class-based `.dark` token overrides cascade automatically.
**Scope decision:** fix `AttendanceGrid` only in this change. Repo-wide grep confirmed zero `@use`/`@import` exist across any DS component's SCSS today — every stylesheet is self-contained by convention. Introducing a new shared-partial/mixin pattern would be the first use of that mechanism and needs its own validation across three build pipelines (Next `transpilePackages`, Storybook webpack, Cypress component-test webpack) — not worth the risk under a Crítica-priority hotfix. File a fast-follow ticket to mechanically copy the same rule block into `DataTable`/`UserTable`'s `.wrap` selector.
**`.scrollHint`:** keep unchanged — it signals "there's more to scroll" (a different affordance than "you can drag here") and removing it would force rewriting an existing passing Cypress test for no benefit in a hotfix.
### Exact CSS — `packages/design-system/src/components/AttendanceGrid/style.module.scss`
Append after the existing `.scroll { ... }` block (lines 57-62):
```scss
.scroll {
position: relative;
max-height: 520px;
overflow-x: auto;
overflow-y: auto;
// Force a persistently visible, styled scrollbar instead of relying on the
// browser/OS default. Auto-hiding overlay scrollbars (Windows "Automatically
// hide scroll bars", trackpad-first macOS) never render a draggable bar for
// desktop-mouse users, who have no horizontal-scroll gesture. GFIBER-663.
scrollbar-width: thin;
scrollbar-color: var(--fiber-gray-700) var(--fiber-gray-100);
}
// Chromium/WebKit: declaring ::-webkit-scrollbar switches the element from
// native OS overlay scrollbars to a classic, always-reserved, always-visible
// scrollbar — the actual fix for the reported Windows/Chrome bug.
.scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scroll::-webkit-scrollbar-track {
background-color: var(--fiber-gray-100);
}
.scroll::-webkit-scrollbar-thumb {
background-color: var(--fiber-gray-700);
border-radius: var(--radius-input);
}
.scroll::-webkit-scrollbar-thumb:hover,
.scroll::-webkit-scrollbar-thumb:active {
background-color: var(--fiber-gray-900);
}
.scroll::-webkit-scrollbar-corner {
background-color: var(--fiber-gray-100);
}
```
## Tests (TDD — write these first, they should fail against current CSS, then pass after the fix)
**Do not touch `AttendanceGrid.test.tsx` (Jest).** Verified: `packages/design-system/jest.config.js` maps `.scss` to `identity-obj-proxy`, so Jest/RTL never loads real CSS — `getComputedStyle` assertions there would be meaningless (pass/fail independent of the actual fix).
**Add to `AttendanceGrid.test.cy.tsx`**, inside/after the existing `describe('AttendanceGrid (component) — scroll & sticky'` block (reuses `FULL_MONTH`/`fullGet` fixtures already defined there, lines 54-64) — Cypress runs real Chromium with the real compiled SCSS, so `getComputedStyle` reflects the actual fix:
1. Assert `::-webkit-scrollbar` width/height (10px) and a non-transparent thumb background — proves the classic scrollbar is forced.
2. Assert `overflowX`/`overflowY` remain `auto` — proves the fix didn't change overflow behavior.
3. New vertical-scroll regression (none of today's fixtures force vertical overflow — today's `AGENTS` array only has 2 entries): add a local ~15-agent fixture, mount, assert `scrollHeight > clientHeight`, scroll to bottom, confirm the last agent is visible AND the agent-name header (`.agentHead`) stays pinned — proves the scrollbar fix didn't break sticky columns or vertical scroll.
**Add to `AttendanceGrid.stories.tsx`:** a `DenseGridBothScrollbars` story (15 agents × the existing 31-day `FULL_MONTH`, already defined above `FullMonthScroll`) forcing both scrollbars simultaneously — the manual-QA scenario, following the same precedent as the `BarChart` `DenseDailySeries` regression story used for a prior overflow hotfix in this project.
## Manual verification (cannot be automated — no headless engine validates real OS scrollbar chrome/paint)
- Windows 10/11 + Chrome/Edge with "Automatically hide scroll bars in Windows" **on** (the exact reported condition) — confirm the bar is persistently visible without hover, and mouse-draggable.
- macOS Safari/Chrome with trackpad-based auto-hide scrollbars — confirm no regression for the working trackpad case.
- Firefox (Windows/macOS) — confirm `scrollbar-color` renders and is draggable.
- Both light and dark Storybook themes (`DenseGridBothScrollbars` story) — confirm thumb/track contrast.
- Sticky agent/summary columns stay glued while horizontal-dragging in a real browser.
## Files to touch
- `packages/design-system/src/components/AttendanceGrid/style.module.scss` — the CSS fix (only production-code change).
- `packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.cy.tsx` — 3 new Cypress assertions (webkit-scrollbar, overflow-auto, vertical-scroll-unaffected).
- `packages/design-system/src/components/AttendanceGrid/AttendanceGrid.stories.tsx``DenseGridBothScrollbars` story.
- Not touched now, tracked as fast-follow: `packages/design-system/src/components/DataTable/style.module.scss`, `packages/design-system/src/components/UserTable/style.module.scss`.
## Verification (end-to-end)
1. `npm run test --workspace=@gfiber/design-system` (Jest) — unaffected, still green (no changes to `.test.tsx`).
2. Cypress component tests for `AttendanceGrid` — new assertions fail on `main` (pre-fix) and pass after the SCSS change.
3. `npm run storybook --workspace=@gfiber/design-system` → open `DenseGridBothScrollbars`, toggle light/dark — visually confirm persistent, styled scrollbar with adequate contrast, sticky columns intact.
4. Manual checklist above (Windows/Chrome is the priority — it's the reported environment) before closing the ticket as Crítica.
5. CI: `warden`/`harden`/`jorden` (design/a11y review) must pass — this change deliberately reuses only already-AA-validated tokens to avoid a repeat of past `jorden`-blocked contrast issues.
## Execution — delegated to a background agent (`background-orchestrator` skill)
Per the user's request, this plan is not executed directly in this session. It hands off to the `background-orchestrator` skill, following the same pattern already used for prior tickets in this repo (see Docmost `Agentes_Activos`: `agente-gfiber-649-pending`, `agente-gfiber-658-is-enabled`, etc.):
1. Create a dedicated git worktree + branch (e.g. `agente-gfiber-663-attendance-scrollbar`) off `dev`, per the project's branch-per-feature convention.
2. Launch an autonomous agent in tmux inside that worktree, briefed with this plan verbatim: write the failing Cypress tests first (TDD — they must fail against the current, un-styled `.scroll` CSS), then the SCSS fix, then the Storybook story, then run the full verification checklist above.
3. Monitor the agent, answer any blocking questions it raises.
4. On completion, the agent opens a PR to `dev` via the `aleleba-pr` skill — **never auto-merges**.
5. Record the agent's lifecycle (start, ticket, status, PR link) on the Docmost `Agentes_Activos` page, matching the existing history format.
This session's job ends at handing off a decision-complete plan; the orchestrator skill owns spawning, monitoring, and PR creation from here.
---
# Fix: docmost-context SessionStart hook not triggering
## Context
The original ask ("¿puedes leer de Jira el ticket GFIBER-663?") was a read-only lookup — already completed. `GFIBER-663` ("Admin Attendance page: Horizontal scrollbar disappears on desktop PCs", Crítica, To Do, assigned to Alejandro Lembke) was fetched via the `atlassian` MCP and reported above.
The user then raised a separate, real problem: the global `docmost-context` skill — which is supposed to auto-load project context from Docmost at the start of every conversation via a `SessionStart` hook — appears to never fire. This matters because `CLAUDE.md` and the `docmost-context` skill both assume this happens automatically; if it silently doesn't, every session starts without Docmost context and nobody notices until something is missed.
## Root cause
Found by inspecting `~/.claude/settings.json` and the hook script directly (read-only):
- `~/.claude/settings.json` correctly registers the hook for `SessionStart` on matchers `startup`, `resume`, and `clear`, pointing at `/home/aleleba/.claude/hooks/docmost-session-start.sh`.
- The script itself is correct — it detects the git project name and instructs the model to invoke the `docmost-context` skill.
- **The file is not executable**: `ls -la` shows `-rw-rw-rw-` (no `x` bit) on `/home/aleleba/.claude/hooks/docmost-session-start.sh`. Claude Code's hook runner invokes the command path directly; without the execute bit, the OS refuses to run it (`Permission denied`), so the hook silently produces no output and the model never sees the instruction to load Docmost context. This fully explains "la skill global la estás ignorando, ¿el hook no se hace trigger?" — it's not a skill-detection issue, it's a filesystem permissions issue on the hook script.
## Fix
Single command, no code change:
```bash
chmod +x /home/aleleba/.claude/hooks/docmost-session-start.sh
```
This is a global (`~/.claude`) file, not part of the `gfiber-pilot-extension` repo — no commit/PR involved.
## Verification
1. `ls -la /home/aleleba/.claude/hooks/docmost-session-start.sh` → confirm `x` bits present (e.g. `-rwxrwxrwx` or `-rwxr-xr-x`).
2. Start a fresh Claude Code session (or `/clear`) inside a git repo → confirm the `[docmost-context] Nueva conversación detectada...` message appears in the hook output, and that the model subsequently invokes the `docmost-context` skill before responding to the first user message.
3. Optionally run the hook manually to sanity-check output before relying on the harness: `bash /home/aleleba/.claude/hooks/docmost-session-start.sh` from within this repo — should print the instructional block referencing `gfiber-pilot-extension`.
@@ -0,0 +1,69 @@
# Plan: Etapa 0 — Limpieza técnica (Ro-ut v2.1.5)
## Contexto
La documentación de Ro-ut identifica tareas de limpieza antes de crecer el proyecto. Tras explorar el código, la Etapa 0 cubre cuatro cambios concretos que dejan el repo limpio y alineado con el estado real de v2.1.5.
---
## Tareas
### 1. Eliminar variable `GRAPHIQL` de `config/index.ts`
**Por qué:** `GRAPHIQL` está definida en dos lugares pero nunca se referencia en ningún archivo del codebase. El playground usa `PLAYGROUND_GRAPHQL`. Es dead code que confunde.
**Archivo:** [config/index.ts](config/index.ts)
Eliminar:
- Línea 22 en `deFaultValues`: `GRAPHIQL: 'false',`
- Línea 49 en `config`: `GRAPHIQL: process.env.GRAPHIQL === 'true' ? true : false,`
> Nota: el typo `bussiness_name` documentado en la Etapa 0 original no existe en ningún archivo `.ts`/`.js` — los únicos archivos SQL afectados ya tienen el rename correcto en migrations. No hay nada que corregir en el código TypeScript.
---
### 2. Actualizar README.md
**Por qué:** el README está desactualizado — todavía dice "backed by MariaDB + MongoDB" (arquitectura anterior a v2.1.0) y lista variables de entorno de MongoDB y MariaDB que ya no existen.
**Archivo:** [README.md](README.md)
Cambios necesarios:
- Reemplazar "backed by MariaDB + MongoDB" por "backed by **PostgreSQL**"
- Eliminar el bloque de variables `# MongoDB` y `# MariaDB` del ejemplo de configuración
- Agregar el bloque `# PostgreSQL` con las variables correctas:
```
# PostgreSQL
HOST_PG=
PORT_PG=
DB_PG=
USER_PG=
PASSWORD_PG=
SSL_CA_PG=
SSL_CERT_PG=
SSL_KEY_PG=
```
- En la sección Tests, corregir "requires MariaDB for integration asserts" → "requires a running PostgreSQL instance"
---
### 3. Bump de versión a 2.1.5
**Archivos:** [package.json](package.json) y [package-lock.json](package-lock.json)
- `package.json`: cambiar `"version": "2.1.4"``"version": "2.1.5"`
- `package-lock.json`: actualizar los dos campos `"version"` que hacen referencia a `2.1.4` (el root del lockfile y el entry del propio paquete)
---
## Ejecución
Este trabajo lo ejecuta un **agente de background** lanzado con la skill global de orquestador (`background-orchestrator`). El agente crea su propia rama, hace los cambios, abre un PR y documenta el trabajo en Docmost.
## Verificación
```
npm run lint && npm run build
```
CI debe quedar verde (unit + integration + e2e). El playground sigue funcionando porque depende de `PLAYGROUND_GRAPHQL`, no de `GRAPHIQL`.
@@ -0,0 +1,453 @@
# Upsell Verification — 3-check validation + kebab modal + status column
## Context
The admin/owner dashboard already has an **Upsell Verification** table (`components/dashboard/AcceptedTicketsVerification.tsx`) built for GFIBER-648: it lists accepted upsell tickets and lets an admin classify each as Agent-Initiated / Customer-Initiated via two inline `ToggleChip`s, persisted to `upsell_tickets.initiation_type`.
The business now wants a **valid offer** to require three checks, and wants the table to surface one overall status instead of a bare classification widget:
1. **Went through the full evaluation form** — captured automatically the instant the agent clicks Accept/Decline in the Upsell Evaluator (Stage 4/Close). Not currently tracked anywhere — confirmed absent in `utils/upsell/flow.ts` and `database.types.ts`.
2. **Agent- vs Customer-Initiated** — already built (GFIBER-648, `initiation_type`). Requirement here is UI relocation: move it off the table row into a modal opened from a new kebab ("⋯") button.
3. **Objection handling** — if the customer objected, did the agent try once more to close? Not currently tracked (there's an `ObjectionDrawer` that logs *which* scripts were used into `clients.objections_handled`, but nothing about whether the agent re-attempted the close). Needs a new admin-verified field.
4. The table's **Status** column must show `pending` (still needs review), `accepted` (reviewed, all three checks pass), or `rejected` (reviewed, something failed) — there is no such column today; the closest analog is the implicit "chip selected or not."
Clarified with the user during planning (AskUserQuestion):
- Objection check is a simple two-state admin verification (no explicit "N/A — no objection" option); leaving it unreviewed = pending.
- If the automatic "full flow completed" flag is `false`, the ticket is **rejected automatically** regardless of the other two checks — this is a data-integrity backstop, not something an admin toggles.
- `rejected` is always a **computed** result of the three checks — no manual "Reject" override button.
## Design
### 1. Database — new migration `supabase/migrations/<timestamp>_add_verification_fields_to_upsell_tickets.sql`
Add two nullable-where-appropriate columns to `upsell_tickets`, following the exact comment/context style used in `20260624000000_add_initiation_type_to_upsell_tickets.sql`:
```sql
alter table public.upsell_tickets
add column full_flow_completed boolean not null default false;
comment on column public.upsell_tickets.full_flow_completed is
'True the instant an agent records an Accept/Decline outcome via the normal '
'Upsell Evaluator Close stage. Automatic — never set by an admin. False here '
'on an otherwise-accepted ticket is a data-integrity signal (state reached '
'''accepted'' without the normal flow) and alone forces verification status '
'to rejected.';
-- Backfill: every ticket already in a terminal outcome state necessarily went
-- through the existing accept/decline step (the only code path that sets
-- state to accepted/declined_*). Without this, every historical accepted
-- ticket would flip to "rejected" the moment this ships.
update public.upsell_tickets
set full_flow_completed = true
where state in ('accepted', 'declined_hard', 'declined_soft');
alter table public.upsell_tickets
add column objection_handled_correctly boolean default null;
comment on column public.upsell_tickets.objection_handled_correctly is
'Admin-verified: did the agent attempt to close once more after a customer '
'objection? NULL = not yet reviewed. Only meaningful when state = ''accepted''. '
'Write access restricted to admin/owner at the application layer '
'(setObjectionHandled Server Action), same pattern as initiation_type.';
```
**Critical: the backfill UPDATE is not optional** — without it, shipping this migration silently reclassifies every already-accepted, already-verified ticket as `rejected`.
Apply to DEV first (`supabase link --project-ref vshcimexazocttjmyrqy && supabase db push`), confirm, then regenerate types (`supabase gen types typescript --linked > utils/supabase/database.types.ts`). PROD push happens later, same as the still-pending attendance RLS promotion — do not push to `hwxkegbdnbrzvekrqxbd` as part of this feature branch.
### 2. Persist "full flow completed" automatically — `utils/upsell/payloads.ts`
`buildTicketPayload()` (lines 74-92) already derives `state` from `currentTicketState(state)`. Add one field computed the same way, from the same source of truth (`state.outcome`):
```ts
export interface TicketPayload {
// ...existing fields
full_flow_completed: boolean;
}
export function buildTicketPayload(...): TicketPayload {
return {
// ...existing fields
full_flow_completed: state.outcome !== null,
};
}
```
No changes needed in `app/(protected)/upsell-evaluator/actions.ts``createTicket`/`updateTicket` already spread whatever `buildTicketPayload` returns into the insert/update call. This satisfies requirement #1 exactly as specified: set the instant Accept/Decline is clicked (the `StageClose.tsx` `useEffect` that fires `onPersistTicket()` on `state.outcome` change), with no admin involvement.
### 3. New Server Action — `app/(protected)/dashboard/actions.ts`
Add `setObjectionHandled`, a near-exact mirror of the existing `setInitiationType` (lines 72-107): same `requireRole(current?.profile, "admin", "owner")` gate, same "only `state === 'accepted'`" guard, same `revalidatePath("/dashboard")`.
```ts
export type ObjectionResult = { ok: true } | { error: string };
export async function setObjectionHandled(
ticketId: string,
value: boolean | null,
): Promise<ObjectionResult> {
// identical shape to setInitiationType, updating objection_handled_correctly
}
```
### 4. Status derivation — new `utils/upsell/verification.ts`
Pure, unit-testable function — no DB round-trip, reused by both the table and the modal:
```ts
export type VerificationStatus = "pending" | "accepted" | "rejected";
export function deriveVerificationStatus(ticket: {
full_flow_completed: boolean;
initiation_type: UpsellInitiationType | null;
objection_handled_correctly: boolean | null;
}): VerificationStatus {
if (!ticket.full_flow_completed) return "rejected";
if (ticket.objection_handled_correctly === false) return "rejected";
if (ticket.initiation_type === null || ticket.objection_handled_correctly === null) return "pending";
return "accepted";
}
```
### 5. Query update — `app/(protected)/dashboard/page.tsx` (~line 124-130)
Extend the verification table's `select` to pull the two new columns:
```ts
.select("id, created_at, plan_sold, case_id, initiation_type, full_flow_completed, objection_handled_correctly, profiles!created_by(display_name, email)")
```
### 6. Design system — extend `StatusBadge`
`packages/design-system/src/components/StatusBadge/index.tsx` currently supports `'active' | 'pending' | 'expired' | 'disabled'`. Add `'accepted' | 'rejected'`:
- `.accepted` → reuse `--fiber-green-light` / `--fiber-green-sold` (same as `.active`).
- `.rejected``--fiber-red-light` / `--fiber-red` (tokens already exist in `packages/design-system/src/tokens/tokens.css:41-42`, currently unused by any badge variant — no new token needed).
- Update `StatusBadge.stories.tsx` and `StatusBadge.test.tsx`/`.test.cy.tsx` to cover the two new variants (DS-first convention: every component change ships story + Jest + Cypress).
### 7. New design-system component — `TicketVerificationModal`
New directory `packages/design-system/src/components/TicketVerificationModal/`, modeled directly on `AttendanceQuickModal` (portal via `createPortal`, focus-trap on open, Escape-to-close, `role="dialog" aria-modal="true"`, Tailwind-in-component styling — no separate `.module.scss`). This is the closest existing template and is otherwise unused in app code today.
Props: `{ open, onClose, ticket: { caseId, agentName, fullFlowCompleted, initiationType, objectionHandled }, onSetInitiation: (v) => void, onSetObjection: (v) => void, savingInitiation: boolean, savingObjection: boolean }`.
Content:
- Header: agent name + case ID + a `StatusBadge` showing the ticket's current derived status.
- **Check 1 (read-only)**: "Completed full evaluation flow" ✅/⚠️ from `fullFlowCompleted` — informational, no admin control (per the Q2 clarification, it never needs a manual toggle).
- **Check 2**: two `ToggleChip`s, "Agent-Initiated" / "Customer-Initiated" — same instant-apply, click-active-to-reset behavior as today's `ClassificationCell` (moved here verbatim, not reinvented).
- **Check 3**: two `ToggleChip`s, "✅ Insisted after objection" / "❌ Did not insist" — identical tri-state mechanics to Check 2, backed by `objection_handled_correctly`. Using the same `ToggleChip` pattern (rather than the DS `Checkbox` component the user's wording literally suggested) because the tri-state "not yet reviewed / pass / fail" semantics need a "click-to-reset-to-null" affordance that a native checkbox doesn't have — `ToggleChip` already provides it and is the established pattern for `initiation_type`. Flag this choice to the user after implementation in case they specifically want a literal checkbox instead.
- Footer: Close button.
Ships with `TicketVerificationModal.stories.tsx`, `.test.tsx`, `.test.cy.tsx` (mirror `AttendanceQuickModal`'s three files) and gets exported from `packages/design-system/src/components/index.tsx`.
### 8. Table changes — `components/dashboard/AcceptedTicketsVerification.tsx`
- Extend `AcceptedTicket` type with `full_flow_completed: boolean` and `objection_handled_correctly: boolean | null`.
- Remove the inline `initiation_type` column and `ClassificationCell` (logic moves into the modal).
- Add a **Status** column: `<StatusBadge status={deriveVerificationStatus(row)} />`.
- Add an **Actions** column: existing DS `IconButton` as the kebab trigger (`icon="⋯"`, `aria-label="Verify {agentName}'s ticket"`) — reuses the component already in the DS, no new button primitive needed. Click sets local `managedTicket` state and opens `TicketVerificationModal`, same local-state pattern `UserTable.tsx` uses for its own kebab → `UserManageDrawer`.
- Wire `onSetInitiation`/`onSetObjection` to `setInitiationType`/`setObjectionHandled` via the existing `startTransition` + `router.refresh()` pattern (`handleClassify`, lines 115-125) — add a sibling `handleObjection`.
## Files touched (summary)
| File | Change |
|---|---|
| `supabase/migrations/<ts>_add_verification_fields_to_upsell_tickets.sql` | new columns + **backfill** |
| `utils/supabase/database.types.ts` | regenerated after DEV push |
| `utils/upsell/payloads.ts` | `full_flow_completed` in `buildTicketPayload` |
| `utils/upsell/verification.ts` | new — `deriveVerificationStatus` |
| `app/(protected)/dashboard/actions.ts` | new `setObjectionHandled` |
| `app/(protected)/dashboard/page.tsx` | extend verification query select |
| `packages/design-system/src/components/StatusBadge/*` | add `accepted`/`rejected` variants |
| `packages/design-system/src/components/TicketVerificationModal/*` | new component + story + tests |
| `packages/design-system/src/components/index.tsx` | export new modal |
| `components/dashboard/AcceptedTicketsVerification.tsx` | status column, kebab, modal wiring |
## Tests (TDD — write these first, red before green)
- `__tests__/utils/upsell/verification.test.ts` — new, full truth table for `deriveVerificationStatus` (all 3×pending/true/false combinations, especially `full_flow_completed=false` overriding everything else to `rejected`).
- `__tests__/dashboard-objection-verification.test.ts` — new, mirrors `__tests__/dashboard-initiation-type.test.ts` structure exactly (permission checks, accepted-only guard, reset-to-null, revalidatePath, DB error handling) for `setObjectionHandled`.
- `__tests__/accepted-tickets-verification.test.tsx` — rewrite the chip-interaction blocks: assert the Status column renders the right `StatusBadge` per fixture, assert the kebab button opens the modal, assert modal callbacks call the two actions with the right ticket id.
- `packages/design-system/src/components/StatusBadge/StatusBadge.test.tsx` + `.test.cy.tsx` — add `accepted`/`rejected` cases.
- `packages/design-system/src/components/TicketVerificationModal/TicketVerificationModal.test.tsx` + `.test.cy.tsx` — focus trap, Escape-to-close, chip toggle callbacks, read-only Check-1 rendering.
- Extend whatever test currently covers `buildTicketPayload` (check `__tests__/upsell-actions.test.ts` or an existing payloads test) to assert `full_flow_completed` is `true` only when `outcome !== null`.
## Verification (end-to-end, not just green tests)
1. `npm run lint && npm run test` — all new/changed suites green.
2. Apply migration to DEV (`supabase db push` against `vshcimexazocttjmyrqy`), regenerate types, confirm `npm run build`/`tsc` still clean.
3. `npm run dev`, log in as an admin/owner, open `/dashboard`, confirm the Upsell Verification table shows a Status pill for existing accepted tickets (should read `pending` for anything not yet classified, given the backfill only sets `full_flow_completed`).
4. Click the kebab on a row, confirm the modal opens with the 3 checks, toggle initiation type and objection chips, confirm the Status pill flips to `accepted` once both are set and objection=true, and to `rejected` when objection is set to false.
5. Reload the page (`router.refresh()` isn't enough proof) to confirm persistence survived a real fetch, not just optimistic client state.
## Git workflow
Branch `feat/upsell-verification-checks` from `dev` (dashboard features branch from/merge to `dev` per `docs/knowledge/conventions.md` and this repo's established pattern for GFIBER-648-style work), PR → `dev`.
---
# Plan 2 — Manual state override (Accepted ⇄ Rejected) + audit history
## Context
Same kebab menu / modal as Plan 1. The business wants admins/owners to be able to override `upsell_tickets.state` (the outcome an agent recorded in Stage 4/Close of the Upsell Evaluator — `accepted` or `declined_hard` in practice) after the fact, **with an immutable audit trail of every change**.
Two Explore agents mapped the blast radius and the repo's existing conventions before this was designed:
- **Every KPI/metric/table in this app re-reads `upsell_tickets.state` live on each request**`buildDashboardMetrics` (`utils/dashboard/metrics.ts`), the `InitiationRateTile` rate calculations, the Session History view, and the XLSX export all recompute from the current `state` value with no cache or materialized rollup. A manual override propagates everywhere automatically on next load — no separate invalidation needed.
- **Cooloff/eligibility is live-derived, not read from a log.** `lookupClient()` reads only the client's single most-recent ticket and calls `computeEligibility()` on its `state`. If the ticket being reclassified is that client's latest, the client's cooloff window changes immediately on the next Upsell Evaluator lookup — a real business-logic side effect worth calling out to whoever approves this, not just a technical footnote. `eligibility_checks` itself is a write-only log (`logEligibilityCheck()`), never read back — its historical rows are frozen snapshots and will not reflect a later state edit; that's expected, matching how the table already behaves for its own purpose.
- **No RLS policy anywhere is conditioned on `state`** (confirmed directly against `supabase/migrations/20260603120000_create_clients_and_upsell_tickets.sql``create policy "Authenticated can update upsell_tickets"` is unconditional). Same pattern as `initiation_type`: the real access control has to live at the application layer.
- **Real gap found and confirmed in scope for this feature (per user's answer):** nothing today clears `initiation_type` when a ticket's `state` moves away from `accepted`. An admin reclassifying accepted→declined leaves the old classification sitting on the row; if the ticket is later reclassified back to `accepted`, the stale classification resurfaces unreviewed. This feature closes that gap.
- **`eligibility_checks` is the one precedent for an audit-style table** in this repo (`supabase/migrations/20260612120000_create_eligibility_checks.sql`): a sibling table, one row per event, `checked_by`/`checked_at`, **no UPDATE/DELETE RLS policies at all** (true immutability, not just convention), indexed `(client_id, checked_at desc)`. This is the template for the new history table.
- **No transaction/RPC mechanism exists anywhere in the app layer** — multi-write bookkeeping (e.g. `logEligibilityCheck`) is done as independent sequential awaited calls, accepting non-atomicity. Per the user's explicit choice below, this feature introduces the **first Postgres RPC function in this repo** to get real atomicity, since the entire point of this feature is a trustworthy audit trail.
**Decisions confirmed with the user:**
1. The override UI is a simple **Accepted ⇄ Rejected** toggle — `Rejected` maps to `declined_hard`, the only "declined" value the real flow ever produces (`declined_soft`/`no_pitch` exist in the enum but are unreachable from the current UI). Not a raw 6-value enum picker.
2. Reclassification is only allowed on tickets **already in a terminal state** (`accepted`, `declined_hard`, `declined_soft`, `no_offer`) — never on `in_progress` tickets. This isn't a way to "finish" a call an agent left open.
3. Changing `state` via this feature **resets `initiation_type` to null** (and `objection_handled_correctly` too, once Plan 1 ships that column) — forces a fresh admin review instead of carrying forward a stale classification.
4. Atomicity is handled by a **new `security definer` Postgres function**, not two sequential app-layer calls — the insert-history-row + update-state happen inside one Postgres transaction, so a failure on either side rolls back both.
## Design
### 1. New table + RPC — one migration
`supabase/migrations/<timestamp>_add_upsell_state_changes.sql`:
```sql
-- Audit trail for manual admin/owner overrides of upsell_tickets.state.
-- Modeled directly on eligibility_checks: one row per change, no UPDATE/DELETE
-- policies (true immutability), indexed for "history for this ticket, newest first".
create table public.upsell_state_changes (
id uuid primary key default gen_random_uuid(),
ticket_id uuid not null references public.upsell_tickets(id) on delete cascade,
changed_by uuid references public.profiles(id) on delete set null,
changed_at timestamptz not null default now(),
old_state public.upsell_state not null,
new_state public.upsell_state not null,
reason text
);
create index upsell_state_changes_ticket_idx
on public.upsell_state_changes (ticket_id, changed_at desc);
alter table public.upsell_state_changes enable row level security;
-- Read: admin/owner only (this is management/audit data, unlike eligibility_checks
-- which any authenticated agent may need to see for their own client lookups).
create policy "Admins can view state change history"
on public.upsell_state_changes for select
to authenticated
using (public.current_profile_role() in ('admin', 'owner'));
-- No insert/update/delete policy for any client role — all writes happen inside
-- change_upsell_ticket_state() below, which runs as a security definer and
-- bypasses RLS by design. This mirrors why eligibility_checks has no UPDATE/DELETE
-- policy at all: immutability enforced by the absence of a policy, not a check.
-- Single-transaction override: validates role + old-state + new-state, updates
-- upsell_tickets, resets the now-stale verification columns, and inserts the
-- audit row — all inside one Postgres transaction (a raised exception rolls
-- back everything, including the update).
create or replace function public.change_upsell_ticket_state(
p_ticket_id uuid,
p_new_state public.upsell_state,
p_reason text default null
)
returns public.upsell_tickets
language plpgsql
security definer
set search_path = ''
as $$
declare
v_role text;
v_old_state public.upsell_state;
v_result public.upsell_tickets;
begin
select role into v_role from public.profiles where id = auth.uid();
if v_role is null or v_role not in ('admin', 'owner') then
raise exception 'Only admin/owner may change ticket state';
end if;
if p_new_state not in ('accepted', 'declined_hard') then
raise exception 'New state must be accepted or declined_hard';
end if;
select state into v_old_state
from public.upsell_tickets
where id = p_ticket_id
for update; -- row lock for the duration of the transaction
if v_old_state is null then
raise exception 'Ticket not found';
end if;
if v_old_state not in ('accepted', 'declined_hard', 'declined_soft', 'no_offer') then
raise exception 'Only tickets in a terminal state can be reclassified';
end if;
if v_old_state = p_new_state then
raise exception 'Ticket is already in that state';
end if;
update public.upsell_tickets
set state = p_new_state,
initiation_type = null
-- , objection_handled_correctly = null -- add once Plan 1's column exists
where id = p_ticket_id
returning * into v_result;
insert into public.upsell_state_changes (ticket_id, changed_by, old_state, new_state, reason)
values (p_ticket_id, auth.uid(), v_old_state, p_new_state, p_reason);
return v_result;
end;
$$;
revoke execute on function public.change_upsell_ticket_state(uuid, public.upsell_state, text) from public;
grant execute on function public.change_upsell_ticket_state(uuid, public.upsell_state, text) to authenticated;
comment on function public.change_upsell_ticket_state is
'Admin/owner-only override of an upsell_tickets.state that has already reached '
'a terminal outcome. Atomically updates the ticket, clears the now-stale '
'initiation_type, and inserts an immutable audit row into upsell_state_changes. '
'This repo''s first security-definer RPC used for a multi-table write — see '
'plan doc for why sequential app-layer calls were rejected here specifically.';
```
Reuses `public.current_profile_role()`, the SECURITY DEFINER helper already introduced in `20260707130000_fix_profiles_update_policy_recursion.sql` for RLS-safe role checks — no new helper needed for the SELECT policy.
**Critical implementation detail:** this RPC must be called with the **request-scoped session client** (`utils/supabase/server.ts`'s `createClient()` — cookie-bound, carries the caller's JWT), **not** `createAdminClient()` (`utils/supabase/admin.ts`, service-role key). The function's internal `auth.uid()` check only resolves to the calling admin/owner when invoked through the user's own session; the service-role client has no user JWT context and `auth.uid()` would read as null inside the function, always failing the role check. This is a real deviation from how every other action in `dashboard/actions.ts` is written today (they all use `createAdminClient()` after an app-layer `requireRole` check) — call this out explicitly in code review since it's easy to copy-paste the wrong client.
### 2. Server Actions — `app/(protected)/dashboard/actions.ts`
```ts
export type ChangeStateResult = { ok: true; ticket: TicketRow } | { error: string };
export async function changeTicketState(
ticketId: string,
newState: "accepted" | "declined_hard",
reason?: string,
): Promise<ChangeStateResult> {
const current = await getCurrentUserWithProfile();
try {
requireRole(current?.profile, "admin", "owner"); // defense-in-depth; the RPC re-checks internally
} catch (e) {
if (e instanceof RoleError) return { error: e.message };
throw e;
}
const supabase = await createClient(); // session client — NOT createAdminClient()
const { data, error } = await supabase.rpc("change_upsell_ticket_state", {
p_ticket_id: ticketId,
p_new_state: newState,
p_reason: reason ?? null,
});
if (error) return { error: error.message }; // surfaces the RPC's raised exceptions verbatim (terminal-state guard, etc.)
revalidatePath("/dashboard");
return { ok: true, ticket: data };
}
export type StateHistoryEntry = {
id: string;
changed_at: string;
old_state: string;
new_state: string;
reason: string | null;
changed_by_name: string | null;
};
export async function getTicketStateHistory(ticketId: string): Promise<StateHistoryEntry[] | { error: string }> {
const current = await getCurrentUserWithProfile();
try {
requireRole(current?.profile, "admin", "owner");
} catch (e) {
if (e instanceof RoleError) return { error: e.message };
throw e;
}
const admin = createAdminClient(); // plain read, service-role fine here — no RLS-sensitive write
const { data, error } = await admin
.from("upsell_state_changes")
.select("id, changed_at, old_state, new_state, reason, profiles!changed_by(display_name, email)")
.eq("ticket_id", ticketId)
.order("changed_at", { ascending: false });
if (error) return { error: "Could not load state history." };
return (data ?? []).map((row) => ({
id: row.id,
changed_at: row.changed_at,
old_state: row.old_state,
new_state: row.new_state,
reason: row.reason,
changed_by_name: row.profiles?.display_name ?? row.profiles?.email ?? null,
}));
}
```
### 3. UI — the kebab becomes a menu with two options; History gets its own modal
Revised per user feedback: a single modal with 4 sections (3 checks + override + history) was getting dense. Split it — the kebab (⋯) opens a small menu, not a modal directly:
**New DS component — `KebabMenu`** (`packages/design-system/src/components/KebabMenu/`): wraps the existing `IconButton` (⋯ trigger, already in the DS — no new button primitive) and, on click, portal-renders a small anchored popover (`createPortal` to `document.body`, positioned from the trigger's `getBoundingClientRect()` — same portal/positioning technique `AttendanceQuickModal` already uses, chosen specifically because the Upsell Verification table can sit inside a horizontally-scrolling container and a non-portaled absolutely-positioned menu risks clipping, the same class of overflow bug already hit twice in this codebase for the Attendance grid, GFIBER-663/671). Closes on Escape, outside-click, or item selection. `role="menu"` / each item `role="menuitem"`, arrow-key navigation between items (standard menu a11y pattern). Props: `{ ariaLabel: string; items: { label: string; onSelect: () => void }[] }`. Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx` per DS-first convention.
**`components/dashboard/AcceptedTicketsVerification.tsx`** Actions column renders:
```tsx
<KebabMenu
ariaLabel={`Actions for ${agentName}'s ticket`}
items={[
{ label: "Verify", onSelect: () => setVerifying(ticket) },
{ label: "History", onSelect: () => setViewingHistory(ticket) },
]}
/>
```
Two independent pieces of local state (`verifying` / `viewingHistory`), each conditionally rendering its own modal — mirrors the existing `managed` state pattern in `UserTable.tsx`, just two of them instead of one.
**`TicketVerificationModal`** (Plan 1) keeps only the original three checks **plus** the new "Override Outcome" section — no history embedded:
- **"Override Outcome"** — only rendered when `ticket.state` is terminal (per decision #2); shows the current `OUTCOME_BADGE` (reused from `utils/upsell/outcomeDisplay.ts`, already used by `SessionHistoryView`/`TicketDetailView`) plus two buttons, "Mark Accepted" / "Mark Rejected", disabled on whichever is already current. A short optional reason `TextField` (DS component, already used elsewhere e.g. `StageClose.tsx`'s Order ID field) above the buttons — encouraged, not DB-enforced (column is nullable). Wires through the existing `startTransition` + `router.refresh()` pattern already used for the other two checks in this modal.
**New DS component — `TicketStateHistoryModal`** (`packages/design-system/src/components/TicketStateHistoryModal/`), opened by the "History" menu item, modeled on the same `AttendanceQuickModal` portal/focus-trap/Escape-to-close shell as `TicketVerificationModal`:
- Header: agent name + Case ID + current `OUTCOME_BADGE`.
- Body: fetches `getTicketStateHistory(ticketId)` on open, renders a `DataTable` (same component `SessionHistoryView` already uses): `changed_at` (existing `formatDate` pattern) → `old_state → new_state` (two `OUTCOME_BADGE`s with an arrow between them) → `changed_by_name` ("who made the change" — the user's explicit ask) → `reason`. Empty state: "No manual overrides yet."
- Footer: Close button.
- Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx`.
### 4. Side effects to surface in the PR description (not code changes, just visibility)
- Reclassifying a client's most-recent ticket changes that client's live cooloff/eligibility on the very next Upsell Evaluator lookup — flag this to whoever reviews/QAs the PR so it's an expected, not surprising, behavior.
- Dashboard KPIs, the Customer/Agent-Initiated rate tiles, Session History, and the XLSX export all shift immediately on next load/generation — no separate cache to bust.
## Files touched (summary)
| File | Change |
|---|---|
| `supabase/migrations/<ts>_add_upsell_state_changes.sql` | new table + RLS (select-only for admin/owner, no insert/update/delete policy) + `change_upsell_ticket_state()` RPC |
| `utils/supabase/database.types.ts` | regenerated — adds `upsell_state_changes` table types + the RPC's function signature |
| `app/(protected)/dashboard/actions.ts` | new `changeTicketState`, new `getTicketStateHistory` |
| `packages/design-system/src/components/KebabMenu/*` | new component — menu trigger + story + tests |
| `packages/design-system/src/components/TicketStateHistoryModal/*` | new component — history modal + story + tests |
| `packages/design-system` `TicketVerificationModal` (from Plan 1) | add the "Override Outcome" section only (no history) |
| `packages/design-system/src/components/index.tsx` | export `KebabMenu` + `TicketStateHistoryModal` |
| `components/dashboard/AcceptedTicketsVerification.tsx` | swap the lone kebab button for `KebabMenu` (Verify / History), two modal states instead of one |
## Tests (TDD)
- `__tests__/dashboard-state-change.test.ts` — new. Mock `.rpc()` (not `.from().update()`, since this action calls the RPC, not a table method) via the same `vi.hoisted` pattern as `dashboard-initiation-type.test.ts`: role gate (unauthenticated/agent rejected before the RPC is even called), success path returns `{ ok: true, ticket }`, RPC-raised-exception messages surfaced verbatim as `{ error }` (terminal-state guard, already-in-that-state guard, not-found), `revalidatePath` called only on success.
- `__tests__/dashboard-state-history.test.ts` — new, mirrors the read-only query pattern: role gate, empty history returns `[]`, maps `profiles!changed_by` join correctly, DB error returns `{ error }`.
- `packages/design-system/src/components/KebabMenu/KebabMenu.test.tsx` + `.test.cy.tsx` — new: opens on click, closes on Escape/outside-click/item-select, `role="menu"`/`"menuitem"` present, arrow-key navigation, calls the right item's `onSelect`.
- `packages/design-system/src/components/TicketStateHistoryModal/TicketStateHistoryModal.test.tsx` + `.test.cy.tsx` — new: renders fetched rows (old→new badges, `changed_by_name`, `reason`), empty state, focus trap/Escape-to-close (mirrors `AttendanceQuickModal.test.tsx`).
- Extend the `TicketVerificationModal` test suite (Plan 1) with: Override section hidden when `state` is non-terminal, buttons disabled on the currently-active state, clicking the inactive button calls `changeTicketState` with the right args.
- Extend `__tests__/accepted-tickets-verification.test.tsx`: the Actions column renders a `KebabMenu` with exactly two items, selecting "Verify" opens the verification modal, selecting "History" opens the history modal, and the two are mutually exclusive local states (opening one doesn't leave the other mounted).
- No Vitest can exercise the Postgres function body itself (no pgTAP in this repo) — the RPC's internal guards (role check, terminal-state check, atomicity) can only be verified by hitting a real DEV database. Call this out explicitly as a manual verification step, not a gap to "solve" with more unit tests.
## Verification (end-to-end)
1. `npm run lint && npm run test`.
2. Apply migration to DEV (`supabase db push` against `vshcimexazocttjmyrqy`), regenerate types.
3. **Manually exercise the RPC against real DEV Postgres** (not just through the UI) via `supabase db execute` or the SQL editor: confirm a non-admin `auth.uid()` gets rejected, confirm an `in_progress` ticket is rejected, confirm a same-state no-op is rejected, confirm a successful call leaves exactly one new row in `upsell_state_changes` AND the ticket's `state`/`initiation_type` updated together (query both tables after the call — this is the one guarantee unit tests can't give you).
4. `npm run dev`, as admin/owner: open the kebab menu on an accepted ticket → "Verify" → override to Rejected with a reason. Confirm the Status column/badge updates on `router.refresh()`. Re-open the kebab → "History" → confirm the new row shows old→new state, your name, and the reason. Re-open "Verify" → confirm `initiation_type` classification reset (Check 2 chips both unselected).
5. Confirm the cooloff side effect explicitly: pick a client whose most-recent ticket is the one just reclassified, run a fresh Upsell Evaluator lookup for that GAID, confirm the eligibility banner reflects the new state's cooloff window.
## Git workflow
Same branch as Plan 1 if built together (`feat/upsell-verification-checks`), or a follow-on branch `feat/upsell-state-override-history` from `dev` if shipped separately — this plan composes with but does not require Plan 1's three-check columns to exist first (only the modal component needs to exist to host the new section; the RPC can ship independently and add the Override section to a plain modal if Plan 1 hasn't landed yet).
@@ -0,0 +1,103 @@
# Implementar Fase 2 del plan LoRA de Qwen3.6 (construcción del dataset) — v1 a volumen reducido, vía agente en background
## Decisión de ejecución
El usuario pidió explícitamente que esta fase la ejecute **un agente autónomo en background usando la skill `agent-orchestrator`** (mismo patrón que la Fase 1: tmux + git worktree + branch propia + Docmost + PR, nunca merge directo). A diferencia de la Fase 1 (una tarea puramente operativa: correr un script y esperar), esta es una tarea de **autoría de contenido** — por eso el agente, dentro de su propio worktree, puede spawnear sus propios subagentes `developer` en paralelo (uno por bucket, con listas de archivos disjuntas) para escribir los seeds, y cierra con el flujo estándar de la skill: commit propio → `qa-validator` omitido (tarea backend/data, no toca UI web) → `pr-shipper` abre PR (nunca mergea) → `ci-developer` espera CI → `docmost-reporter` documenta en su propia subpágina → `mailer` notifica por correo → se queda en turno esperando `"apruebo <agente>"`.
Lo que hace **la sesión madre (yo, ahora)** en este plan es solo el **lanzamiento**: crear el worktree/branch, la subpágina de Docmost del agente, el `TASK.md` con todo el diseño de contenido de abajo, y arrancar la sesión tmux. La ejecución del contenido (sanitize → autoría de seeds → build → validate → commit → PR) la hace el agente de forma autónoma; la integración final a la página principal de Docmost, el merge (con validación explícita del usuario) y el archivado del agente quedan para un turno posterior, cuando el usuario diga `"apruebo <agente>"`.
## Contexto
Con la Fase 0 (infraestructura) y la Fase 1 (schemas de los 5 MCPs + `data/raw/replay.jsonl` con 720 ejemplos) completadas y ya commiteadas en `master`, el usuario pidió avanzar con la Fase 2: construir el dataset de entrenamiento (`scripts/04_sanitize.py`, `05_build_dataset.py`, `06_validate_dataset.py`).
A diferencia de las Fases 0/1 (operativas: instalar, verificar, volcar schemas, pedirle prompts al modelo de producción), la Fase 2 es fundamentalmente un trabajo de **autoría de contenido**: hay que enseñarle al modelo aprox. 1800-2300 ejemplos nuevos (Penpot, los otros 4 MCPs, adherencia a skills, delegación a subagentes, negativos/no-tool, manejo de errores) sobre reglas que **el modelo base todavía no sigue** (esa es la razón de ser del LoRA). Por eso estos ejemplos se autoran directamente (yo + subagentes en paralelo, grounded en los schemas/reglas reales ya documentados en `data/schemas/*.json`, la subpágina de Penpot, y los 5 `SKILL.md`/7 agentes `~/.claude/agents/*.md` reales) en vez de muestrear al modelo de producción, que es lo que sí tenía sentido para el replay (Fase 1) pero no aquí.
Se investigó antes de planificar:
- **Inventario de código/convenciones existentes**: repo limpio, 5 scripts previos (`00`, `01`, `03` — no hay `02_dump_mcp_schemas.py` como archivo separado, los schemas se volcaron directo desde la sesión). Estilo consistente: docstring inicial, constantes `UPPER_CASE` en vez de `argparse`, sin `logging` (solo `print` con tags `[...]`), JSONL con `ensure_ascii=False` + `flush()` inmediato, manejo de errores por contador sin abortar el batch completo. `data/raw/replay.jsonl` tiene exactamente `{"messages": [{"role":"user","content":...}, {"role":"assistant","content":...,"reasoning_content":...}], "meta": {"temperature":...,"finish_reason":...}}` — 2 turnos, sin `tools`/`tool_calls`. Los 5 `data/schemas/*.json` son `[{name, description, parameters}, ...]` (154 tools totales: Penpot 4, Gitea 53, GitHub-personal 43, Docmost 17, Atlassian 37).
- **Inventario de skills/agentes reales**: confirmado que son exactamente 5 skills (`agent-orchestrator` 719 líneas/~35 reglas duras, `aleleba-pr` 261 líneas/~11 reglas en formato `### NEVER`/`### ALWAYS`, `docmost-context` 164 líneas casi sin reglas duras, `spark-ssh` 70 líneas/3 reglas, `web-ui-test` 179 líneas/reglas de headless) y 7 agentes (`developer`, `code-reviewer`, `qa-validator`, `pr-shipper`, `ci-developer`, `docmost-reporter`, `mailer`) bajo `~/.claude/agents/`. Se extrajeron verbatim las reglas `NUNCA/SIEMPRE/OBLIGATORIO` de cada una — este es el material crudo para los buckets de adherencia a skills (~450) y delegación a subagentes (~180). `agent-orchestrator` domina el corpus (~65% de los bytes de skills) y es la fuente más rica para ambos buckets.
- **Entorno de ejecución local**: este entorno de desarrollo (donde vive `~/.claude/skills`, `~/.claude/agents`, `~/.claude/plans`, y el propio repo `qwen3-6-lora`) tiene `python3.10.12` y `jinja2 3.1.6`, pero **no** `gitleaks` ni `detect-secrets` instalados, y **no** tiene el checkpoint del modelo (vive solo en disco local de spark, fuera del NFS, por diseño — ver Fase 0). Esto determina dónde corre cada script: `04_sanitize.py` y `05_build_dataset.py` corren **localmente aquí** (no necesitan GPU ni modelo — "Estado de vLLM: no aplica" según el plan principal); `06_validate_dataset.py` necesita `apply_chat_template(..., return_assistant_tokens_mask=True)` con el tokenizer real, así que debe correr **dentro del contenedor `qwen-lora-train` en spark** (vía skill `spark-ssh` + `docker exec`), para usar exactamente `transformers==5.14.1` (la misma versión ya verificada en Fase 0), no una versión distinta instalada localmente.
- **Decisión de volumen y skill held-out ya confirmadas con el usuario**: se arranca con un **v1 a volumen reducido** (aprox. 800-1000 ejemplos nuevos, proporcional entre buckets) para validar el pipeline completo (sanitize→build→validate) antes de invertir en el volumen completo (aprox. 2500-3000 del plan original) — evita rehacer trabajo si `06_validate_dataset.py` encuentra un problema de fondo (masking, longitud, chat template). La skill held-out (0 ejemplos de entrenamiento la mencionan, en ningún bucket) es **`spark-ssh`** — la más autocontenida y menos cruzada con delegación a subagentes.
- **Todo lo generado se versiona en el repo**, igual que en Fase 0/1: seeds, copias sanitizadas de skills/agentes/plans, y los JSONL finales se commitean y pushean a `origin/master` al terminar.
**Resultado esperado de este plan**: `data/train.jsonl` y `data/eval.jsonl` (v1, aprox. 800-1000 ejemplos nuevos + los 700 de replay), validados de punta a punta por `06_validate_dataset.py` dentro del contenedor de training, commiteados y pusheados, con Fase 2 marcada "En proceso" en Docmost (no "Completada" — falta la pasada de volumen completo, que se hace en un plan/turno posterior una vez revisado el v1 con el usuario).
---
## Plan de ejecución
### 0. Lanzamiento del agente (lo hace la sesión madre, en este turno)
1. `AGENT_NAME = agente-fase2-dataset`. Crear `.worktrees/agente-fase2-dataset/` + branch `agente-fase2-dataset` en `qwen3-6-lora/`.
2. Crear la fila del agente en la tabla "Agentes Activos" de la página Docmost `019fa739-ae2b-7be3-9bd0-e5363a1b047d`, y su subpágina dedicada (checkpoints de progreso vía `docmost-reporter`), capturando `pageId`/`spaceId` **antes** de lanzar tmux.
3. Configurar `.claude/settings.local.json` del worktree con los permisos necesarios: `Bash`, `Read`, `Edit`, `Write`, `Glob`, `Grep`, `Task`, `mcp__docmost`, `mcp__gitea` (para el PR), y la skill `spark-ssh` (para el paso de validación en el contenedor). No necesita `mcp__penpot`/`mcp__github-personal`/`mcp__atlassian` en vivo — los schemas ya están volcados a `data/schemas/*.json` en el repo.
4. Escribir `TASK.md` con el contenido completo de las secciones 1-5 de abajo (el diseño de sanitización, autoría de seeds, ensamblado, validación y cierre), más el recordatorio explícito de que el agente **nunca mergea** y que la subpágina de Docmost se actualiza en cada checkpoint importante (después de `04_sanitize.py`, después de cada bucket de seeds, después de `05_build_dataset.py`, después de `06_validate_dataset.py`).
5. Lanzar la sesión tmux (modo interactivo, prompt corto + `TASK.md` completo), capturar `SESSION_ID` del nombre del archivo `.jsonl` de transcript, actualizar el Docmost con el Session ID real, confirmar al usuario que arrancó sin prompts pendientes.
6. Monitoreo pasivo posterior (no cada 30s): revisar cuando haya una razón concreta (el usuario pregunta, o llega el correo de `mailer`).
### Contenido de `TASK.md` — lo que ejecuta el agente de forma autónoma, dentro de su propio worktree/branch
### 1. `scripts/04_sanitize.py` — scrubbing de secretos (corre localmente, dentro del worktree del agente)
- Fuentes: los 5 `SKILL.md`, los 7 `~/.claude/agents/*.md`, y los 66 `~/.claude/plans/*.md` (628K) — **no** transcripts (`~/.claude/projects/*.jsonl`), ya descartados como fuente en el hallazgo de Fase 0 ("casi vacíos de uso de MCPs").
- `pip install detect-secrets` localmente (paquete puro Python, sin binario/daemon) como red de seguridad genérica (entropía alta, formatos conocidos de AWS/JWT/etc.) — se declara explícitamente aquí para que quede cubierto por la aprobación de este plan, igual que se pidió permiso para `sshpass` en la Fase 0.
- Además, lista de regex explícitas para patrones ya conocidos de este proyecto: `SPARK_PASSWORD`, `GMAIL_APP_PASSWORD`, el bearer token reusado (documentado como hallazgo de seguridad en el plan principal), emails reales, IPs/hostnames internos de spark.
- Sustitución **estable** (no borrado): mismo secreto real → mismo placeholder semántico (`<<SPARK_PASSWORD>>`, `<<BEARER_TOKEN_1>>`, etc.) vía un diccionario hash persistido *localmente y gitignoreado* (nunca commiteado, solo para debug).
- Salida: `data/raw/sanitized/{skills,agents,plans}/...` (misma estructura relativa que el original).
- **Gate**: el script termina con exit code ≠ 0 si `detect-secrets` o las regex explícitas encuentran algo en la salida *ya sanitizada* — falla cerrado, no adivina reemplazos para patrones no reconocidos.
### 2. Autoría de seeds por bucket (corre localmente, dentro del worktree, contenido nuevo)
El agente escribe esto — puede spawnear sus propios subagentes `developer` en paralelo (uno por bucket, cada uno con una lista de archivos disjunta: solo su `data/raw/seeds/<bucket>.jsonl`) para paralelizar la autoría, siempre grounded en el schema/reglas reales ya extraídas (no inventar). El commit de cada bucket lo hace el agente mismo (los `developer` nunca commitean, por regla general de `agent-orchestrator`). Formato final de entrenamiento (`messages` + `tools` cuando aplique + `tool_calls` estructurados como dict de `arguments`, nunca XML escrito a mano — según la Decisión de diseño #4 del plan principal):
- **Penpot** (`data/schemas/penpot.json`, 4 tools + reglas no-obvias de la subpágina dedicada): seeds cubriendo cada regla documentada (`insertChild` no `appendChild`, orden invertido de `children` en flex, re-fijar `growType` tras `resize()`, ausencia de `import_image`/`filePath`, etc.), variando shapes/valores.
- **Otros 4 MCPs** (gitea/github-personal/docmost/atlassian, 150 tools combinadas): seeds por tool/familia de tools, incluyendo explícitamente la regla no-obvia de formato de tablas de Docmost (separador GFM + una fila por línea) ya documentada en el plan principal.
- **Adherencia a skills**: seeds basados en las reglas verbatim extraídas de las 5 `SKILL.md` (menos `spark-ssh`, held-out) — checklist inicial en el `<think>`, auto-corrección a mitad de trayectoria, fidelidad a plantillas de reporte.
- **Delegación a subagentes**: seeds basados en `agent-orchestrator` (los 7 pasos obligatorios antes de terminar, protocolo de concurrencia, cuándo invocar `mailer`/`docmost-reporter`) y los otros 6 agentes.
- **Negativos/no-tool**: frases cercanas a triggers estrictos (ej. las frases exactas de activación de `agent-orchestrator`) que NO deben disparar ninguna skill/tool — el modelo debe responder en texto plano.
- **Manejo de errores**: trayectorias donde una tool call falla (`"Tool execution failed: ..."`) y el modelo debe leerlo como texto y autocorregirse — basado en la tabla de 11 incidentes reales de `agent-orchestrator` y los bugs de Docmost (colapso de tablas, corrupción de tildes) ya documentados.
### 3. `scripts/05_build_dataset.py` — ensamblado (corre localmente)
- Carga todos los `data/raw/seeds/*.jsonl` + `data/raw/replay.jsonl`.
- Aplica variación determinística (`random.seed(42)` para reproducibilidad) — sustitución de valores desde los schemas reales + un set chico de parafraseos escritos a mano por intención — para llegar al volumen v1 (aprox. 800-1000 nuevos, repartidos proporcionalmente a los buckets del plan original: Penpot ~110, otros MCPs ~290, skills ~160, delegación ~65, negativos ~70, errores ~55).
- Verifica que ningún seed mencione `spark-ssh` (assert del held-out).
- Tagea cada ejemplo con `meta.bucket`, mezcla, separa 90/10 train/eval **estratificado por bucket** (para que eval no quede dominado por un solo bucket).
- Escribe `data/train.jsonl` / `data/eval.jsonl`.
### 4. `scripts/06_validate_dataset.py` — validación (corre en el contenedor `qwen-lora-train` en spark, vía skill `spark-ssh`)
- Por cada ejemplo: `tokenizer.apply_chat_template(messages, tools=..., tokenize=True, return_assistant_tokens_mask=True, return_dict=True)` — assert sin excepción, `<tool_call><function=...>` bien formado cuando corresponde, máscara de assistant no vacía y no cubre system/user/tool.
- Filtra (no trunca) ejemplos que excedan 8192 tokens — reporta cuántos y de qué bucket (nunca silencioso).
- Re-corre el gate de secretos sobre `train.jsonl`/`eval.jsonl` como última línea de defensa.
- Imprime/reporta un resumen final por bucket (conteo, filtrados, excepciones).
### 5. Cierre del agente (flujo estándar de `agent-orchestrator`, nunca merge)
1. Commit final propio (nunca de un `developer`) de: `scripts/04_sanitize.py`, `05_build_dataset.py`, `06_validate_dataset.py`, `data/raw/sanitized/`, `data/raw/seeds/*.jsonl`, `data/train.jsonl`, `data/eval.jsonl` — en su propia branch `agente-fase2-dataset`.
2. `qa-validator`: **omitido explícitamente** — tarea backend/data pura, no toca ninguna interfaz web. Dejarlo dicho en el checkpoint de Docmost, no simplemente saltarlo en silencio.
3. `pr-shipper`: abre el PR (título/cuerpo en inglés, vía skill `aleleba-pr`) desde `agente-fase2-dataset` hacia `master`. **Nunca mergea.**
4. `ci-developer`: espera los checks (si el repo tiene CI configurado en Gitea Actions; si no hay ninguno configurado, lo reporta y continúa).
5. `docmost-reporter`: checkpoint final en la subpágina propia del agente — resumen ejecutivo, conteos reales por bucket, resultado de `06_validate_dataset.py` (incluyendo cualquier ejemplo filtrado por longitud o secreto sobreviviente), decisiones tomadas (volumen v1 reducido, held-out `spark-ssh`), link al PR.
6. `mailer`: envía el correo de finalización con ese mismo resumen.
7. El agente **no cierra su sesión** — se queda en turno esperando `"apruebo agente-fase2-dataset"` o una corrección.
**Fuera del alcance de este plan** (se hace en un turno posterior, cuando el usuario apruebe): revisar el PR, mergear con validación explícita del usuario, integrar el resumen de la subpágina del agente a la página principal del plan (Fase 2 → "En proceso", con el resultado del v1 documentado y la decisión de volumen completo pendiente), y archivar el agente (mover fila a "Historial", borrar worktree/branch/subpágina) — exactamente el mismo procedimiento de FASE 4 de `agent-orchestrator` ya ejecutado al cerrar la Fase 1.
---
## Verificación
**Del lanzamiento (esta sesión, ahora):**
- `.worktrees/agente-fase2-dataset/` y la branch existen; la sesión tmux arrancó sin prompts de permisos pendientes.
- La fila del agente en "Agentes Activos" tiene el Session ID real (no "pendiente") y su subpágina de Docmost existe.
**Del trabajo del agente (verificable más tarde, sin polling activo):**
- `data/raw/sanitized/` existe y el gate de `04_sanitize.py` termina en exit 0 (no sobrevive ningún patrón conocido).
- `data/raw/seeds/*.jsonl` existen para los 6 buckets, ninguno menciona `spark-ssh`.
- `data/train.jsonl`/`data/eval.jsonl` existen, aprox. 1500-1700 líneas combinadas (v1 nuevo + replay), JSON válido línea por línea.
- `06_validate_dataset.py` corre dentro de `qwen-lora-train` en spark y termina reportando 0 excepciones y 0 secretos sobrevivientes (los filtrados por longitud, si los hay, quedan documentados, no ocultos).
- `vllm-qwen36` sigue `Up ... (healthy)` — esta fase no lo toca en ningún momento.
- Hay un PR abierto desde `agente-fase2-dataset` hacia `master` (nunca mergeado por el agente).
- Llega el correo de `mailer` con el resumen final.
- La subpágina de Docmost del agente documenta conteos reales por bucket y el resultado de la validación.
- El agente sigue en turno, esperando `"apruebo agente-fase2-dataset"` — no cierra su sesión solo.
@@ -0,0 +1,91 @@
# Plan: Conectar PostgreSQL de Luminarrh de forma segura y encriptada
## Contexto
Tienes una base de datos PostgreSQL (imagen `postgres:18`) corriendo en Docker en tu Synology NAS, contenida en `luminarrh/`.
**Estado inicial:**
- Sin encriptación SSL configurada
- Puerto 5436 expuesto sin restricción de hosts
- Sin control de acceso por IP
**Estado actual (implementado):**
- SSL/TLS obligatorio para todas las conexiones externas
- Autenticación `scram-sha256` (hash fuerte)
- TLS 1.2 mínimo
- Cualquier IP puede conectar desde internet, pero **solo con SSL + credenciales**
- Red local `<<INTERNAL_IP_2>>/24` también con SSL obligatorio
---
## Archivos implementados
### 1. `luminarrh/postgresql.conf` — SSL activado
```conf
listen_addresses = '*'
max_connections = 500
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server.crt'
ssl_key_file = '/etc/postgresql/ssl/server.key'
ssl_min_protocol_version = 'TLSv1.2'
shared_buffers = 2GB
maintenance_work_mem = 128MB
timezone = 'America/Guatemala'
log_timezone = 'America/Guatemala'
```
### 2. `luminarrh/pg_hba.conf` — Control de acceso
```conf
# Conexión local (dentro del contenedor) — sin SSL requerido
local all all peer
# Red local <<INTERNAL_IP_2>>/24 — SSL obligatorio
hostssl all all <<INTERNAL_IP_2>>/24 scram-sha256
# Internet — SSL obligatorio para cualquier IP
hostssl all all 0.0.0.0/0 scram-sha256
# Rechazar todo lo demás sin SSL
host all all 0.0.0.0/0 reject
```
Clave: `hostssl` exige SSL. `host` sin SSL es rechazado. No hay reglas `trust`.
### 3. `luminarrh/docker-compose.yaml` — Mounts SSL
- Puerto solo TCP (`5436:5432/tcp`) — sin UDP
- Mount del `pg_hba.conf` explícito
- Mount del SSL como `ro` (solo lectura)
- Command explícito para config_file y hba_file
### 4. `luminarrh/ssl/server.crt` + `server.key`
Certificado autofirmado para `senabed.p-lao.com`, generado con OpenSSL RSA 2048.
### 5. `luminarrh/README.md`
Guía completa de conexión con SSL para Linux, macOS, Windows, Python (psycopg2), Node.js (pg).
---
## Verificación
1. **DNS**: `senabed.p-lao.com``181.209.153.86`
2. **Conexión sin SSL es rechazada**: `sslmode=disable``pg_hba.conf rejects connection`
3. **Conexión con SSL**: requiere `sslmode=require` + certificado + contraseña ✅
4. **Cualquier IP con SSL puede conectar**
5. **Red local `<<INTERNAL_IP_2>>/24` con SSL puede conectar**
---
## Notas de seguridad
| Aspecto | Estado |
|---------|--------|
| SSL/TLS obligatorio | ✅ hostssl + TLS 1.2 mínimo |
| Autenticación | ✅ scram-sha256 |
| Control de acceso | ✅ hostssl + reject |
| Contraseña | ⚠️ `<<DB_PASSWORD_1>>` (débil) |
| Certificado | ⚠️ Autofirmado (no verifica identidad) |
@@ -0,0 +1,108 @@
# GFIBER-683 — Allow admins to correct offer data (accepted/rejected override + history)
## Context
Jira **GFIBER-683**: "Agents in the pilot group have submitted incorrect upsells that compromised reporting metrics calculation. We need a mechanism to allow admins to validate, edit and correct these wrong entries."
Concretely: at the end of the Upsell Evaluator flow, an agent records whether the customer **Accepted** or **Rejected** the offer (`upsell_tickets.state`, mapped from the enum: `accepted` / `declined_hard` in practice — the only two "declined" the current UI can produce). Agents sometimes pick the wrong one by mistake. Since every dashboard KPI, rate tile, and export re-reads `state` live on every request, one wrong pick silently skews reporting metrics with no way to fix it today.
A detailed design already exists in Docmost (space **GFiber** → page *Upsell_State_Override_—_Plan_Historial_de_Cambios_(Accepted-Rejected)*, ES/EN subpages, written 2026-07-09) and was refined further in this conversation against the current state of the repo. This plan supersedes the Docmost draft on two points where the repo/requirements moved since it was written (see below); the rest of the Docmost design (migration, RPC, RLS, server actions, tests) still holds and an Explore agent re-verified every file path/helper name against today's code.
**What changed vs. the Docmost draft, confirmed with the user in this session:**
1. **Table scope widened.** Today `app/(protected)/dashboard/page.tsx` fetches only `state = "accepted"` tickets for the "Offer Verification" table (`ValidationsCard``AcceptedTicketsVerification`). An agent who mis-marked an accepted offer as Rejected produces a `declined_hard` ticket that never appears in this table — there'd be nothing to click "History"/override on. Fix: broaden the query to `state IN ('accepted', 'declined_hard')` — the only two states this feature's toggle ever produces or corrects. Not all 6 enum values — `no_pitch`, `declined_soft`, `no_offer`, `in_progress` stay out of this table's scope.
2. **Menu design simplified.** The Docmost draft split this into two menu items — "Verify" (existing modal + a new embedded "Override Outcome" section) and "History" (separate read-only modal). The user's clarified intent is simpler: leave the existing verification modal **completely untouched**, and add **one** new second menu item that opens a **single new modal** combining both the Accepted⇄Rejected override control *and* the change history in one view.
3. `no_pitch` stays excluded from reclassification, per the user — confirmed as-is from the Docmost draft.
## Design
### 1. Migration — new audit table + atomic RPC
New file `supabase/migrations/<timestamp>_add_upsell_state_changes.sql` (timestamp must sort after the latest existing migration, `20260714120000_add_sales_coach_notes_to_upsell_tickets.sql`):
- **Table `upsell_state_changes`**: `id uuid pk`, `ticket_id uuid references upsell_tickets(id) on delete cascade`, `changed_by uuid references profiles(id) on delete set null`, `changed_at timestamptz default now()`, `old_state upsell_state not null`, `new_state upsell_state not null`, `reason text`. Index on `(ticket_id, changed_at desc)`.
- Modeled directly on the existing audit-table precedent `supabase/migrations/20260612120000_create_eligibility_checks.sql`, but **no INSERT/UPDATE/DELETE policy for any client role at all** (unlike `eligibility_checks`, which lets any authenticated user insert) — every write goes through the RPC below. RLS: `select` policy gated to `public.current_profile_role() in ('admin','owner')` (reuse the existing helper from `supabase/migrations/20260707130000_fix_profiles_update_policy_recursion.sql:29-42` — do not write a new one).
- **RPC `change_upsell_ticket_state(p_ticket_id uuid, p_new_state upsell_state, p_reason text default null) returns upsell_tickets`**, `security definer`, `set search_path = ''`: checks caller role is admin/owner via `auth.uid()``profiles.role` (or `current_profile_role()`), checks `p_new_state in ('accepted','declined_hard')`, row-locks the ticket (`for update`), checks its current `state` is one of the terminal states `('accepted','declined_hard','declined_soft','no_offer')` (excludes `in_progress` and `no_pitch`), checks it isn't already in `p_new_state`, then in one transaction: `update upsell_tickets set state = p_new_state, initiation_type = null where id = ...` and `insert into upsell_state_changes (...)`. `revoke ... from public; grant execute ... to authenticated`.
- **Critical gotcha to get right**: this RPC must be called via the **session-scoped client** (`createClient()` from `utils/supabase/server.ts`), not `createAdminClient()` (`utils/supabase/admin.ts`) — every other action in `dashboard/actions.ts` today uses `createAdminClient()` after an app-layer `requireRole` check, so this is a real deviation; call it out in the PR description/code review since it's easy to copy-paste the wrong client and have `auth.uid()` silently resolve to null inside the function.
- Regenerate `utils/supabase/database.types.ts` after applying the migration to DEV.
### 2. Server Actions — `app/(protected)/dashboard/actions.ts`
Add two new actions, following the exact import/helper pattern already used in this file (`getCurrentUserWithProfile` from `utils/supabase/get-current-user.ts`, `requireRole`/`RoleError` from `utils/roles.ts`):
- `changeTicketState(ticketId, newState: "accepted" | "declined_hard", reason?)`: app-layer `requireRole(profile, "admin", "owner")` as defense-in-depth, then `createClient().rpc("change_upsell_ticket_state", {...})`, surface any raised exception as `{ error }`, `revalidatePath("/dashboard")` on success.
- `getTicketStateHistory(ticketId)`: same role gate, then a plain read via `createAdminClient()` (no RLS-sensitive write here) joining `profiles!changed_by(display_name, email)`, ordered `changed_at desc`.
### 3. Widen the verification table's query — `app/(protected)/dashboard/page.tsx`
- Change the `acceptedTicketsResult` query (~line 128) from `.eq("state", "accepted")` to `.in("state", ["accepted", "declined_hard"])`, and add `state` to the `.select(...)` column list.
- `AcceptedTicket` type (`components/dashboard/AcceptedTicketsVerification.tsx:17-30`) gets a new `state: UpsellState` field.
- No other change needed here: `ValidationsCard`'s "Information by Agent" tab (`buildValidationSummaryByAgent`) derives its pending/valid/rejected tally from `full_flow_completed`/`initiation_type`/`objection_handled_correctly` only, not from `state`, so it picks up the wider ticket set automatically and correctly.
### 4. UI — kebab becomes a 2-item menu; second item opens one combined modal
**New DS component `KebabMenu`** (`packages/design-system/src/components/KebabMenu/`): wraps the existing `IconButton` (⋯ trigger) and portal-renders (`createPortal` to `document.body`, positioned from the trigger's `getBoundingClientRect()` — same technique already used by `AttendanceQuickModal`/`TicketVerificationModal`, needed because this table can scroll horizontally and a non-portaled menu risks clipping, the same overflow-bug class already hit twice in this repo for the Attendance grid). Closes on Escape/outside-click/item-select. `role="menu"` / `role="menuitem"`, arrow-key navigation. Props: `{ ariaLabel: string; items: { label: string; onSelect: () => void }[] }`. Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx` per this repo's DS-first convention.
`components/dashboard/AcceptedTicketsVerification.tsx` Actions column (currently a single `IconButton` at lines 179-194 calling `setManagedTicket(ticket)`) becomes:
```tsx
<KebabMenu
ariaLabel={`Actions for ${agentName}'s ticket`}
items={[
{ label: "Verify", onSelect: () => setManagedTicket(ticket) },
{ label: "Change Status", onSelect: () => setStateModalTicket(ticket) },
]}
/>
```
`TicketVerificationModal` is **not modified** — it keeps rendering exactly as it does today (the 4 existing sections: full_flow_completed, initiation_type, objection_handled_correctly, sales coach notes).
**New DS component `TicketStateModal`** (`packages/design-system/src/components/TicketStateModal/`), opened by the new "Change Status" menu item, built on the same portal/focus-trap/Escape-to-close shell as `TicketVerificationModal`/`AttendanceQuickModal`:
- Header: agent name + Case ID + current `OUTCOME_BADGE` (reused from `utils/upsell/outcomeDisplay.ts`).
- **Override section**: "Mark Accepted" / "Mark Rejected" buttons, disabled on whichever is currently active, plus an optional reason `TextField` (DS component already used elsewhere, e.g. `StageClose.tsx`'s Order ID field). On submit, calls `changeTicketState` via the existing `startTransition` + `router.refresh()` pattern already used for the other actions in this file.
- **History section** (same view, below the override controls): fetches `getTicketStateHistory(ticketId)` on open, renders a `DataTable` (same component `SessionHistoryView` uses): `changed_at``old_state → new_state` (two `OUTCOME_BADGE`s with an arrow between) → `changed_by_name``reason`. Empty state: "No manual overrides yet."
- Footer: Close button.
- Ships with `.stories.tsx`, `.test.tsx`, `.test.cy.tsx`.
`AcceptedTicketsVerification.tsx` gets one new local state (`stateModalTicket`) alongside the existing `managedTicket`, each independently rendering its own modal — mirrors the existing `managed` state pattern already used for `TicketVerificationModal`.
### Side effects to call out in the PR description (no code change, just visibility)
- Reclassifying a client's most-recent ticket changes that client's live cooloff/eligibility on the next Upsell Evaluator lookup (`lookupClient()` derives eligibility from the latest ticket's `state` live, no cache).
- Dashboard KPIs, rate tiles, Session History, and the XLSX export all shift on next load — nothing else to invalidate.
## Files touched
| File | Change |
|---|---|
| `supabase/migrations/<ts>_add_upsell_state_changes.sql` | new table + RLS (admin/owner select-only, no insert/update/delete policy) + `change_upsell_ticket_state()` RPC |
| `utils/supabase/database.types.ts` | regenerated |
| `app/(protected)/dashboard/actions.ts` | new `changeTicketState`, new `getTicketStateHistory` |
| `app/(protected)/dashboard/page.tsx` | widen `acceptedTicketsResult` query to `state IN ('accepted','declined_hard')`, add `state` to select |
| `components/dashboard/AcceptedTicketsVerification.tsx` | `AcceptedTicket.state` field; swap lone kebab `IconButton` for `KebabMenu` (Verify / Change Status); new `stateModalTicket` local state rendering `TicketStateModal` |
| `packages/design-system/src/components/KebabMenu/*` | new — menu trigger + story + tests |
| `packages/design-system/src/components/TicketStateModal/*` | new — combined override+history modal + story + tests |
| `packages/design-system/src/components/index.tsx` | export `KebabMenu` + `TicketStateModal` |
## Tests (TDD)
- `__tests__/dashboard-state-change.test.ts` — mock `.rpc()` (this action calls the RPC, not `.from().update()`), role gate, success path, RPC exception messages surfaced verbatim, `revalidatePath` only on success.
- `__tests__/dashboard-state-history.test.ts` — role gate, empty history → `[]`, `profiles!changed_by` join mapping, DB error → `{ error }`.
- `KebabMenu.test.tsx` + `.test.cy.tsx` — open/close (click, Escape, outside-click, item-select), `role="menu"`/`"menuitem"`, arrow-key nav, correct `onSelect` fired.
- `TicketStateModal.test.tsx` + `.test.cy.tsx` — override buttons disabled on current state, click inactive button calls `changeTicketState` with right args, history rows render (old→new badges, changed_by_name, reason), empty state, focus-trap/Escape (mirror `AttendanceQuickModal.test.tsx`).
- Extend `__tests__/accepted-tickets-verification.test.tsx`: Actions column renders `KebabMenu` with exactly two items; "Verify" opens the (unmodified) verification modal; "Change Status" opens `TicketStateModal`; mutually exclusive modal states.
- Extend/add a page-level test or integration check confirming the widened query actually returns `declined_hard` tickets alongside `accepted` ones.
- No Vitest can exercise the Postgres function body (no pgTAP in this repo) — the RPC's internal guards (role check, terminal-state check, atomicity) can only be verified against a real DEV database; call this out as a manual verification step, not a gap for more unit tests.
## Verification (end-to-end)
1. `npm run lint && npm run test`.
2. Apply migration to DEV (`supabase db push` against `vshcimexazocttjmyrqy`), regenerate types.
3. Exercise the RPC directly against DEV Postgres (SQL editor / `supabase db execute`): non-admin `auth.uid()` rejected; `in_progress`/`no_pitch` ticket rejected; same-state no-op rejected; a successful call leaves exactly one new `upsell_state_changes` row **and** the ticket's `state`/`initiation_type` updated together in the same query check.
4. `npm run dev` as admin/owner: confirm the Offer Verification table now lists both accepted and declined_hard tickets. Open the kebab on an accepted ticket → "Change Status" → override to Rejected with a reason → confirm the row's status updates on refresh → reopen "Change Status" → confirm the new history row (old→new, your name, reason) appears above/below the override controls → reopen "Verify" → confirm `initiation_type` reset (Check 2 chips unselected).
5. Confirm the cooloff side effect: pick a client whose latest ticket is the one just reclassified, run a fresh Upsell Evaluator lookup for that GAID, confirm the eligibility banner reflects the new cooloff window.
## Git workflow
New branch from `dev`: `feat/gfiber-683-offer-state-override`, per this repo's convention (branch from `main`... actually per `CLAUDE.md` this repo branches feature work from `main`, and periodically `main``dev` is merged for DEV testing — confirm target base branch matches how GFIBER-689/GFIBER-648 branches were structured before opening the PR).
@@ -0,0 +1,64 @@
# GFIBER-671 — Botones de scroll izquierda/derecha en el Attendance Grid
## Context
**Ticket:** GFIBER-671 (High, To Do, asignado a Alejandro Lembke) — *"Add buttons to move the attendance calendar to the right or to the left (like scrolling)"*. Surgió en el daily del 2026-07-02: *"The scroll bar doesn't get displayed for some users, so we need something else to enable them to navigate the attendance calendar."*
El mismo día se cerró **GFIBER-663** (PR #89`dev`), que atacó el síntoma raíz por CSS: en Windows con "Automatically hide scroll bars" (activado por defecto en muchas configuraciones), el scrollbar horizontal del `AttendanceGrid` nunca se renderizaba como barra persistente/arrastrable para usuarios de mouse en desktop. El fix fue `scrollbar-width`/`scrollbar-color` + `::-webkit-scrollbar*` sobre el contenedor `.scroll`, forzando un scrollbar clásico siempre visible.
GFIBER-671 es el fast-follow: incluso con un scrollbar visible, seguir dependiendo *exclusivamente* de arrastrar una barra de 10px es una interacción frágil (precisión de mouse, descubribilidad, accesibilidad motora). El pedido es un control explícito e independiente del navegador — flechas que desplazan la grilla — como refuerzo, no como reemplazo del scrollbar ya corregido.
**Resultado buscado:** dos botones flecha (izquierda/derecha) superpuestos sobre el borde del área scrolleable del `AttendanceGrid`, que aparecen solo cuando hay contenido para desplazar, se deshabilitan en los extremos, y desplazan la grilla con un click — sin tocar persistencia, Server Actions, ni el modelo de datos (es una mejora puramente de interacción de UI).
## Investigación previa
Confirmado leyendo el código real (no solo la documentación de Docmost):
- **Componente objetivo:** `packages/design-system/src/components/AttendanceGrid/index.tsx` — component plano `"use client"`, sin refs ni estado hoy (100% driven by props). El wrapper de la app, `components/dashboard/AttendanceGrid.tsx`, **no necesita cambios**: no usa el prop `monthLabel`/`onPrevMonth`/`onNextMonth` del DS (tiene su propio header de navegación de mes fuera de este componente) y solo envuelve el render del DS en un `<div>` para capturar `onPointerDown` del status-picker — no posee ref al contenedor de scroll. Todo el trabajo va **dentro del DS**, consistente con la convención DS-first del proyecto (ningún control nuevo va inline en la app).
- **Contenedor de scroll:** `<div className={styles.scroll} data-scroll>` (index.tsx:125) — ya tiene `position: relative` (style.module.scss:58), heredado del fix de GFIBER-663. Esto es clave: **no hace falta reestructurar CSS** para tener un contexto de posicionamiento — los botones pueden ser `position: absolute` hijos directos de `.scroll`, igual que ya hace `.scrollHint` (style.module.scss:232-240, right-edge fade decorativo). Un elemento `position: absolute` cuyo *containing block* es el propio elemento con `overflow-x: auto` **no se desplaza con el scroll** (se posiciona respecto al padding-box estático del contenedor, no respecto al contenido que se desplaza) — es el mismo mecanismo que ya usa `.scrollHint` para quedar fijo en el borde derecho.
- **Columnas sticky a esquivar:** `.agentCell`/`.agentHead` (ancho 200px, sticky `left:0`) y, si `showSummary` está activo (default `true`), `.summaryCell`/`.summaryHead` (ancho 88px adicional, sticky `left:200px`). El botón **izquierdo no debe ir en `left:0`** (taparía el nombre del agente) — debe ir en el borde donde terminan las columnas congeladas: `left: 200px` (sin summary) o `left: 288px` (con summary). El botón derecho va en `right: 0`, en el mismo slot que hoy ocupa `.scrollHint`.
- **Estilo a reutilizar (decisión confirmada con el usuario):** `.navBtn` (style.module.scss:30-47) — círculo 28px, ``/`` unicode, hover `--fiber-gray-100`, focus-visible `--fiber-blue`. Hoy este estilo solo lo usa el header de mes opcional del propio componente (index.tsx:107-123), que la app no consume — es decir, **existe y está validado visualmente pero no tiene ningún consumidor real todavía**. Reutilizarlo mantiene consistencia visual con la identidad ya definida en Penpot para este componente y cumple el mínimo de WCAG 2.2 SC 2.5.8 (24×24px). Se le suma `box-shadow` + fondo sólido (`--fiber-surface`) para que se lea flotando sobre celdas de cualquier color (weekend, today, status tonos).
- **Sin precedente de teclado Left/Right:** el único roving-focus del repo usa `ArrowUp`/`ArrowDown` (Picker en `components/dashboard/AttendanceGrid.tsx:229-247`, para el menú de status). No hay ningún patrón `ArrowLeft`/`ArrowRight` existente — no es necesario inventarlo para este ticket (los botones son suficientes; el foco natural por Tab ya los hace accesibles por teclado).
- **DataTable/UserTable comparten el mismo `overflow-x:auto` sin botones** — confirmado, fuera de alcance de este ticket (ya está anotado como deuda fast-follow desde GFIBER-663).
## Approach
Todo el cambio vive en `packages/design-system/src/components/AttendanceGrid/`. Component sigue siendo controlado por props para todo lo existente; se le agrega estado *interno* puramente de presentación (no se expone a la app):
1. `useRef<HTMLDivElement>` sobre el `<div className={styles.scroll}>`.
2. `useState` con `{ canLeft: boolean, canRight: boolean }`. Un `useEffect` calcula el estado inicial al montar/cuando cambian `agents`/`days` (el ancho de la tabla puede cambiar de mes a mes) comparando `scrollWidth` vs `clientWidth` del ref; si no hay overflow (`scrollWidth <= clientWidth`), ambos botones se **ocultan por completo** (no solo deshabilitan) — no tiene sentido mostrar controles de scroll cuando no hay nada que desplazar.
3. Listener de `scroll` sobre el propio contenedor (recalcula `canLeft`/`canRight` en cada evento) + `ResizeObserver` sobre el contenedor (recalcula si el usuario resizea la ventana o cambia el layout del dashboard). Ambos se limpian en el cleanup del `useEffect`.
4. Dos `<button>` con la clase `.navBtn` existente (mismo trato visual, con `box-shadow`/fondo agregado para el overlay), `type="button"`, `aria-label="Scroll attendance grid left"` / `"...right"`, `disabled` cuando `!canLeft` / `!canRight`, `data-scroll-left` / `data-scroll-right` para hooks de test (mismo patrón que el `data-scroll`/`data-scroll-hint` ya existentes).
5. `onClick`: `scrollRef.current?.scrollBy({ left: ±Math.round(scrollRef.current.clientWidth * 0.8), behavior })`, con `behavior` = `'auto'` si `window.matchMedia('(prefers-reduced-motion: reduce)').matches`, si no `'smooth'`.
6. El `.scrollHint` decorativo (fade derecho) se **elimina** — el botón funcional en el mismo slot lo vuelve redundante y visualmente competiría con él en el borde derecho. Si en la revisión visual se prefiere mantenerlo detrás del botón, es un ajuste de una línea, pero la recomendación es no duplicar la señal.
## Archivos a modificar
- **`packages/design-system/src/components/AttendanceGrid/index.tsx`** — agregar `useRef`/`useState`/`useEffect` + los dos botones dentro de `.scroll`, después del `<table>` (reemplazando el `<span className={styles.scrollHint}>` actual).
- **`packages/design-system/src/components/AttendanceGrid/style.module.scss`** — nuevas clases `.scrollBtn` (extiende `.navBtn`: `position: absolute`, `top: 50%`, `transform: translateY(-50%)`, `z-index: 4`, `background-color: var(--fiber-surface)`, `border: 1px solid var(--fiber-gray-200)`, `box-shadow: var(--shadow-sm)`), `.scrollBtnLeft { left: 200px }` / `.scrollBtnLeftWithSummary { left: 288px }` (o resolver el offset inline con una custom property, a decidir en implementación) y `.scrollBtnRight { right: 8px }`. Eliminar `.scrollHint`.
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.tsx`** (Jest/RTL) — nuevos tests: botones ausentes cuando no hay overflow (mockear `scrollWidth`/`clientWidth` vía `Object.defineProperty` en el elemento, patrón estándar de RTL para jsdom), presentes y con `aria-label` correctos cuando sí hay overflow, click llama a `scrollBy` (mock `Element.prototype.scrollBy`) con el signo correcto, `disabled` refleja `scrollLeft` en los extremos tras disparar el evento `scroll`.
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.cy.tsx`** — extender el bloque `describe('AttendanceGrid (component) — scroll & sticky', ...)` (líneas 66-176) ya existente: click en el botón derecho aumenta `scrollLeft` real (layout de navegador real, no mock), click izquierdo lo disminuye, botón derecho se oculta/deshabilita al llegar al final, botón izquierdo al llegar al inicio, los botones permanecen visualmente anclados al hacer scroll (no se desplazan con el contenido), y no tapan el nombre del agente (posición izquierda después de las columnas sticky).
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.stories.tsx`** — story adicional con `agents`/`days` suficientes para forzar overflow en un viewport angosto, para revisión visual en Storybook antes de mergear.
No se toca `components/dashboard/AttendanceGrid.tsx` (wrapper de la app), ni Server Actions, ni el esquema de Supabase — cambio 100% contenido en el design system, sin persistencia involucrada.
## Ejecución
Este trabajo se despacha con la skill global **`background-orchestrator`** (no se implementa en esta sesión interactiva). Siguiendo el mismo patrón ya usado para el ticket hermano GFIBER-663 (`agente-gfiber-663-attendance-scrollbar`, registrado en la página Docmost *Agentes_Activos*):
1. **Agente autónomo en tmux**, nombre sugerido `agente-gfiber-671-attendance-scroll-buttons`, corriendo en su propio git worktree bajo `.worktrees/` sobre una rama dedicada (`feat/gfiber-671-attendance-scroll-buttons`, branch desde `dev` — mismo target que GFIBER-663).
2. **TDD estricto** (rojo→verde): el agente escribe primero los tests de Jest/RTL y Cypress descritos en la sección de Verificación antes de tocar `index.tsx`/`style.module.scss`.
3. **Nunca mergea** — al terminar, abre PR hacia `dev` vía la skill `aleleba-pr` (output en inglés, sin `Co-Authored-By` ni trailers de atribución, por convención del repo).
4. **Se documenta en Docmost**: entrada nueva en *Agentes_Activos* (historial) y actualización de la subpágina *Grid_de_Asistencia_(Attendance)* con el resultado, igual que se hizo para GFIBER-663.
5. El orquestador (esta sesión) monitorea el progreso del agente, responde preguntas si el agente se bloquea, y valida/archiva el resultado al finalizar (`/aprobar agente-gfiber-671-attendance-scroll-buttons`) — sin mergear directamente.
Este plan (el archivo completo hasta aquí) es el brief que se le entrega al agente en background como contexto de arranque.
## Verificación
1. `npm run test` (Vitest, app) — debe seguir en verde, sin tocar comportamiento del wrapper de la app.
2. Jest del design-system (`npm run test --workspace=@gfiber/design-system` o el script equivalente) — nuevos tests de botones en verde.
3. Cypress component tests del DS — nuevas aserciones de scroll real (`scrollLeft`, `disabled`, posición fija) en verde.
4. Revisión visual en Storybook: confirmar que el botón izquierdo no se superpone al nombre del agente ni al header sticky, y que el botón derecho no queda apretado contra el borde de la tarjeta.
5. Prueba manual en DEV con un roster que exceda el ancho del viewport (mes completo, varios agentes): click en ambas flechas desplaza la grilla, se deshabilitan/ocultan en los extremos, funcionan con teclado (Tab + Enter/Space) y son anunciados correctamente por lector de pantalla (`aria-label`).
6. `npm run lint` + `tsc` limpios.
@@ -0,0 +1,34 @@
# Plan: Documentar arquitectura de Triple T en Docmost
## Context
En la reunión de knowledge-transfer técnico ("GFiber Technical Knowledge Transfer", transcript aportado por el usuario como PDF en la raíz del repo), Juan Elorriaga (ingeniero saliente) le explicó a Alejandro (usuario, ingeniero entrante) la arquitectura de **Triple T**, uno de los tres repos del proyecto GFiber. Esta arquitectura es **GCP-based (Terraform, Cloud Run, Firestore, LangGraph/FastAPI)** y no está capturada en ningún lugar del Docmost actual — el espacio "GFiber" solo documenta el stack Next.js/Supabase/Vercel del repo `gfiber-pilot-extension` que vive en este working directory. Son sistemas distintos dentro de la misma iniciativa GFiber (probablemente el backend "legacy"/paralelo que se está gestionando junto con el rediseño Next.js).
El usuario pidió explícitamente: extraer la arquitectura de Triple T del transcript y documentarla en una **subpágina nueva dentro de `GFiber_Pilot_Extension`** en Docmost (space "GFiber", id `019e654c-79b5-72fe-9e8c-5edf3af6f64f`, página padre `GFiber_Pilot_Extension` id `019e8dfd-a638-7ba6-96a8-86e733c42b60`), para que quede como referencia del equipo.
## Acción
Usar `mcp__docmost__create_page` con:
- `spaceId`: `019e654c-79b5-72fe-9e8c-5edf3af6f64f`
- `parentPageId`: `019e8dfd-a638-7ba6-96a8-86e733c42b60` (GFiber_Pilot_Extension)
- `title`: `Triple_T_—_Arquitectura_(GCP-Backend)`
- `content`: markdown con la siguiente estructura, redactado en español (consistente con el resto del espacio), citando que la fuente es el transcript de knowledge-transfer:
1. **Resumen** — qué es Triple T (herramienta de troubleshooting/diagnóstico asistido por IA, basada en grafo), quién la construyó (Juan Elorriaga), relación con Apsel/frontend (mismos 2 proyectos GCP: Apsel y Triple T; no hay proyecto GCP separado para frontend — vive dentro de Triple T por convención, sin razón lógica, solo para minimizar costos).
2. **Estructura de repos e infra (IaC)** — 3 repos (frontend global + Apsel + Triple T backends separados), cada uno con `infra/{dev,prod,global}` en Terraform siguiendo buenas prácticas (separación de outputs/providers/vars/versions), carpeta `modules/` reutilizable, todo el estado remoto (bucket `Terraform State Gfiber Triple T`, sin state local salvo backups puntuales).
3. **App (API)** — FastAPI + LangGraph, separación en dominios/servicios/utilidades.
4. **Pipeline de grafo** — extrae imágenes desde un Google Doc/guía de troubleshooting → las guarda secuencialmente en un bucket GCS (`images`) → las reincorpora al grafo. Un llamado LLM adicional agrupa los nodos del grafo en clusters abstractos ("groups", ej. "initial triage", "diagnóstico de layer físico") para que el checklist final no sea una lista plana. Nota de escalabilidad: si se agregan más guías, se puede escalar a un árbol por guía (no es la única solución posible).
5. **Firestore** — no-relacional; entornos sandbox/prod/dev (réplicas); colecciones de historial de conversaciones y el grafo en sí.
6. **Cómputo (Cloud Run)** — contenedores Docker por entorno y repo (frontend dev/prod, Triple T dev/prod, + sandbox de Josías). Prod mantiene 3 containers calientes para evitar cold start/concurrencia; Dev usa mínima infra (ahorro de costos) por lo que es más lento — comportamiento esperado, no bug. Deploy a Cloud Run automatizado desde CI.
7. **CI (GitHub Actions)** — solo C, no CD real (el "deploy" ocurre dentro del mismo pipeline de CI post-merge). Jobs bloqueantes: linting (Ruff), testing (Pytest backend / Cypress frontend), security (Bandit), secret-leak scanning (TruffleHog, en frontend y backend). Change-detection basado en diffs de paths para solo rebuildear/pushear/deployar cuando cambian app/deps/Docker/CI — aplicado en los 3 repos.
8. **Dependencias** — Python vía `pyproject.toml` + **uv** (alternativa a pip, escrita en Rust, gestiona versiones de Python por proyecto). Pre-commit hook corre linting + formato + TruffleHog antes de cada commit.
9. **Documentación existente en el repo** — README a nivel raíz por repo (punto de entrada recomendado), sub-READMEs específicos (ej. cómo y cuándo correr el pipeline de enriquecimiento del grafo), y una carpeta `docs/` autogenerada por IA en refactors grandes.
10. **Desarrollo local** — requiere `gcloud auth login` (token dura ~6-8h, causa común de fallos silenciosos), workspace con los 3 repos abiertos juntos (para que el agente de código tenga contexto cruzado), Makefile raíz (`make API` levanta backend en :8000, `make dev` levanta frontend en :3000, limpia puertos automáticamente). Local se conecta a la infraestructura remota de **Dev** (no hay backend 100% local). Auth de la app usa Google Workspace (email debe estar whitelisteado en un archivo de config).
11. **Seguridad** — service account de GCP rotada cada 60 días (obligatorio); keys/.env no están en el repo remoto, se comparten manualmente (Slack/1Password) y deben colocarse en `.env` local por repo; no hay `.env.example` (gap identificado durante la reunión).
12. **Deuda técnica / puntos de mejora mencionados** — posible eliminación de la tabla de versionado de prompts en BigQuery (Apsel) en favor de inyectar el prompt directamente desde el codebase (reduce latencia, como ya se hace en Triple T); no hay `.env.example` en frontend.
13. **Nota de fuente** — al pie: "Extraído del transcript de la reunión 'GFiber Technical Knowledge Transfer' (Juan Elorriaga → Alejandro Lembke, con João Guilherme Silva Morossini)."
## Verificación
- Confirmar que la página se creó correctamente bajo `GFiber_Pilot_Extension` (usar `mcp__docmost__get_page` con el id devuelto, o revisar `mcp__docmost__list_pages` del space).
- Reportar al usuario el link/id de la nueva subpágina.
@@ -0,0 +1,494 @@
# Subagentes internos para `agent-orchestrator`
## Contexto
La skill global `agent-orchestrator` lanza un "agente-hijo" autónomo por tarea (tmux + git worktree + rama
dedicada), que hoy hace TODO inline: implementa el código, revisa Docmost, manda el correo SMTP, corre
`web-ui-test` y `aleleba-pr` él mismo. El usuario quiere paralelizar y especializar ese trabajo con
subagentes internos (herramienta `Task`) que el agente-hijo pueda invocar — en particular poder lanzar
varios "developer" a la vez, cada uno implementando una función/parte distinta, más subagentes dedicados a
mantener Docmost al día y a mandar el correo.
Tras explorar el entorno confirmamos: `~/.claude/agents/` existe pero está vacía (no hay subagentes custom
todavía), y el formato real de frontmatter en este entorno (visto en agentes del plugin `c3po`) es
`name/description/model/effort/color/maxTurns/tools`. El usuario fijó además dos requisitos de portabilidad
que corrigen una instrucción vieja del propio `SKILL.md` (líneas ~439-444, que pedía listar los MCP
explícitamente en `tools:`):
- **Modelo**: ningún subagente debe fijar `sonnet` (u otro) — todos usan `model: inherit`, para que el
entorno decida (a veces modelos locales, a veces Claude).
- **Herramientas**: ningún subagente debe restringir `tools:` — se omite el campo por completo para
heredar TODAS las herramientas del hilo padre, incluidos todos los MCP conectados (docmost, gitea,
github, atlassian, penpot, etc.), sin mantenerlos listados a mano.
El usuario también decidió el set completo de subagentes (developer + docmost-reporter + mailer, más
code-reviewer, qa-validator y pr-shipper) y la regla de concurrencia: los `developer` **nunca commitean**
(solo editan sus archivos asignados y devuelven un resumen); el agente-hijo hace `git add`+commit de forma
**secuencial**, uno por developer, para evitar carreras de git aunque los developers hayan corrido en
paralelo. Y una regla transversal: cada subagente no tiene memoria compartida — su respuesta final de texto
es lo único que el agente-hijo ve, así que debe ser un resumen completo y autocontenido (qué hizo,
decisiones, archivos, bloqueos) para que el agente-hijo tenga contexto pleno y pueda responder si la sesión
madre le pregunta algo.
## Enfoque
### 1. Crear 6 archivos de subagente global en `~/.claude/agents/`
Todos con `model: inherit` y **sin** campo `tools:` (heredan todo). Cada uno termina con una sección
"Reporte final (OBLIGATORIO)" con una plantilla de texto fija, para garantizar que el agente-hijo reciba
contexto completo y estructurado.
**`/home/aleleba/.claude/agents/developer.md`**
```markdown
---
name: developer
description: Implementa una porción acotada y bien definida de trabajo (una función, módulo, componente o fix) restringida a archivo(s) específicos que el agente que lo invoca le asigna explícitamente. Úsalo para escribir o modificar código dentro de un alcance de archivos ya decidido. Se pueden lanzar varias instancias en paralelo (varias llamadas Task en un mismo turno) siempre que cada una reciba una lista de archivos DISJUNTA de las demás. Nunca hace git add ni commit, ni gestiona procesos de servidor de larga duración.
model: inherit
effort: medium
color: blue
maxTurns: 40
---
Eres un `developer`: implementas una porción acotada de código dentro de los archivos EXACTOS que te asignó
quien te invocó. No tienes memoria de otras conversaciones ni de otros subagentes — todo lo que necesitas
debe venir en el prompt que recibiste; si algo imprescindible falta, dilo como bloqueo en tu resumen final
en vez de asumirlo.
## Alcance (léelo primero)
- Toca ÚNICAMENTE los archivos que se te listaron explícitamente (rutas exactas). Si para completar la
tarea necesitas tocar un archivo fuera de esa lista, NO lo hagas por tu cuenta: detente, documenta por
qué en tu resumen final como "bloqueo/duda" y deja claro qué archivo adicional haría falta y por qué.
- Si varios `developer` corren en paralelo, tus archivos son DISJUNTOS de los suyos — no hay razón para que
toques nada fuera de tu lista.
## Cómo trabajar
1. Explora antes de escribir: usa Read/Grep/Glob sobre los archivos asignados y su contexto inmediato
(imports, tipos, tests existentes) para seguir las convenciones ya presentes en el proyecto (estilo,
nombres, patrones de manejo de errores, etc.). No inventes un estilo nuevo si ya hay uno establecido.
2. Implementa el cambio de forma incremental y verificable. Si el proyecto tiene comandos de verificación
que TERMINAN (typecheck, lint, build, test unitario puntual), puedes correrlos para autovalidar tu
propio trabajo.
3. **Nunca dejes procesos de servidor corriendo en background** (dev server, watch mode, `npm run dev`,
etc.) — no es tu responsabilidad gestionar el ciclo de vida del servidor; eso lo hace quien te invocó o
el subagente `qa-validator` más adelante. Si necesitas ejecutar algo para verificar, usa comandos que
terminen solos.
4. **Nunca hagas `git add`, `git commit`, `git push` ni crees ramas.** Tu trabajo termina en el código
editado en disco; el commit lo hace siempre quien te invocó, de forma secuencial junto con los demás
developers del mismo lote, para evitar carreras en el índice de git.
5. Si tienes una duda de diseño que cambia el alcance (ambigüedad real, no una decisión técnica menor),
toma la decisión más razonable tú mismo, documenta por qué en tu resumen, y sigue — no te bloquees por
decisiones de implementación rutinarias.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
Tu respuesta final de texto debe ser un resumen completo y autocontenido, con esta estructura:
```
ESTADO: completo / parcial / bloqueado
QUÉ IMPLEMENTASTE
- (descripción concreta de los cambios)
ARCHIVOS TOCADOS (rutas exactas, todas dentro de tu alcance asignado)
- path/a/archivo1.ts — qué cambió
- path/a/archivo2.ts — qué cambió
DECISIONES DE DISEÑO Y POR QUÉ
- (cualquier decisión no trivial que tomaste y su justificación)
COMANDOS EJECUTADOS (si corriste build/lint/test para autovalidar)
- comando → resultado
DUDAS / BLOQUEOS
- (archivos fuera de tu alcance que hicieron falta, ambigüedades sin resolver, o "ninguno")
```
No omitas ninguna sección aunque esté vacía (usa "ninguno").
```
**`/home/aleleba/.claude/agents/code-reviewer.md`**
```markdown
---
name: code-reviewer
description: Revisa el diff o los cambios producidos por uno o varios subagentes developer antes de que se commiteen — busca bugs de lógica, problemas de seguridad, y oportunidades de simplificación. Solo inspecciona y reporta, nunca edita código ni hace commit. Úsalo después de que un developer (o un lote de developers en paralelo) terminó de editar, antes de git add/commit.
model: inherit
effort: high
color: yellow
maxTurns: 20
---
Eres un `code-reviewer`: revisas cambios de código YA HECHOS en disco (todavía sin commitear) por uno o
varios subagentes `developer`. No editas nada — solo inspeccionas y reportas. No tienes memoria de otras
conversaciones: todo el contexto que necesitas debe venir en el prompt o ser obtenible con `git diff`.
## Cómo trabajar
1. Identifica qué revisar: si quien te invocó te dio una lista de archivos, revisa el diff de esos archivos
exactos (`git diff -- <archivos>` o `git diff HEAD -- <archivos>` si ya hay staging previo). Si no te dio
lista, usa `git status --short` y `git diff` para ver todo lo pendiente de commitear.
2. Lee cada archivo cambiado con suficiente contexto alrededor (no solo las líneas del diff) para entender
si el cambio es correcto en su entorno real.
3. Busca, en este orden de prioridad:
- **Bugs de lógica**: casos borde no manejados, condiciones invertidas, off-by-one, null/undefined no
considerados, promesas no esperadas, errores no propagados/silenciados.
- **Seguridad**: inyección (SQL/comandos/HTML), secretos o credenciales hardcodeados, validación de
input faltante, exposición de datos sensibles en logs.
- **Simplificación / duplicación**: código repetido que podría reusar algo existente, complejidad
innecesaria, patrones inconsistentes con el resto del proyecto.
4. No corrijas nada tú mismo. Tu output es un informe para que quien te invocó decida si commitea tal cual
o pide una corrección (a un `developer`).
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
ARCHIVOS REVISADOS
- path/a/archivo1.ts
- path/a/archivo2.ts
HALLAZGOS BLOQUEANTES (deben corregirse antes de commitear)
- archivo:línea — descripción del problema y por qué es bloqueante
HALLAZGOS RECOMENDADOS (no bloquean, pero conviene corregir)
- archivo:línea — descripción
NITS (opcionales, estilo/preferencia)
- archivo:línea — descripción
VEREDICTO: APTO PARA COMMIT / REQUIERE CORRECCIÓN
```
Si no hay hallazgos en una categoría, escribe "ninguno". Sé concreto y accionable — cada hallazgo debe
poder convertirse directamente en una instrucción para un `developer` de corrección.
```
**`/home/aleleba/.claude/agents/qa-validator.md`**
```markdown
---
name: qa-validator
description: Valida funcionalmente en un navegador real headless (envolviendo la skill web-ui-test) el flujo que se implementó, antes de abrir el PR. Úsalo cuando la tarea tocó una interfaz web, después de que el código relevante ya está commiteado o al menos estable. No lo uses para tareas puramente backend/CLI/librería sin interfaz web navegable.
model: inherit
effort: medium
color: green
maxTurns: 30
---
Eres un `qa-validator`: validas funcionalmente, en un navegador real headless, el flujo concreto que se
implementó. No tienes memoria de otras conversaciones: todo el contexto que necesitas (URL o descripción
del flujo, si el servidor de desarrollo ya está corriendo o hay que levantarlo, credenciales de prueba si
aplican) debe venir en el prompt de quien te invocó.
## Cómo trabajar
1. Invoca la skill `web-ui-test` (con la herramienta Skill) pasándole como argumento la URL o la
descripción del flujo a probar que recibiste en tu prompt.
2. Si quien te invocó indicó que el servidor de desarrollo YA está corriendo (y en qué puerto/URL), úsalo
directamente — no lo vuelvas a levantar. Si no hay servidor corriendo y el proyecto tiene un script de
desarrollo definible (`package.json` con `dev`/`start`), la skill `web-ui-test` ya sabe levantarlo; no
dupliques ese trabajo.
3. Sigue el ciclo de la skill: abrir → snapshot → interactuar (clicks/forms/navegación reales del flujo
concreto, no solo cargar la home) → volver a snapshot tras cada cambio de DOM → capturar evidencia →
cerrar la sesión SIEMPRE, incluso si algo falló.
4. Revisa errores de consola (`console error`) y compara el comportamiento observado contra lo que la tarea
debía lograr.
5. Si tu tarea recibida es explícitamente backend/CLI/librería sin interfaz web navegable, no ejecutes nada
de Playwright: repórtalo como "omitido — no aplica" y explica por qué.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
No vuelques snapshots crudos, HTML, ni el contenido de las capturas — solo rutas y resumen.
```
VEREDICTO: PASA / FALLA / OMITIDO (no aplica)
FLUJO PROBADO
- URL/flujo: ...
- Pasos ejecutados: ...
ARTEFACTOS
- .local/screenshots/...-viewport.png
- .local/screenshots/...-full.png
- .local/screenshots/...-snapshot.md
ERRORES DE CONSOLA
- (cantidad y detalle, o "ninguno")
HALLAZGOS (bugs de UX/funcionalidad si los hay)
- ...
ESTADO DE LA SESIÓN PLAYWRIGHT: cerrada / falló el cierre (requiere kill-all, ya ejecutado)
```
Si el veredicto es FALLA, sé específico sobre qué paso falló y qué se esperaba, para que quien te invocó
pueda corregirlo (con un `developer`) y volver a invocarte.
```
**`/home/aleleba/.claude/agents/pr-shipper.md`**
```markdown
---
name: pr-shipper
description: Abre el PR final invocando la skill aleleba-pr (commit final si hiciera falta, push, creación del PR). Nunca mergea. Úsalo como el último paso antes de notificar al usuario, una vez que todos los commits ya están hechos y la validación de qa-validator pasó (o se omitió justificadamente).
model: inherit
effort: low
color: purple
maxTurns: 15
---
Eres un `pr-shipper`: tu única función es invocar la skill `aleleba-pr` para llevar el trabajo ya
commiteado hasta un PR abierto. No tienes memoria de otras conversaciones: la descripción de la tarea y
cualquier contexto para el título/cuerpo del PR deben venir en el prompt de quien te invocó.
## Cómo trabajar
1. Invoca la skill `aleleba-pr` (con la herramienta Skill) pasándole como argumento la descripción de la
tarea que recibiste, para que la use como contexto extra del título/cuerpo del PR.
2. Sigue el pipeline de la skill tal cual está definido (verificación de rama protegida, detección de
plataforma GitHub/Gitea, commit de cualquier pendiente con HEREDOC y sin `git add -A`, push, creación
del PR con resumen en inglés).
3. **Sobre la confirmación de push**: corres dentro de un agente autónomo en background sin usuario
interactivo disponible en este momento — la autonomía para operaciones de rutina (incluyendo el push de
esta tarea) ya fue otorgada de antemano por quien te invocó. No te quedes esperando una confirmación que
nadie va a dar; procede con el push. La única excepción real es la regla de seguridad más fuerte de la
skill: si detectas que sigues en una rama protegida (`main`/`master`/`dev`) o que el diff incluye
cambios claramente fuera del alcance de la tarea (archivos de andamiaje como `TASK.md`, `PLAN.md`,
`.claude/settings.local.json`), DETENTE y repórtalo como bloqueo en vez de continuar.
4. Nunca hagas merge del PR ni de ninguna rama — tu trabajo termina en el PR creado.
5. Verifica antes de terminar que el mensaje de commit y el cuerpo del PR NO contienen ninguna atribución a
Claude (`Co-Authored-By: Claude`, "Generated with Claude Code", 🤖, etc.) — regla absoluta.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
PLATAFORMA: GitHub / Gitea
RAMA: {branch-name} → BASE: {base-branch}
COMMITS INCLUIDOS EN EL PR: (lista breve, hash + mensaje)
PR: {url del PR}
ARCHIVOS DE ANDAMIAJE EXCLUIDOS: (confirmar que TASK.md/PLAN.md/settings.local.json no se colaron)
ESTADO: DONE / BLOQUEADO (con motivo)
```
```
**`/home/aleleba/.claude/agents/docmost-reporter.md`**
```markdown
---
name: docmost-reporter
description: Actualiza la subpágina de Docmost del agente-hijo con un checkpoint de avance (fase completada, hash de commit, archivos cambiados, decisiones, bloqueos) usando el MCP mcp__docmost__*. Úsalo después de cada commit o fase importante — no solo al final — pasándole todo el contexto del checkpoint en el prompt, ya que no tiene memoria de invocaciones anteriores.
model: inherit
effort: low
color: cyan
maxTurns: 10
---
Eres un `docmost-reporter`: actualizas la subpágina de Docmost de un agente-hijo con un checkpoint de
avance. No tienes memoria de otras conversaciones ni de invocaciones anteriores tuyas: todo lo que debes
escribir (pageId, spaceId, y el contenido del checkpoint) debe venir completo en el prompt.
## Cómo trabajar
1. Lee primero la página con `mcp__docmost__get_page({pageId})` para ver su contenido actual antes de
escribir nada encima — nunca sobrescribas a ciegas.
2. Actualiza con `mcp__docmost__update_page` incorporando el checkpoint que te pasaron: fase/commit
completado, hash del commit (si aplica), archivos cambiados, decisiones de diseño y por qué, resultado
de validación funcional (si te lo pasaron), y estado.
3. **Regla de formato conocida de Docmost**: `update_page` NO recrea correctamente tablas markdown (se
"colapsan" a una sola línea). Para las secciones que se editan repetidamente (Progreso, Razonamiento y
decisiones, Subagentes utilizados, Archivos modificados) usa **listas** (`- item`), no tablas, cuando
hagas `update_page`. Las tablas (`| --- |` en varias líneas) solo funcionan bien cuando la página se creó
con `create_page` — no las reintroduzcas al editar con `update_page`.
4. Si te pidieron también actualizar la fila del agente en la página "Agentes Activos" (otro `pageId`
distinto), hazlo con el mismo cuidado: es una lista de filas, no reescribas toda la página, solo la fila
correspondiente.
5. Si el checkpoint indica un bloqueo, pon el campo Estado = "esperando-aprobación" y describe exactamente
qué se necesita, tal como te lo pasaron.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
PÁGINA(S) ACTUALIZADA(S): pageId(s) y nombre
CONTENIDO ESCRITO: (resumen fiel de lo que quedó en la página — si el MCP falló, el texto completo que
se intentó escribir, para que quien te invocó pueda reintentar)
RESULTADO DE LAS LLAMADAS MCP: éxito / error (con el mensaje de error si lo hubo)
```
```
**`/home/aleleba/.claude/agents/mailer.md`**
```markdown
---
name: mailer
description: Envía el correo SMTP de bloqueo o finalización del agente-hijo, reusando el comando curl estándar con las env vars AGENT_NAME/GMAIL_ADDRESS/GMAIL_APP_PASSWORD/PROJECT_NAME ya presentes en la sesión tmux. Úsalo en vez de correr el curl directamente, cada vez que el agente-hijo necesite notificar al usuario (bloqueo, necesita input, o finalización de tarea).
model: inherit
effort: low
color: orange
maxTurns: 5
---
Eres un `mailer`: tu única función es enviar UN correo SMTP con el asunto y cuerpo que te pasaron. No
tienes memoria de otras conversaciones: el asunto y el cuerpo ya deben venir redactados en tu prompt.
## Cómo trabajar
1. Verifica que las env vars estén presentes antes de intentar enviar:
```bash
[ -z "$GMAIL_ADDRESS" ] && echo "FALTA GMAIL_ADDRESS"
[ -z "$GMAIL_APP_PASSWORD" ] && echo "FALTA GMAIL_APP_PASSWORD"
```
Si falta alguna, repórtalo como fallo — no inventes valores ni las pidas por otro canal.
2. Envía exactamente este comando, sustituyendo `Subject` y el cuerpo por lo que te pasaron (mantén el
formato HEREDOC):
```bash
curl --ssl-no-revoke -s --url 'smtps://smtp.gmail.com:465' \
--user "$GMAIL_ADDRESS:$GMAIL_APP_PASSWORD" \
--mail-from "$GMAIL_ADDRESS" --mail-rcpt "$GMAIL_ADDRESS" --upload-file - <<EOF
From: Claude Orchestrator <$GMAIL_ADDRESS>
To: $GMAIL_ADDRESS
Subject: {asunto que te pasaron}
{cuerpo que te pasaron}
EOF
```
3. **Nunca imprimas `$GMAIL_APP_PASSWORD` en texto plano** en tu reporte ni en ningún log — el comando lo
usa vía variable de entorno, no lo repitas literal.
4. Revisa el código de salida de `curl`. Si fue distinto de 0, repórtalo como fallo con el error mostrado.
## Reporte final (OBLIGATORIO — es lo único que quien te invocó va a ver de ti)
```
RESULTADO: enviado / falló (con motivo)
ASUNTO USADO: {asunto exacto}
DESTINATARIO: $GMAIL_ADDRESS
RECORDATORIO: quien te invocó NO debe cerrar su sesión tras este correo — debe quedarse esperando la
respuesta del usuario vía tmux send-keys.
```
```
Ninguno de los 6 fija `background: true`: el protocolo de commits secuenciales exige que el agente-hijo
reciba el resumen completo de cada `developer` antes de decidir el siguiente `git add`/commit, así que
necesita invocaciones síncronas.
### 2. Editar `/home/aleleba/.claude/skills/agent-orchestrator/SKILL.md`
Todas las ediciones caen dentro del bloque `TASK.md` de FASE 3 (lo que el agente-hijo recibe y ejecuta).
FASE 1, 2 y 4 (acciones de la sesión madre) no cambian.
**a) Sección "NOTIFICAR AL USUARIO POR CORREO (SMTP)"** (líneas ~390-414) — reemplazar el `curl` inline por
delegación al subagente `mailer`:
```
NOTIFICAR AL USUARIO POR CORREO (SMTP) — vía el subagente `mailer`
- **Invoca el subagente `mailer` SIEMPRE que necesites input del usuario o tengas un problema que no puedas
resolver solo.** No esperes a terminar la tarea.
- **Invócalo cuando**:
- Te bloqueas con un error que no puedes resolver (API errors, permisos, dependencias faltantes, etc.)
- Necesitas que el usuario te dé una decisión o aclaración
- Encuentras un problema crítico en el codebase que cambia el alcance
- Terminas la tarea (paso 5 de "PASOS OBLIGATORIOS ANTES DE TERMINAR", ya documentado)
- **Qué pasarle**: AGENT_NAME, PROJECT_NAME, y el asunto + cuerpo del correo ya redactados por ti.
- **Importante**: el correo es tu forma de salir del bloqueo. Es mejor enviar un correo que quedarse
atascado.
- **NO cierres tu sesión ni termines el proceso después de que `mailer` envíe CUALQUIER correo.** Te quedas
esperando en el mismo turno la respuesta del usuario vía `tmux send-keys`. Nunca des la tarea por cerrada
hasta que el usuario apruebe explícitamente ("apruebo $AGENT_NAME") o te indique una corrección.
```
**b) Sección "SUBAGENTES (herramienta Task)"** (líneas ~439-446) — es la sección que el usuario señaló como
desactualizada (pedía listar MCP en `tools:`). Reemplazar por el catálogo de los 6 subagentes, cuándo usar
cada uno, y el protocolo de concurrencia:
```
SUBAGENTES (herramienta Task)
- Tienes 6 subagentes custom globales en ~/.claude/agents/: `developer`, `code-reviewer`, `qa-validator`,
`pr-shipper`, `docmost-reporter`, `mailer`. Todos usan `model: inherit` (heredan tu modelo) y NO
restringen `tools:` — heredan TODAS tus herramientas, incluidos TODOS los MCP conectados (docmost, gitea,
github, atlassian, penpot, etc.), sin que haga falta listarlos. No necesitas decirles nada sobre permisos
de MCP en el prompt.
- Los subagentes comparten tu mismo filesystem y worktree — NO crean su propio worktree ni rama.
Cuándo invocar cada uno:
- `developer` — implementa una porción acotada de código dentro de archivo(s) que TÚ le asignas
explícitamente. Puedes lanzar VARIOS `developer` EN PARALELO (varias llamadas Task en un solo mensaje)
cuando el trabajo se divide en archivos DISJUNTOS — asigna a cada uno una lista que no se solape con la
de los demás.
- `code-reviewer` — revisa el diff de uno o varios `developer` ANTES de commitear: bugs, seguridad,
simplificación. Solo inspecciona, nunca edita ni commitea.
- `qa-validator` — envuelve `web-ui-test` para validar funcionalmente en navegador real, antes del PR.
- `pr-shipper` — envuelve `aleleba-pr` para abrir el PR al final. Nunca mergea.
- `docmost-reporter` — actualiza tu subpágina de Docmost con un checkpoint. Invócalo tras CADA commit o
fase importante, no solo al final.
- `mailer` — envía el correo SMTP (bloqueo o finalización) en vez de correr el curl tú mismo.
PROTOCOLO DE CONCURRENCIA (CRÍTICO — evita carreras en git)
- Los `developer` NUNCA hacen `git add` ni `git commit` — solo editan sus archivos asignados y te
devuelven un resumen completo. El commit lo haces SIEMPRE TÚ, nunca un subagente.
- Si lanzaste varios `developer` en paralelo, espera a que TODOS terminen. Opcionalmente lanza UN
`code-reviewer` sobre el diff agregado de todo el lote (es solo lectura, no hay carrera en revisar en
paralelo a los commits). Luego procesa el commit de forma SECUENCIAL: por cada developer, en orden,
`git add <sus archivos>` + commit, ANTES de pasar al siguiente. Nunca mezcles `git add` de dos
developers en un mismo commit salvo que la tarea lo requiera explícitamente.
- Cada subagente no tiene memoria compartida ni contexto persistente: su respuesta final de texto es LO
ÚNICO que vas a ver de él. Dale en el prompt solo lo que no pueda averiguar por sí mismo (rutas exactas,
decisiones ya tomadas, resultados de otros subagentes que necesite) — evita pegarle diffs completos o
transcripciones largas si puede obtenerlos él mismo con Read/Grep/git diff. Usa su resumen para alimentar
a `docmost-reporter` y para responder si la sesión madre te pregunta algo.
```
**c) Sección "DOCMOST DURANTE EL TRABAJO"** (líneas ~448-464) — reemplazar llamadas MCP directas por
delegación a `docmost-reporter`:
```
DOCMOST DURANTE EL TRABAJO — vía el subagente `docmost-reporter` — ¡OBLIGATORIO, NO LO OLVIDES!
- Tu subpágina está bajo "Agentes Activos" en el space del proyecto $PROJECT_NAME. Su pageId te lo pasa la
madre en TASK.md. Pásaselo (junto al spaceId) a `docmost-reporter` en CADA invocación — no asumas que lo
recuerda de una invocación anterior.
- **CHECKPOINT OBLIGATORIO: invoca `docmost-reporter` al terminar CADA fase / tras CADA commit importante**
— no solo al final. Pásale: qué hiciste, decisiones y por qué, y el progreso (fase X ✅).
- **REPORTAR POR CADA COMMIT Y CADA TAREA:**
- Cada commit → invoca `docmost-reporter` con: hash, archivos cambiados, qué hiciste.
- Cada tarea del checklist completada → invoca `docmost-reporter` con: nombre de la tarea, qué hiciste,
resultado.
- No esperes al final para documentar.
- Si te bloqueas, ANTES de invocar `mailer`, invoca `docmost-reporter` para poner estado =
"esperando-aprobación" y describir exactamente qué necesitas y por qué.
```
**d) "CHECKLIST DE TAREAS"** (líneas ~466-474) — referenciar los subagentes correspondientes en cada ítem
(implementación → developer/code-reviewer, validación → qa-validator, PR → pr-shipper, docs →
docmost-reporter).
**e) "PASOS OBLIGATORIOS ANTES DE TERMINAR"** (líneas ~476-508) — reescribir los pasos 2-6 para invocar
`qa-validator`, `pr-shipper`, `docmost-reporter` y `mailer` en vez de hacerlo inline, manteniendo intacta la
regla de no cerrar sesión tras el correo.
**f) "REFERENCIAS RÁPIDAS"** (líneas ~637-642) — aclarar que `aleleba-pr` y `web-ui-test` ya no se invocan
directamente desde el agente-hijo, sino a través de `pr-shipper` y `qa-validator` respectivamente; añadir
referencia a los 6 subagentes.
## Decisiones de diseño relevantes (por qué)
- **`code-reviewer` corre una vez por lote, no una vez por developer**: revisar el diff agregado tras que
terminan todos los developers de un lote paralelo detecta inconsistencias *entre* sus cambios que un
reviewer aislado no vería, y es más simple que orquestar N reviews.
- **`pr-shipper` no espera confirmación interactiva de push**: `aleleba-pr` fue pensada para uso con
usuario presente y pide confirmar antes del push, pero el agente-hijo ya opera con autonomía total
delegada por TASK.md (comportamiento ya existente hoy, sin cambios) — se documenta explícitamente en
`pr-shipper.md` que esa autorización ya fue dada de antemano, sin tocar el diseño de `aleleba-pr` en sí.
La salvaguarda de rama protegida / archivos de andamiaje se mantiene como límite duro.
- **`docmost-reporter` usa listas, no tablas, al editar**: reutiliza el bug ya documentado en el
Troubleshooting del propio `SKILL.md` (`update_page` colapsa tablas markdown) — solo el `create_page`
inicial de la madre (sin cambios) sigue usando tablas.
- **Ningún subagente gestiona procesos de servidor de larga duración**: se prohíbe explícitamente en
`developer.md`; solo `qa-validator` (vía `web-ui-test`) decide si levanta el dev server, evitando
servidores huérfanos entre subagentes.
## Verificación
1. Confirmar que los 6 archivos se crearon en `~/.claude/agents/` con frontmatter válido (`name`,
`description`, `model: inherit`, sin `tools:`) — revisar que no haya errores de YAML.
2. Releer el `SKILL.md` completo tras la edición para confirmar que ningún punto del bloque `TASK.md`
siga instruyendo al agente-hijo a llamar `mcp__docmost__*`, el `curl` de correo, `web-ui-test` o
`aleleba-pr` directamente sin pasar por su subagente.
3. Prueba funcional real (recomendada antes de dar por bueno el cambio): la próxima vez que se lance un
agente-hijo con esta skill, confirmar en su transcript JSONL que efectivamente invoca `Task` con
`subagent_type` igual a alguno de los 6 nombres, que los `developer` no aparecen haciendo `git commit`
en su propio turno, y que el agente-hijo es quien commitea después.
@@ -0,0 +1,65 @@
# GFIBER-708 (Spike A) — Propuesta técnica: replicar Triple T dentro de Upsell 2.0
## Contexto
**GFIBER-708** ("Spike A - Create experiment to replicate the Triple T functionality in Upsell 2.0") es un ticket tipo Spike sin descripción, asignado a Alejandro, estado *In Development*. Existe un hermano **GFIBER-709** ("Spike B", mismo título, asignado a Christian Barillas, estado *Blocked*) — son dos experimentos/propuestas paralelas que el equipo comparará antes de decidir el camino. **GFIBER-609** ("Take down the old GCP deployments for Triple T and Upsell", estado Analysis) confirma que el destino final es decomisionar el stack GCP legacy una vez migrado.
**Triple T** hoy es una herramienta de troubleshooting asistido por IA sobre GCP (FastAPI + LangGraph + Firestore), basada en un **grafo de nodos**: el agente de call center sigue una guía paso a paso (ej. "¿el cliente no tiene wifi? → revisar conexión del router → revisar luz del router → …"), con los nodos agrupados en clusters ("initial triage", "diagnóstico de layer físico") por un LLM de clustering. Corre en un proyecto GCP separado del stack Next.js/Supabase de este repo (ver página Docmost `Triple_T_—_Arquitectura_(GCP-Backend)`).
El usuario (asignado al Spike A) quiere que esta propuesta gire alrededor de un **chat agéntico** (no un árbol de clicks) que ayude al agente en lenguaje natural, pero que **siga fielmente el procedimiento definido** — sin inventar ni saltarse pasos. Discutimos y descartamos explícitamente:
- **LoRA fine-tuning**: no iterable (cualquier cambio al procedimiento exige re-entrenar), no garantiza orden estricto, fuera de escala para 2 ingenieros/3 meses.
- **RAG puro** (similitud semántica sobre texto libre): bueno para Q&A abierto, pero no lleva estado de "qué paso ya se hizo" ni garantiza no saltarse pasos del procedimiento.
- **Árbol de decisión puro** (el approach legacy): garantiza el orden pero es rígido y no conversacional.
Landing en: **chat agéntico con tool-calling sobre un árbol de troubleshooting versionado como JSON** (sin RAG como columna vertebral). Confirmado con el usuario. Ya existe en este repo el 80% de la infraestructura necesaria:
- [`app/(protected)/upsell-evaluator/api/copilot/route.ts`](app/(protected)/upsell-evaluator/api/copilot/route.ts) — loop agéntico acotado (`MAX_TOOL_ROUNDS`) contra Fuelix (gateway Claude de la empresa), con tools de solo-lectura ejecutadas server-side.
- [`utils/ai/copilot.ts`](utils/ai/copilot.ts) — `buildSystemPrompt` (base + "gem" persona), `COPILOT_TOOLS` (catálogo de tools como data), parsing de resultado estructurado.
- [`utils/ai/copilot-tools.ts`](utils/ai/copilot-tools.ts) — ejecución real de las tools contra Supabase.
- [`utils/ai/fuelix.ts`](utils/ai/fuelix.ts) — cliente Anthropic apuntado a Fuelix.
- Tabla `gems` (migración `20260606120000_create_gems.sql`) — ya permite inyectar instrucciones adicionales por sesión/persona.
**Supabase no soporta un motor de grafos nativo** (Apache AGE no está entre las extensiones habilitadas, y tiene problemas de compatibilidad de versión) — pero no lo necesitamos, y tampoco conviene modelarlo como tablas relacionales (`nodes`+`edges`): Triple T hoy es **un solo árbol** ("Internet Troubleshooting"), generado como unidad desde un Google Doc, y su escalabilidad futura pensada es "un árbol por guía adicional" (varios documentos pequeños, no un grafo gigante interconectado). Eso es exactamente el caso donde **modelar el árbol completo como JSON versionado** gana: hay precedente directo en el propio ecosistema — la tabla `configuration` de Apsel (BigQuery) versiona configuraciones completas del clasificador con `status active/inactive`, subiendo una versión nueva y despromoviendo la anterior sin tocar código. El mismo patrón acá: una tabla `troubleshooting_guides` (Postgres/Supabase) con columna `tree jsonb` + `version` + `status active/inactive`. El agente carga el árbol activo completo (o el subárbol relevante) y lo navega con funciones puras en TypeScript — no SQL, no recursive CTEs. Relacional (nodos/edges) seguiría siendo mejor *si* más adelante se necesitara edición de nodos individuales vía UI de admin o analytics por nodo vía SQL, pero eso no es parte del alcance de esta spike.
**Adenda — persistencia de datos capturados durante el chat de troubleshooting:** el usuario notó que el plan original no contemplaba que Upsell Evaluator ya captura datos del cliente/ticket, y preguntó si convendría construir un **cliente y servidor MCP** para que el chat guarde datos mencionados en la conversación. Investigación + exploración del código (agente Explore) concluyeron que **no** — MCP resuelve el problema de exponer un catálogo de tools a *múltiples clientes de IA distintos* o de aislar ejecución por gobernanza/auditoría; aquí el copilot vive en el mismo runtime Next.js que ejecutaría la tool, así que MCP solo agregaría latencia, overhead de tokens y un proceso más que mantener, sin ningún beneficio real (ver fuentes: [supabase/supabase#679](https://github.com/supabase/supabase/discussions/679), comparativa MCP vs tool-calling nativo 2026). Se confirmó además que las tools actuales (`utils/ai/copilot-tools.ts`) son **estrictamente de solo lectura** (`get_client_by_gaid`, `search_clients`, `recent_tickets`, `sales_summary` — solo `select`, nunca mutan) — este sería el primer caso de escritura del catálogo.
El usuario precisó el alcance: los datos a persistir son del **ticket** (`upsell_tickets`), no del perfil del cliente (`clients`) — específicamente datos de troubleshooting (ej. qué se revisó, qué encontró el agente), separados de la razón de la llamada que **ya se captura hoy** vía la columna existente `upsell_tickets.issue_type` (enum, incluye `internet_wifi`). Y quiere que el guardado no sea autónomo: **el agente de IA propone, el humano confirma con un click** antes de persistir — mismo criterio de seguridad que ya aplican a otros campos sensibles del ticket (`initiation_type`, `objection_handled_correctly`, `sales_coach_notes` — todos con Server Actions dedicadas y restricción de rol).
Dado ese "confirmar con click", **no hace falta una tool con efecto de escritura para el LLM** — alcanza con extender el mismo contrato de salida estructurada que ya usa el copilot (`CopilotResult` en `utils/ai/copilot.ts`, hoy `{ mood, replies, objection?, next_action? }`) con un campo nuevo `proposed_troubleshooting_data?: { label, value }[]`, renderizarlo como chips/botones de confirmación en la UI de chat, y persistir el click con un nuevo Server Action (mismo patrón que `saveClientSurvey`/`updateTicket` en `app/(protected)/upsell-evaluator/actions.ts`) que hace un append al nuevo campo jsonb del ticket.
## Entregable
Documento de **propuesta técnica** (no código de producto) en **Docmost**, siguiendo el patrón ya usado por otros planes del space GFiber (ej. `Upsell_State_Override_—_Plan_Historial_de_Cambios`, `GFIBER-673_—_Propuesta_Tutorial_in-app`).
**Ya creado en esta sesión** (turno anterior):
- Página padre: **`GFIBER-708_—_Propuesta_Troubleshooting_Agéntico_en_Upsell_2.0`** (id `019fa9bc-84e5-76d9-b8d1-ec8bba0f7cff`), hija de `GFiber_Pilot_Extension`.
- Subpágina **`Plan_(English)`** (id `019fa9bd-d66c-7462-9b1a-c0855ade6f37`).
- Subpágina **`Plan_(Español)`** (id `019fa9bd-4959-76ee-8a92-9f2861e87e2e`).
**Pendiente en esta iteración:** actualizar (`mcp__docmost__update_page`) ambas subpáginas para agregar la nueva sección 6 (persistencia de datos de troubleshooting en el ticket) descrita abajo — no se recrean las páginas, se extienden.
Ambas versiones deben cubrir el mismo contenido (no es un resumen vs. detalle — son traducciones equivalentes, como en los planes previos del space).
## Contenido del documento
1. **Resumen ejecutivo** — qué es Triple T hoy, por qué se está evaluando reemplazarlo/replicarlo dentro de Upsell 2.0, y que este documento es la propuesta "Spike A" (mencionar la existencia de "Spike B" en paralelo, sin asumir su contenido).
2. **Comparación de 4 enfoques** con tradeoffs concretos (árbol de decisión, RAG puro, fine-tuning/LoRA, agéntico+árbol-versionado) — la tabla de razones ya desarrollada en la conversación.
3. **Arquitectura recomendada**:
- Modelo de datos: tabla `troubleshooting_guides` en Supabase Postgres con columna `tree jsonb` (el árbol completo: nodos, contenido en markdown, símbolos de síntomas/triggers, referencias a nodos siguientes, agrupación por cluster) + `version` + `status active/inactive` — mismo patrón de versionado que `configuration` de Apsel. Reemplaza el grafo de Firestore. Sin extensión de grafos, sin recursive CTEs: el árbol activo se carga completo y se navega con funciones puras en TypeScript.
- Nuevas tools de agente (mismo esquema que `COPILOT_TOOLS`): p. ej. `get_next_candidate_steps(symptoms | current_node_id)`, `get_node(id)`, `search_nodes_by_symptom(text)` — operan sobre el JSON ya cargado en memoria, no hacen queries SQL por nodo. Todas de solo lectura, ejecutadas server-side como las existentes en `copilot-tools.ts`.
- System prompt / "gem" de troubleshooting: instrucción explícita de **nunca inventar ni saltarse un paso** — siempre grounded en el resultado de una tool call antes de sugerir la siguiente acción al agente/cliente.
- Reuso del loop agéntico ya existente en `copilot/route.ts` (bounded tool rounds, Fuelix) — nueva ruta o extensión de la existente, a definir en el documento como pregunta abierta de implementación (no se decide en esta spike).
- Migración de contenido: el árbol actual "Internet Troubleshooting" (hoy en Firestore, generado desde un Google Doc) se migraría a un único registro `tree jsonb` en `troubleshooting_guides` — mencionar como trabajo de datos, no de código, fuera del alcance de esta spike.
4. **Qué NO cubre esta spike** — no incluye el pipeline de generación automática desde Google Doc ni el LLM-clustering de Triple T original (se asume carga estática/manual de nodos por ahora); no incluye migración de datos real ni decommission de GCP (eso es GFIBER-609, posterior).
5. **Próximos pasos** — validación con Morossini/equipo, comparación con Spike B (GFIBER-709) cuando se desbloquee, y qué se necesitaría para pasar de spike a implementación real.
6. **Persistencia de datos de troubleshooting en el ticket** (sección nueva):
- **¿Por qué no MCP?** — un párrafo explicando que MCP resuelve exposición de tools a múltiples clientes de IA o aislamiento por gobernanza, ninguno de los cuales aplica aquí (copilot y tool corren en el mismo proceso Next.js); agregar MCP sumaría latencia/overhead de tokens sin beneficio. Se reutiliza el mismo tool-calling nativo (Anthropic) ya implementado.
- **Modelo de datos:** nueva columna en `upsell_tickets` (migración nueva, ej. `troubleshooting_log jsonb not null default '[]'::jsonb`) — un array de entradas confirmadas `{ node_id, label, value, confirmed_at }`, por-ticket (no por-cliente): cada llamada/sesión de troubleshooting es su propio registro, coherente con que `upsell_tickets` ya es el log append-only por interacción. Distinto de `clients.discovery_answers` (perfil estable del hogar) y de `upsell_tickets.issue_type` (la razón de la llamada, que **ya existe** como enum — ej. `internet_wifi` — y no requiere cambios).
- **Sin tool de escritura para el LLM:** dado que el usuario quiere confirmación humana con un click (no autoguardado silencioso), basta con extender el contrato de salida estructurada que ya devuelve el copilot (`CopilotResult` en `utils/ai/copilot.ts`) con un campo opcional `proposed_troubleshooting_data?: { label: string; value: string }[]`. La UI de chat renderiza cada propuesta como un chip/botón "Guardar"; al hacer click se llama un Server Action nuevo (mismo patrón que `saveClientSurvey`/`updateTicket` en `app/(protected)/upsell-evaluator/actions.ts`) que hace `append` al array `troubleshooting_log` del ticket activo.
- **GAID + Case ID obligatorios al inicio de la conversación:** el chat de troubleshooting debe pedir el **GAID** y el **Case ID** como primer paso, antes de cualquier diagnóstico — son identificadores obligatorios y ya compartidos entre sistemas (GAID identifica al cliente/`clients.gaid`, Case ID cruza con el sistema de case-management vía `upsell_tickets.case_id`, ambos ya existentes en el esquema). Esto no es un dato más a proponer-y-confirmar como el resto: es el paso de identificación que resuelve **a qué ticket** pertenece la sesión — reutiliza `lookupClient(gaidInput)` (ya existente en `actions.ts`) para resolver/crear el `client_id`, y el mismo ticket en curso (identificado por `case_id`) es al que se le hace `append` en `troubleshooting_log`. Sin estos dos datos, no hay ticket al cual asociar ninguna propuesta de guardado — por eso van primero, no como una propuesta más entre otras.
- **Fuera de alcance de esta spike:** la implementación real de la migración, el Server Action, y el cambio de UI — esta sección documenta el diseño para que el equipo lo evalúe junto con el resto de la propuesta, no lo construye.
## Verificación
- Ambas subpáginas (`Plan_(English)`, `Plan_(Español)`) actualizadas con la sección 6 nueva, vía `mcp__docmost__update_page`, verificado con `mcp__docmost__get_page` tras la escritura.
- Contenido revisado por el usuario antes de considerar la spike cerrada — no se toca el estado del ticket Jira GFIBER-708 a menos que el usuario lo pida explícitamente, y no se ejecuta ninguna migración de Supabase real (esto es solo el documento de propuesta).
@@ -0,0 +1,64 @@
---
context: Unificar las skills del ecosistema de agentes en background en una sola skill `agent-orchestrator`, manteniendo separadas `aleleba-pr`, `docmost-context` y `web-ui-test`.
---
# Plan: Consolidar skills de agentes en `agent-orchestrator`
## Contexto
Actualmente el ecosistema de agentes en background está fragmentado en **7 skills + 1 comando** separados:
| Skill | Archivo | Función |
|-------|---------|---------|
| `agent-orchestrator` | `.claude/skills/agent-orchestrator/SKILL.md` | Orquestador principal (trigger, fases, referencias) |
| `agent-launcher` | `.claude/skills/agent-launcher/SKILL.md` | Flujo de lanzamiento (detectar proyecto, crear worktree, tmux, Docmost) |
| `agent-monitor` | `.claude/skills/agent-monitor/SKILL.md` | Monitoreo de sesiones (JSONL, tmux, desbloqueo) |
| `agent-instructions` | `.claude/skills/agent-instructions/SKILL.md` | Template TASK.md para agentes autónomos |
| `agent-archiver` | `.claude/skills/agent-archiver/SKILL.md` | Validación y archivo de agentes completados |
| `agent-safety` | `.claude/skills/agent-safety/SKILL.md` | Reglas de seguridad compartidas |
| `agent-troubleshooting` | `.claude/skills/agent-troubleshooting/SKILL.md` | Tabla de problemas conocidos y soluciones |
| `aprobar` | `.claude/commands/aprobar.md` | Comando para validar/archivar agentes |
Las siguientes **3 skills se mantienen separadas** (no se consolidan):
- **`aleleba-pr`** — Pipeline de entrega (commit, push, PR). Referenciada por el orquestador pero con lógica independiente.
- **`docmost-context`** — Carga de contexto del proyecto desde Docmost. Se activa automáticamente vía hook.
- **`web-ui-test`** — Pruebas de UI con Playwright headless. Referenciada en el checklist de TASK.md.
## Plan de ejecución
### Paso 1: Unificar contenido en `agent-orchestrator/SKILL.md`
Crear un nuevo archivo `.claude/skills/agent-orchestrator/SKILL.md` que contenga **todo** el flujo de agente en background, organizado en secciones claras:
1. **Trigger** — Cuándo activarse (frases del usuario)
2. **Reglas de seguridad** — Reglas de merge, atribución a Claude, aislamiento (de `agent-safety`)
3. **Fase 1: Lanzar** — Flujo completo de `agent-launcher` (detectar proyecto, crear worktree, tmux, Docmost, verificación)
4. **Fase 2: Monitoreo** — Checks de `agent-monitor` (JSONL, tmux, desbloqueo, send-keys)
5. **Fase 3: Instrucciones del agente** — Template TASK.md de `agent-instructions` (incluye email SMTP, checklist, reglas de git, Docmost)
6. **Fase 4: Archivo** — Flujo de `agent-archiver` (leer Docmost, integrar docs, borrar subpágina, merge, cleanup)
7. **Troubleshooting** — Tabla de problemas conocidos de `agent-troubleshooting`
8. **Referencias** — Links a las 3 skills que se mantienen separadas (`aleleba-pr`, `docmost-context`, `web-ui-test`)
### Paso 2: Eliminar archivos de skills obsoletas
Eliminar los siguientes archivos:
- `.claude/skills/agent-launcher/SKILL.md`
- `.claude/skills/agent-monitor/SKILL.md`
- `.claude/skills/agent-instructions/SKILL.md`
- `.claude/skills/agent-archiver/SKILL.md`
- `.claude/skills/agent-safety/SKILL.md`
- `.claude/skills/agent-troubleshooting/SKILL.md`
- `.claude/commands/aprobar.md`**NO ELIMINAR**, mantener este comando
### Paso 3: Actualizar referencias cruzadas
Verificar que no queden referencias a las skills eliminadas en ningún otro archivo del proyecto. Las únicas referencias que deben quedar son:
- Dentro de `agent-orchestrator/SKILL.md` (referencia a `aleleba-pr`, `docmost-context`, `web-ui-test`)
- En el hook `~/.claude/hooks/docmost-session-start.sh` (referencia a `docmost-context`)
### Paso 4: Verificar
- Confirmar que `agent-orchestrator/SKILL.md` es autocontenido y funcional
- Verificar que no hay archivos huérfanos referenciando las skills eliminadas
- Confirmar que las 3 skills excluidas (`aleleba-pr`, `docmost-context`, `web-ui-test`) siguen intactas y funcionales
@@ -0,0 +1,112 @@
# GFIBER-711 — Tutorial interactivo de My Usage
## Contexto
GFIBER-711 ("Tutorial: My Usage") es la tercera subtarea de GFIBER-710 ("Implement
additional in-app tutorials") que se aborda, después de GFIBER-702 (Upsell Evaluator,
mergeado a `dev`) y GFIBER-712 (Dashboard, actualmente en curso en background vía
`agente-gfiber-712-dashboard-tour`, todavía no mergeado). Quedan además GFIBER-713 (Admin)
y GFIBER-714 (Leagues) para más adelante.
A diferencia del Dashboard (solo admin/owner), `/my-metrics` — la ruta real detrás del link
"My Usage" del NavBar — es accesible a **todos los roles autenticados** (confirmado: no hay
`layout.tsx` propio, solo el gate de autenticación general; el propio código documenta
"Accessible to all authenticated roles", con scoping de datos vía RLS por `created_by`/
`agent_id`, no por rol).
El usuario decidió lanzar el agente de GFIBER-711 **en paralelo** con el de GFIBER-712,
aceptando el riesgo de un conflicto de merge en los archivos compartidos de infraestructura
del tour (ver sección de riesgo más abajo), en vez de esperar a que Dashboard termine primero.
## Estructura real de la pantalla
`app/(protected)/my-metrics/page.tsx` (Server Component, `dynamic = "force-dynamic"`)
renderiza, en este orden:
1. Header + `<DateRangeControl>` (línea con `basePath="/my-metrics"`, `activeFilter="mine"`)
**mismo componente compartido** que usa el Dashboard (`components/dashboard/DateRangeControl.tsx`).
2. Grid de 5 `<AgentStatCard>` (design system): Interactions, Offers Made, Accepted,
Rejected, Conversion Rate.
3. `<AdoptionCharts variant="individual">` — también compartido con el Dashboard
(`components/dashboard/AdoptionCharts.tsx`), pero el tour de Dashboard (GFIBER-712) **no**
lo ancla en su alcance aprobado, así que hoy no hay colisión de anclaje ahí.
4. Sección "Sessions" → `<SessionHistoryView showStats={false} detailBasePath="/my-metrics">`
(`app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx`, ya es `'use client'`).
5. Estado vacío (sin interacciones aún) — sin anclajes, no bloqueante, igual que el caso
análogo en Dashboard.
Grep exhaustivo confirmó: cero `data-tour` en esta pantalla o sus componentes hoy.
## Coordinación de naming con GFIBER-712 (componente compartido)
`DateRangeControl` es usado por ambos tours. Para evitar que el mismo `data-tour` fijo en el
componente diga "dashboard-*" cuando también aparece en `/my-metrics`, ya le pedí al agente
de GFIBER-712 (vía tmux, en curso) que renombre su anclaje de `dashboard-date-range` a
**`date-range-control`** (genérico, por componente, no por página que lo consume). Este plan
asume ese nombre ya renombrado y lo usa igual acá. Si por algún motivo el agente de Dashboard
no llegó a aplicarlo antes de que el de My Usage lo necesite, el agente de My Usage debe
usar `date-range-control` de todos modos (es el nombre correcto a largo plazo) y, si
encuentra `dashboard-date-range` en su lugar, renombrarlo él mismo.
## Reuso de infraestructura
- **`utils/upsell/tour-storage.ts`** — reusar tal cual, `tourId = "my-usage"` → key
`gfiber:tour-seen:my-usage`, independiente de los otros dos tours.
- **`utils/upsell/tour-bridge.ts`** — GFIBER-712 lo está refactorizando de singleton a
`Map<tourId, fn>` en paralelo, en su propio worktree, todavía sin mergear. **Riesgo de
conflicto aceptado por el usuario**: el agente de My Usage va a encontrar la versión
singleton vieja en `dev`. Debe hacer el mismo refactor (`Map<string, fn>` keyado por
`tourId`) de forma independiente, **en un commit dedicado y aislado** (sin mezclarlo con
cambios específicos de My Usage), para que sea lo más fácil posible de reconciliar cuando
ambos PRs converjan — el usuario resolverá ese conflicto de merge manualmente más adelante.
- **`components/NavBar/TourButton.tsx`** — mismo caso: GFIBER-712 lo está creando (reemplaza
a `UpsellTourButton.tsx`) con un mapa `pathname → tourId`. El agente de My Usage debe crear
este mismo componente si todavía no existe en su rama (heredada de `dev` sin ese cambio),
agregando la entrada `"/my-metrics": "my-usage"` al mapa, también en un commit aislado.
- **Patrón de `ProductTour.tsx`** — igual que Dashboard: sin `flow` (no hay máquina de
etapas), `TourController` interno + `TourProvider` + `SkipButton`/`nextButton` custom +
estilos `--fiber-*`.
## Anclajes del tour (4 pasos)
| # | `data-tour` | Elemento real | Contenido del paso |
| 1 | `date-range-control` | `<DateRangeControl>` (compartido) | "Filtra tus métricas por rango de fechas." |
| 2 | `my-usage-stats` | Grid de 5 `<AgentStatCard>` en `page.tsx` | "Tus estadísticas personales: interacciones, ofertas, aceptadas/rechazadas y tasa de conversión." |
| 3 | `adoption-charts` | `<AdoptionCharts variant="individual">` (compartido, sin uso previo por otro tour) | "Tu tendencia de adopción de la herramienta, día a día." |
| 4 | `session-history` | `<SessionHistoryView>` | "Revisa tu historial de sesiones — click en cualquier fila para ver el detalle completo." |
## Cambios a implementar
- `utils/upsell/tour-bridge.ts` — refactor a `Map<tourId, fn>` (si no llegó ya mergeado desde
GFIBER-712 al momento de implementar).
- `components/NavBar/TourButton.tsx` — crear (si no existe ya) o extender su mapa con
`"/my-metrics": "my-usage"`.
- Nuevo `app/(protected)/my-metrics/_flow/ProductTour.tsx` (o ubicación equivalente,
`'use client'`): `TOUR_ID = "my-usage"`, 4 steps de la tabla de arriba.
- `app/(protected)/my-metrics/page.tsx`: envolver el `return` con `<ProductTour>{...}</ProductTour>`.
- `data-tour` en: `components/dashboard/DateRangeControl.tsx` (ya debería tener
`date-range-control` si GFIBER-712 aplicó el rename; si no, renombrarlo), el grid de
`AgentStatCard` en `page.tsx`, `components/dashboard/AdoptionCharts.tsx`,
`app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx`.
## Riesgo aceptado
Conflicto de merge probable en `tour-bridge.ts` y `TourButton.tsx` cuando los PRs de
GFIBER-712 y GFIBER-711 converjan (ambos tocan los mismos archivos de infraestructura
compartida en paralelo). El usuario ya decidió aceptar este costo a cambio de arrancar ambos
tours en paralelo. Mitigación: instruir a ambos agentes a mantener esos cambios de
infraestructura en commits aislados y descriptivos, para facilitar un rebase/resolución
manual posterior.
## Verificación end-to-end
- Con `localStorage` vacío, login con cualquier rol (no solo admin/owner) → el tour se
dispara solo en `/my-metrics`.
- Recorrer los 4 pasos, incluido el estado con datos reales (no el estado vacío).
- Completar/Saltar → `localStorage["gfiber:tour-seen:my-usage"] === "true"`; reload no lo
re-dispara.
- Botón de reinicio en NavBar (`TourButton`) → en `/my-metrics` relanza el tour de My Usage;
en `/dashboard` y `/upsell-evaluator` sigue relanzando el suyo respectivo, sin pisarse.
- Probar con un agente sin interacciones aún (estado vacío) → confirmar que no rompe nada.
- Confirmar que el tour de Upsell Evaluator y el de Dashboard (si ya está mergeado) siguen
funcionando sin regresión tras el refactor de `tour-bridge.ts`/`TourButton.tsx`.
@@ -0,0 +1,187 @@
# Etapa 3 — Enforcement de permisos
## Contexto
Ro-ut es una plataforma de logística. Las etapas 0-2 están completadas. La Etapa 3 es el siguiente paso en el orden de ejecución: hacer que el campo `permissions` realmente proteja las operaciones GraphQL.
**Estado actual:**
- `userExtractor` (middleware en `src/server/middlewares/userExtractor.ts`) decodifica el JWT y guarda `req.usuario = decoded.sub` (string, ej. `"aleleb"`). Solo valida autenticación, **no verifica permisos**.
- Los schemas GraphQL (`user.schema.ts`, `company.schema.ts`, `office.schema.ts`) llaman a `userExtractor` en cada resolver, pero nadie lee `req.usuario` ni verifica permisos.
- `AdminUser` en `user.schema.ts` tiene `ViewUser`, `CreateUser`, `EditUser`, `DeleteUser` como booleanos, pero nunca se consultan.
- La tabla `User` en PostgreSQL tiene una columna `permissions` (JSONB) que almacena los permisos del usuario.
- El token JWT del test lleva `sub: "aleleb"` — no lleva permisos embebidos.
**Problema:** Cualquiera autenticado puede ejecutar todas las operaciones sin restricciones de permiso.
---
## Diseño de la solución
### Estrategia
1. **Crear un helper `checkPermission`** que lea `req.usuario` (username), haga query a la BD para obtener los permisos del usuario, y verifique un permiso específico.
2. **Actualizar `userExtractor`** para que después de decodificar el JWT, llame al helper y guarde `req.usuario` como un objeto con `{ id, permissions }` en lugar de solo el string.
3. **Actualizar los schemas** de User, Company y Office para verificar permisos antes de ejecutar cada operación.
4. **Actualizar los tests** para que verifiquen que los permisos se aplican correctamente.
### Patrón de permisos propuesto
```
AdminUser:
ViewUser: boolean // puede ver usuarios
CreateUser: boolean // puede crear usuarios
EditUser: boolean // puede editar usuarios
DeleteUser: boolean // puede eliminar usuarios
AdminCompany: (nuevo)
ViewCompany: boolean // puede ver empresas
CreateCompany: boolean // puede crear empresas
EditCompany: boolean // puede editar empresas
DeleteCompany: boolean // puede eliminar empresas
AdminOffice: (nuevo)
ViewOffice: boolean // puede ver oficinas
CreateOffice: boolean // puede crear oficinas
EditOffice: boolean // puede editar oficinas
DeleteOffice: boolean // puede eliminar oficinas
```
---
## Plan de implementación
### Tarea 3.1 — Verificar permisos en controllers de User
**Archivos a modificar:**
1. **`src/server/middlewares/userExtractor.ts`** — Después de `req.usuario = usuario`, hacer query a la BD para obtener los permisos del usuario y guardar `req.usuario = { id: usuario, permissions: ... }`.
2. **`src/server/GraphQL/schema/user.schema.ts`** — En cada query/mutation, verificar el permiso correspondiente antes de ejecutar:
- `user()` y `users()` → verificar `adminUser.ViewUser`
- `insertUser()` → verificar `adminUser.CreateUser`
- `editUser()` → verificar `adminUser.EditUser`
- `deleteUser()` → verificar `adminUser.DeleteUser`
**Detalle del helper `checkPermission`:**
```typescript
// src/server/middlewares/userExtractor.ts (o archivo nuevo checkPermission.ts)
import db from '@models/apiPostgresModel/config/apiPostgres-connection';
export const checkPermission = async (username: string, permissionPath: string): Promise<boolean> => {
const { rows } = await db.query(
`SELECT "permissions" FROM "User" WHERE "user" = $1`,
[username]
);
if (!rows.length || !rows[0].permissions) return false;
// permissionPath es algo como "adminUser.ViewUser"
const [namespace, key] = permissionPath.split('.');
return !!rows[0].permissions[namespace]?.[key];
};
```
**Detalle de las verificaciones en el schema:**
En cada campo async de los ObjectType, antes de llamar al controller:
```typescript
// Ejemplo para query user()
@Field( () => User, { nullable: true } )
async user(@Arg('id') id: string, @Ctx() context: { req: any; res: any; }){
await userExtractor({req: context.req, res: context.res, isGraphQL: true});
// Verificar permiso
const hasPermission = await checkPermission(
context.req.usuario.id || context.req.usuario,
'adminUser.ViewUser'
);
if (!hasPermission) {
throw new GraphQLError('No tienes permiso para ver usuarios');
}
return getUserController({ args: { id } });
}
```
### Tarea 3.2 — Verificar permisos en mutations de User
Ya cubierto por el patrón anterior en `user.schema.ts`:
- `insertUser()``adminUser.CreateUser`
- `editUser()``adminUser.EditUser`
- `deleteUser()``adminUser.DeleteUser`
### Tarea 3.3 — Definir permisos para Company y Office
**Archivos a modificar:**
1. **`src/server/GraphQL/schema/company.schema.ts`** — Agregar `AdminCompany` con `ViewCompany`, `CreateCompany`, `EditCompany`, `DeleteCompany`. Verificar en cada campo async.
2. **`src/server/GraphQL/schema/office.schema.ts`** — Agregar `AdminOffice` con `ViewOffice`, `CreateOffice`, `EditOffice`, `DeleteOffice`. Verificar en cada campo async.
3. **`src/server/GraphQL/schema/user.schema.ts`** — Agregar `AdminCompany` y `AdminOffice` al ObjectType `Permissions` para que el schema GraphQL los exponga.
**Detalle de las verificaciones:**
Para Company:
- `company()` y `companies()``adminCompany.ViewCompany`
- `insertCompany()``adminCompany.CreateCompany`
- `editCompany()``adminCompany.EditCompany`
- `deleteCompany()``adminCompany.DeleteCompany`
Para Office:
- `office()`, `offices()`, `officesByCompany()``adminOffice.ViewOffice`
- `insertOffice()``adminOffice.CreateOffice`
- `editOffice()``adminOffice.EditOffice`
- `deleteOffice()``adminOffice.DeleteOffice`
### Tarea 3.4 — Actualizar tests
**Archivos a crear/modificar:**
1. **`src/server/tests/server/user/index.test.ts`** — Agregar tests que verifiquen:
- Que un usuario sin `ViewUser` no puede ejecutar `usersQuery`
- Que un usuario sin `CreateUser` no puede ejecutar `insertUser`
- Que un usuario sin `EditUser` no puede ejecutar `editUser`
- Que un usuario sin `DeleteUser` no puede ejecutar `deleteUser`
2. **`src/server/tests/server/company/index.test.ts`** — Agregar tests similares para Company.
3. **`src/server/tests/server/office/index.test.ts`** — Agregar tests similares para Office.
**Nota crítica sobre tests y SQL:**
Los tests de integración (`npm run test:backend`) usan `src/server/sql/ro-ut_app_pg.sql` (sin usuario seed) con un token JWT fijo (`sub: "aleleb"`). Los tests E2E usan `src/server/sql/ro-ut_app_pg_with_user.sql` (con usuario seed admin con permisos completos).
Para que los tests de integración funcionen con el enforcement de permisos, necesitamos:
1. **Agregar un usuario seed a `ro-ut_app_pg.sql`** (o crear un archivo `ro-ut_app_pg_with_test_user.sql` dedicado para integration tests) con un usuario `aleleb` con permisos configurables. El CI workflow en `.gitea/workflows/main-workflow.yml` ejecuta `psql ... -f src/server/sql/ro-ut_app_pg.sql` en el job `integration-back-end-testing`.
2. **Crear un helper `createTestToken(username)`** en `src/server/tests/configTest.ts` que genere JWTs con diferentes `sub` claims, para poder probar escenarios de "sin permisos" vs "con permisos".
3. **Los tests de permisos denegados** no pueden usar la BD real (porque el usuario seed tendrá permisos). Se necesita:
- O bien crear un usuario de test con `permissions: {}` vacío
- O bien mockear la query de permisos en los tests que deben fallar
---
## Resumen de archivos a modificar
| Archivo | Acción | Tarea |
|---------|--------|-------|
| `src/server/middlewares/userExtractor.ts` | Modificar | 3.1 - Agregar query de permisos |
| `src/server/GraphQL/schema/user.schema.ts` | Modificar | 3.1, 3.2, 3.3 - Agregar verificaciones + AdminCompany/AdminOffice |
| `src/server/GraphQL/schema/company.schema.ts` | Modificar | 3.3 - Agregar verificaciones de permisos |
| `src/server/GraphQL/schema/office.schema.ts` | Modificar | 3.3 - Agregar verificaciones de permisos |
| `src/server/tests/server/user/index.test.ts` | Modificar | 3.1, 3.2 - Tests de permisos User |
| `src/server/tests/server/company/index.test.ts` | Modificar | 3.3 - Tests de permisos Company |
| `src/server/tests/server/office/index.test.ts` | Modificar | 3.3 - Tests de permisos Office |
---
## Verificación
1. `npm run test:backend` — Todos los tests existentes deben pasar (los nuevos tests verificarán el enforcement).
2. `npm run lint` — Sin errores de linting.
3. `npm run build` — Compilación limpia.
4. Manual: ejecutar un query GraphQL con un token de usuario sin permisos → debe devolver error de permisos.
@@ -0,0 +1,83 @@
# Plan: Mejorar docmost-context para lectura selectiva
## Contexto
La skill `docmost-context` actual lee **todas** las páginas de alta prioridad sin límite. En un space con muchas páginas, esto desperdicia contexto en páginas que son importantes pero no críticas. El usuario quiere que el skill sea quirúrgico: listar páginas primero, identificar las verdaderamente clave (arquitectura, estructura, estilo de código, tecnologías) y leer solo esas.
El usuario confirmó un enfoque **combinado**: siempre leer las páginas clave de arquitectura/estructura, y además leer páginas relevantes al contexto de lo que el usuario va a hacer.
## Diseño propuesto
### Cambios principales
1. **Sistema de scoring en lugar de clasificación plana** — cada página recibe un score basado en múltiples factores, no solo keywords.
2. **Lectura en dos pasos**:
- **Paso A**: Listar todas las páginas y calcular score (solo con `list_pages`, sin leer contenido)
- **Paso B**: Leer solo las páginas seleccionadas por score
3. **Límite hard de 5 páginas completas** — nunca leer más de 5 páginas en detalle.
4. **Adaptación al contexto del usuario** — si el usuario menciona testing, deploy, database, etc., se aplica un bonus a las páginas relevantes a ese tema.
5. **Lectura parcial para páginas "importantes"** — en lugar de leer todas las de media prioridad, leer solo título + snippet y decidir si vale la pena leer más.
### Estructura del scoring
```
score = keyword_score + depth_score + name_quality + topic_bonus
keyword_score: 2 puntos por cada keyword que coincida (arquitectura, estructura, stack, etc.)
depth_score: 3 para páginas raíz, 2 para nivel 1, 1 para nivel 2, 0 para nivel 3+
name_quality: 1 si el nombre es significativo, 0 si es "Untitled" o vacío
topic_bonus: 2 si la página coincide con el tema del usuario (testing, deploy, etc.)
```
**Tiers:**
- **Crítico (score >= 8)**: Leer completo. Máximo 5 páginas.
- **Importante (score 4-7)**: Leer título + snippet. Expandir solo si el snippet es relevante.
- **Nice-to-know (score < 4)**: Solo anotar título. No leer contenido.
### Reglas de selección por tamaño del space
| Total páginas | Completas | Snippets | Ignorar |
|---|---|---|---|
| 0-3 | Todas | 0 | 0 |
| 4-10 | Top 3-5 | Resto | 0 |
| 11+ | Top 5 | Siguientes 5 | Resto |
### Temas del usuario (topic bonus)
| Tema | Keywords de bonus |
|---|---|
| Testing | test, jest, vitest, cypress, playwright, e2e |
| Deploy/CI | deploy, CI/CD, pipeline, docker, vercel, github actions |
| Database | database, schema, migration, prisma, drizzle, kysely |
| UI/Components | component, design system, tailwind, theme, layout |
| API | API, endpoint, route, controller, service |
### Cambios en el SKILL.md
El archivo `SKILL.md` se reestructura en:
1. **Secciones 1-3**: Detectar proyecto, listar spaces, buscar coincidencia — **sin cambios**.
2. **Sección 4 nueva**: "Dos pasos: puntuar y leer" — reemplaza la clasificación actual.
- Paso A: Puntuar con la fórmula y los factores descritos arriba.
- Paso B: Reglas de selección según tamaño del space.
- Lectura parcial con contenido-gated expansion.
- Bonus temático basado en el contexto del usuario.
3. **Sección 5**: "Leer solo lo seleccionado" — reemplaza la lectura actual de "todas las de alta prioridad".
4. **Sección 6**: "Reportar, no transcribir" — **sin cambios**.
5. **Sección 7**: "Una sola vez" — **sin cambios**.
### Archivo a modificar
- `/home/aleleba/.claude/skills/docmost-context/SKILL.md` — el único archivo a modificar.
## Verificación
1. Invocar la skill con `Skill` tool para verificar que:
- El scoring produce resultados razonables.
- El límite de 5 páginas se respeta.
- La adaptación por tema funciona cuando el usuario menciona testing/deploy/etc.
- Los edge cases (pocas páginas, muchas páginas, sin coincidencias) se manejan bien.
@@ -0,0 +1,120 @@
# Segmentar `background-orchestrator` en skills más pequeñas
## Context
El SKILL.md actual de `background-orchestrator` tiene ~470 líneas y 31KB. Contiene TODO el flujo: lanzar agentes, monitorear, generar instrucciones, validar y archivar. Es difícil de navegar y mantener. La idea es segmentarlo en skills más pequeñas que el orchestrator orqueste en secuencia.
**Nota importante:** Las skills de Claude Code son **user-invoked** (se invocan con `/skill-name`), no se pueden llamar programáticamente desde otra skill. El orchestrator no "llama" a las otras skills como funciones — lo que hace es **guiar el flujo de conversación** en fases, y en cada fase ejecuta los pasos correspondientes (o le dice al usuario que invoque la skill correspondiente).
## Propuesta de segmentación
```
~/.claude/skills/
background-orchestrator/ # Coordinador del flujo (reducido a ~80 líneas)
agent-launcher/ # Flujo completo de lanzamiento de agente
agent-monitor/ # Monitoreo y control de agentes
agent-instructions/ # Template TASK.md para agentes
agent-archiver/ # Validación y archivo de agentes
agent-safety/ # Reglas de seguridad compartidas
agent-troubleshooting/ # Tabla de troubleshooting
```
### 1. `agent-launcher` — Flujo de lanzamiento (Section A)
- Detectar proyecto (package.json → pyproject.toml → basename)
- Pedir tarea al usuario
- Generar AGENT_NAME (slug `agente-*`)
- Crear worktree en `.worktrees/`
- Configurar permisos (settings.local.json con allow-list de MCP)
- Instalar dependencias (npm install en worktree)
- Lanzar sesión tmux (sin `| tee`, con `pipe-pane` para log)
- Capturar SESSION_ID desde JSONL
- Registrar en Docmost (página "Agentes Activos" + subpágina)
- Confirmar al usuario
### 2. `agent-monitor` — Monitoreo y control (Section B)
- Verificar transcript JSONL (líneas, tiempo de actividad)
- Detectar bloqueos (`grep "Do you want to proceed"`)
- Extraer últimas acciones del agente (jq en JSONL)
- Medir progreso en rama (commits sobre base)
- Mostrar panel limpio (sin ANSI escapes)
- Instrucciones para `tmux send-keys` (desbloquear)
- Distinguir "Ruminating" real de bloqueo real
- Leer subpágina en Docmost para resumen
### 3. `agent-instructions` — Template TASK.md (Section C)
- Bloque de instrucciones para el agente hijo
- Permisos y autonomía
- Reglas de git (commits atómicos, no merge, no Claude attribution)
- Guidelines de subagentes (Task tool, MCP access)
- Checkpoints de Docmost (por fase)
- Pasos obligatorios al terminar (web-ui-test, aleleba-pr, email)
- Template de correo de finalización
### 4. `agent-archiver` — Validación y archivo (Section D)
- Leer subpágina del agente en Docmost
- Integrar trabajo en documentación temática del proyecto
- Borrar subpágina de "Agentes Activos"
- Quitar bloque de la lista activa
- Preguntar al usuario sobre merge (advertir deploy a producción)
- Preguntar sobre eliminación de rama
- Eliminar worktree, rama, sesión tmux, artefactos
- Confirmar archival con links
### 5. `agent-safety` — Reglas de seguridad (Section REGLAS)
- Política de merge (agentes nunca mergean, madre solo con validación explícita)
- Prohibición de atribución a Claude
- Aislamiento de worktrees
- Protección de credenciales
- Referencia centralizada — otras skills citan esta en vez de duplicar
### 6. `agent-troubleshooting` — Lecciones aprendidas (Section Troubleshooting)
- Tabla de 12 problemas conocidos con causa y solución
- Regla de oro post-lanzamiento
- Referencia para diagnóstico
### 7. `background-orchestrator` — Coordinador (reducido a ~80 líneas)
- **TRIGGER**: cuándo activarse (frases exactas)
- **Referencia a agent-safety**: reglas de seguridad
- **Guía de flujo de conversación**:
- FASE 1: Lanzar → ejecutar pasos de agent-launcher
- FASE 2: Monitoreo → ejecutar pasos de agent-monitor
- FASE 3: Archivar → ejecutar pasos de agent-archiver
- **Referencias cruzadas**: "para lanzamiento, ver `/agent-launcher`", etc.
## Mapa de dependencias
```
background-orchestrator (entry point)
├── references → agent-safety (reglas de seguridad)
├── ejecuta → agent-launcher (flujo de lanzamiento)
│ ├── usa → agent-instructions (contenido de TASK.md)
│ └── usa → agent-safety (reglas de seguridad)
├── ejecuta → agent-monitor (checks de estado)
│ └── usa → agent-safety (reglas de seguridad)
├── ejecuta → agent-archiver (flujo de aprobación)
│ └── usa → agent-safety (reglas de seguridad)
└── referencia → agent-troubleshooting (referencia de debugging)
```
## Migración
1. **Crear directorios** para las 6 nuevas skills
2. **Extraer contenido** de cada sección del SKILL.md actual a su skill correspondiente
3. **Reescribir orchestrator** — mantener solo trigger, guía de flujo y referencias
4. **Verificar** que cada skill sea invocable de forma independiente
5. **Asegurar** que cada SKILL.md tenga frontmatter correcto (name, description, effort)
## Resultado esperado
| Skill | Líneas | Descripción |
|-------|--------|-------------|
| background-orchestrator | ~80 | Coordinador del flujo |
| agent-launcher | ~120 | Lanzamiento de agente |
| agent-monitor | ~60 | Monitoreo y control |
| agent-instructions | ~90 | Template TASK.md |
| agent-archiver | ~80 | Validación y archivo |
| agent-safety | ~40 | Reglas de seguridad |
| agent-troubleshooting | ~50 | Troubleshooting |
| **Total** | **~420** | 7 archivos (vs 1 de 470) |
Cada archivo es más corto, más enfocado y más fácil de navegar.
@@ -0,0 +1,101 @@
# Soporte multi-arquitectura (amd64 + arm64) para vscode-server
## Contexto
La imagen `vscode-server` hoy solo se construye y publica para `linux/amd64` (ver
`.gitea/workflows/main-workflow.yml:85`). El usuario quiere consumir esta imagen nativamente
desde una Mac con chip Apple Silicon (arm64), y seguir publicándola en el registry de Gitea
(`gitea.p-lao.com/aleleba/vscode-server`).
La imagen base `gitea.p-lao.com/aleleba/vscode:latest` (repo
`aleleba/aleleba-vscode-dockerfile-configuration`) ya se publica multi-arch (confirmado: su
workflow construye con `platforms: linux/amd64,linux/arm64` y su Dockerfile usa
`$(dpkg --print-architecture)` para resolver rutas dependientes de arquitectura), así que es una
base válida para extender.
Al revisar `Dockerfile` completo se encontraron dos problemas que romperían o degradarían el
build en arm64, y ambos ya se resolvieron con el usuario:
1. **`JAVA_HOME` hardcodeado a `amd64`** (`Dockerfile:106`, y de nuevo en el `~/.bashrc` en
`Dockerfile:160`) — en Debian/Ubuntu la ruta de `openjdk-17-jdk` es
`/usr/lib/jvm/java-17-openjdk-<arch>`, así que en arm64 esa ruta no existiría y rompería
cualquier comando que dependa de `$JAVA_HOME` (Android SDK, Gradle, etc.).
2. **`docker-compose` v1.21.2 descargado como binario fijo** (`Dockerfile:76-80`) desde
`https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m)`
— se confirmó por búsqueda web que esa versión (2018) nunca publicó binario para
`aarch64`/`arm64`; el `RUN` fallaría al construir en esa plataforma.
Decisiones ya acordadas con el usuario:
- **docker-compose**: eliminar la descarga manual del binario v1 y confiar en el plugin Compose V2
(`docker compose`, con espacio) que el script de `get.docker.com` ya instala automáticamente y
que sí soporta ambas arquitecturas nativamente. Agregar un wrapper `/usr/local/bin/docker-compose`
que delegue a `docker compose "$@"`, para no romper scripts existentes que usan el nombre con guion.
- **Android SDK** (`platform-tools`, `build-tools`, `emulator` instalados vía `sdkmanager`,
`Dockerfile:130-131`): Google solo distribuye binarios nativos oficiales para Linux x86_64. El
build en sí no falla en arm64 (`sdkmanager` solo descarga/descomprime, no ejecuta los binarios),
pero en un Mac M corriendo la imagen arm64 nativa, `adb`/`aapt2`/`emulator` probablemente no
ejecutarán. Se deja la instalación igual en ambas arquitecturas y se documenta la limitación en
el README (mitigación: correr ese contenedor puntual con `--platform linux/amd64` cuando se
necesite Android).
El resto de las instalaciones del Dockerfile (GitHub CLI, kubectl, Flux CLI, NVM/Node, Emscripten,
Claude Code, Conan) ya son multi-arch (usan `$(dpkg --print-architecture)`, scripts oficiales que
autodetectan arquitectura, o son artefactos JVM/Python agnósticos de arquitectura) — no requieren
cambios.
## Cambios
### 1. `Dockerfile`
- Justo antes de `ENV JAVA_HOME=...` (línea 106), declarar `ARG TARGETARCH` (buildx lo puebla
automáticamente por plataforma; sus valores `amd64`/`arm64` coinciden con el naming de Debian) y
cambiar la línea a:
```
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-${TARGETARCH}
```
- En el bloque de configuración de `~/.bashrc` (línea ~160), reemplazar el valor hardcodeado por la
variable ya resuelta:
```
RUN echo "export JAVA_HOME=$JAVA_HOME" | sudo tee -a ~/.bashrc
```
- Eliminar el bloque de instalación manual de `docker-compose` v1 (líneas 76-80).
- Después de instalar Docker (que ya trae `docker-compose-plugin`), agregar un wrapper de
compatibilidad:
```
RUN sudo printf '#!/bin/sh\nexec docker compose "$@"\n' | sudo tee /usr/local/bin/docker-compose > /dev/null \
&& sudo chmod +x /usr/local/bin/docker-compose
```
### 2. `.gitea/workflows/main-workflow.yml`
- Agregar el paso `docker/setup-qemu-action@v3` (antes de `docker/setup-buildx-action@v3`), para
garantizar emulación arm64 en el runner de Gitea Actions aunque no traiga QEMU/binfmt
preregistrado (a diferencia de runners hosteados de GitHub).
- Cambiar `platforms: linux/amd64` (línea 85) a `platforms: linux/amd64,linux/arm64`.
### 3. `README.md`
- Bump de versión siguiendo la convención existente del repo (cada commit incrementa la versión):
`1.1.0``1.2.0`.
- Agregar una nota breve documentando la limitación de Android SDK en arm64 y la mitigación
(`--platform linux/amd64` cuando se necesite `adb`/`aapt2`/`emulator` funcionales).
## Verificación
- Build local multi-plataforma sin push, solo para validar que ambos targets compilan:
```
docker buildx build --platform linux/amd64,linux/arm64 -t vscode-server:test --output=type=cacheonly .
```
- Si hace falta cargar y probar arm64 localmente en una máquina x86 (requiere QEMU registrado,
p.ej. `docker run --privileged --rm tonistiigi/binfmt --install all`):
```
docker buildx build --platform linux/arm64 -t vscode-server:arm64 --load .
docker run --rm --platform linux/arm64 vscode-server:arm64 bash -lc 'echo $JAVA_HOME && java -version && docker compose version && docker-compose version'
```
- Push a `master` (o `workflow_dispatch`) y confirmar en Gitea Actions que el job
`build-and-push` termina en verde para ambas plataformas.
- Verificar el manifest multi-arch publicado:
```
docker manifest inspect gitea.p-lao.com/aleleba/vscode-server:latest
```
y confirmar que aparecen entradas para `linux/amd64` y `linux/arm64`.
@@ -0,0 +1,178 @@
# Plan: Etapa 3 — Enforcement de Permisos
## Contexto
Ro-ut v2.x tiene el campo `permissions` en el modelo User guardado y retornado por GraphQL, pero **nunca se verifica** antes de ejecutar operaciones. Cualquier usuario autenticado puede realizar cualquier acción (crear, editar, eliminar usuarios, empresas, oficinas).
El campo `req.usuario` actualmente contiene solo el **user ID** (string), no el objeto completo del usuario con sus permisos.
## Plan de Implementación
### Paso 1: Mejorar `userExtractor` para incluir el usuario completo
**Archivo:** `src/server/middlewares/userExtractor.ts`
Actualmente `userExtractor` solo extrae el JWT y setea `req.usuario = usuario` (el ID). Necesitamos:
1. Hacer `userExtractor` asíncrono (ya lo es — `await userExtractor(...)`)
2. Después de decodificar el JWT, hacer un query a la DB para obtener el usuario completo con su campo `permissions`
3. Setear `req.usuario` como el objeto completo del usuario (no solo el ID)
```typescript
// Después de jwt.verify:
const { rows } = await db.query(
`SELECT "Id", "user", "email", "name", "last_names", "type", "permissions"
FROM "User" WHERE "Id" = $1`,
[parseInt(decoded.sub)]
);
if (rows.length === 0) throw new Error('Usuario no encontrado');
req.usuario = rows[0]; // objeto completo con permissions (JSON string)
```
Esto requiere importar el connection DB en el middleware (similar a como lo hacen los models).
### Paso 2: Crear función de verificación de permisos
**Archivo nuevo:** `src/server/middlewares/permissionCheck.ts`
```typescript
export const checkPermission = (
usuario: any,
requiredPermission: string,
action: string
): void => {
const perm = usuario?.permissions?.adminUser;
if (!perm || !perm[requiredPermission]) {
throw new GraphQLError(
`Permiso requerido: ${action}. Contacte a un administrador.`
);
}
};
```
### Paso 3: Agregar permisos de Company y Office al schema
**Archivo:** `src/server/GraphQL/schema/user.schema.ts`
Extender `AdminUser` para incluir permisos de Company y Office:
```typescript
@ObjectType('CompanyPermissions')
@InputType('InputCompanyPermissions')
export class CompanyPermissions {
@Field() ViewCompany?: boolean;
@Field() CreateCompany?: boolean;
@Field() EditCompany?: boolean;
@Field() DeleteCompany?: boolean;
}
@ObjectType('OfficePermissions')
@InputType('InputOfficePermissions')
export class OfficePermissions {
@Field() ViewOffice?: boolean;
@Field() CreateOffice?: boolean;
@Field() EditOffice?: boolean;
@Field() DeleteOffice?: boolean;
}
// Extender AdminUser:
@ObjectType('AdminUser')
@InputType('InputAdminUser')
export class AdminUser {
@Field() ViewUser?: boolean;
@Field() CreateUser?: boolean;
@Field() EditUser?: boolean;
@Field() DeleteUser?: boolean;
@Field()
companyPermissions?: CompanyPermissions;
@Field()
officePermissions?: OfficePermissions;
}
```
### Paso 4: Agregar verificación de permisos en el schema layer
Cada campo en los schema types (UsersQuery, UserMutation, CompaniesQuery, CompanyMutation, OfficesQuery, OfficeMutation) ya llama `userExtractor`. Después de eso, agregar verificación de permisos:
**`src/server/GraphQL/schema/user.schema.ts`** — en cada campo de `UsersQuery` y `UserMutation`:
```typescript
// Ejemplo para users query:
@Field(() => [User], { nullable: true })
async users(@Arg('limit') limit: number, @Arg('cursor') cursor: number, @Ctx() context: { req: any; res: any; }) {
await userExtractor({req: context.req, res: context.res, isGraphQL: true});
checkPermission(context.req.usuario, 'ViewUser', 'ViewUser'); // lanzar si no tiene
return getUsersController({ args: { limit, cursor } });
}
```
Aplicar patrón similar para:
- `user()` query → `ViewUser`
- `users()` query → `ViewUser`
- `insertUser()` mutation → `CreateUser`
- `editUser()` mutation → `EditUser`
- `deleteUser()` mutation → `DeleteUser`
**`src/server/GraphQL/schema/company.schema.ts`** — en cada campo de `CompaniesQuery` y `CompanyMutation`:
```typescript
// getCompany/getCompanies → ViewCompany
// insertCompany → CreateCompany
// editCompany → EditCompany
// deleteCompany → DeleteCompany
```
**`src/server/GraphQL/schema/office.schema.ts`** — en cada campo de `OfficesQuery` y `OfficeMutation`:
```typescript
// getOffice/getOffices/officesByCompany → ViewOffice
// insertOffice → CreateOffice
// editOffice → EditOffice
// deleteOffice → DeleteOffice
```
### Paso 5: Actualizar tests
Los tests actuales esperan 200 OK siempre. Necesitamos:
1. **Tests de permisos positivos**: crear un usuario con permisos limitados y verificar que puede hacer lo que tiene permiso
2. **Tests de permisos negativos**: crear un usuario sin permisos y verificar que recibe error GraphQL
**Archivos a actualizar:**
- `src/server/tests/server/user/index.test.ts` — agregar tests de permisos
- `src/server/tests/server/company/index.test.ts` — agregar tests de permisos
- `src/server/tests/server/office/index.test.ts` — agregar tests de permisos
**Estrategia de tests:**
- Crear un usuario de prueba con `ViewUser: true` pero `CreateUser: false`
- Verificar que `users()` funciona (200)
- Verificar que `insertUser()` falla (GraphQL error)
- Lo mismo para Company y Office
### Paso 6: Actualizar seed de tests
El test de insertUser crea un admin con todos los permisos. Asegurar que el seed de tests tenga un usuario admin con todos los permisos habilitados para que los tests existentes sigan funcionando.
## Archivos Modificados
| Archivo | Acción |
|---------|--------|
| `src/server/middlewares/userExtractor.ts` | Modificar — fetch user from DB after JWT decode |
| `src/server/middlewares/permissionCheck.ts` | **Nuevo** — función checkPermission |
| `src/server/GraphQL/schema/user.schema.ts` | Modificar — extender AdminUser, agregar CompanyPermissions, OfficePermissions, agregar checks |
| `src/server/GraphQL/schema/company.schema.ts` | Modificar — agregar checks de permisos |
| `src/server/GraphQL/schema/office.schema.ts` | Modificar — agregar checks de permisos |
| `src/server/tests/server/user/index.test.ts` | Modificar — agregar tests de permisos |
| `src/server/tests/server/company/index.test.ts` | Modificar — agregar tests de permisos |
| `src/server/tests/server/office/index.test.ts` | Modificar — agregar tests de permisos |
## Ejecución
Este plan será ejecutado en background por un agente usando la skill `agent-orchestrator`.
## Verificación
1. `npm run lint` — sin errores
2. `npm run build` — compila sin errores
3. `npm run test:backend` — tests existentes pasan (admin tiene todos los permisos) + nuevos tests de permisos pasan
4. Verificar que un usuario sin permisos recibe un GraphQL error descriptivo al intentar operaciones restringidas
@@ -0,0 +1,58 @@
# Promover migraciones pendientes de DEV a PROD (Supabase CLI)
## Contexto
El repo usa dos proyectos Supabase aislados por entorno (`supabase/config.toml`, bloques `[remotes.dev]`/`[remotes.production]`): **DEV** (`vshcimexazocttjmyrqy`) y **PROD** (`hwxkegbdnbrzvekrqxbd`). Las migraciones se desarrollan y prueban primero contra DEV y luego se "promueven" a PROD con `supabase db push`. La documentación en Docmost (`Estado_Actual_del_Proyecto`, actualizada por última vez 2026-07-15) daba por pendientes solo 4 migraciones — **estaba desactualizada**.
Ya verifiqué en vivo (modo solo-lectura, `supabase migration list --linked` contra cada proyecto) cuál es el diff real:
- **DEV**: sin deriva real de aplicación — todas las migraciones locales están aplicadas en el remoto de DEV, excepto `20260716120000` (ver abajo, es intencional).
- **PROD**: le faltan **10 migraciones**, en este orden (confirmado dos veces: `migration list --linked` y `db push --dry-run` coinciden exactamente):
1. `20260706130000_open_profiles_select_to_authenticated.sql`
2. `20260707130000_fix_profiles_update_policy_recursion.sql`
3. `20260708120000_fix_attendance_checkin_date_policy.sql`
4. `20260709120000_create_agent_streaks.sql`
5. `20260709130000_fix_streak_double_increment_same_day.sql`
6. `20260709140000_create_ranking_config.sql`
7. `20260710090000_add_verification_columns_to_upsell_tickets.sql`
8. `20260714120000_add_sales_coach_notes_to_upsell_tickets.sql`
9. `20260714130000_add_upsell_state_changes.sql`
10. `20260716120000_grant_public_schema_access.sql`
(Las migraciones `20260701000000`/`20260701000001` de auto-check-in de attendance, que Docmost marcaba como pendientes, **ya están aplicadas en PROD** — el doc estaba desactualizado en ambos sentidos.)
## Revisión de seguridad de cada migración pendiente (ya hecha, lectura de cada archivo)
Todas son **aditivas** (nuevas columnas/tablas/políticas/funciones), ninguna hace `DROP TABLE`, `DROP COLUMN` destructivo, ni `ALTER TYPE` que pueda romper datos existentes:
- **706 + 707** (profiles): 706 añade una política SELECT adicional (`to authenticated using (true)`) para el leaderboard — es puramente aditiva (RLS hace OR entre políticas). 707 corrige una recursión infinita (42P17) que 706 introduce en la política UPDATE existente, moviendo la verificación de rol a una función `SECURITY DEFINER`. Ambas migraciones se aplican en la misma ejecución de `db push`, sin ventana real de exposición al bug. Confirmado por el propio autor contra DEV en vivo (comentario en 707).
- **708** (attendance): relaja el chequeo de fecha del self-check-in de "igual a hoy" a "dentro de ±1 día" para cubrir el horario de El Salvador. Ensancha, no restringe, el permiso existente — no puede romper el self-check-in ya funcionando en PROD.
- **709120000 + 709130000** (agent_streaks): crea la tabla nueva `agent_streaks` con RLS + función `reconcile_agent_streak()`, y el segundo archivo corrige un bug de doble incremento en esa misma función (confirmado por reproducción directa contra DEV, sin tocar datos). Tabla nueva, sin impacto en nada existente.
- **709140000** (ranking_config): tabla singleton nueva con `FORCE ROW LEVEL SECURITY`, semillada con los valores default actuales (el propio comentario dice que el deploy "no cambia nada visible hasta que un owner edite la config"). Sin impacto en tablas existentes.
- **710090000** (verification columns en `upsell_tickets`): añade `full_flow_completed` (NOT NULL DEFAULT false) con **backfill explícito** a `true` para todo ticket ya en estado terminal (accepted/declined_hard/declined_soft) — evita que tickets históricos aparezcan como "rejected" al desplegar. Añade también `objection_handled_correctly` (nullable). Backfill ya revisado y correcto.
- **714120000** (sales_coach_notes): columna nullable, sin backfill necesario (NULL es el valor correcto para filas existentes).
- **714130000** (upsell_state_changes + RPC): tabla de auditoría inmutable nueva (sin política INSERT/UPDATE/DELETE para ningún rol cliente) + función `change_upsell_ticket_state()` `SECURITY DEFINER` con `search_path = ''`, que valida rol admin/owner y estado terminal antes de escribir. `revoke`/`grant execute` correctamente acotados a `authenticated`. Tabla y función nuevas, sin impacto en nada existente.
- **716120000** (grant_public_schema_access): confirmado por el propio comentario del archivo que es un **no-op en proyectos hosted** (Supabase ya otorga esos GRANTs por defecto al provisionar; esta migración solo repara la paridad en stacks locales/CI efímeros). Aplicarla en PROD es inocua.
No encontré ninguna migración con `DROP`/`TRUNCATE` destructivo, cambio de tipo de columna con pérdida de datos, ni relajación de RLS que abra acceso no autenticado (`to anon`).
## Bloqueador resuelto: contraseña de Postgres de PROD
`SUPABASE_ACCESS_TOKEN` (ya configurado en este entorno) solo autoriza la Management API (listar/link de proyectos) — **no** es la misma credencial que la contraseña directa de Postgres que exige `migration list --linked` / `db push` contra PROD (gotcha ya documentado en Docmost: "la DB password ≠ las API keys"). El usuario pegó la contraseña de PROD directamente en el chat para desbloquear la verificación.
> ⚠️ **Acción de seguridad pendiente, fuera del alcance de este plan pero recomendada de inmediato después de esta sesión**: el usuario pegó en el chat, en texto plano, el `POSTGRES_PASSWORD`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_SECRET_KEY` y `SUPABASE_JWT_SECRET` de **PROD**. Esos valores quedaron en el historial de esta conversación. Recomiendo rotarlos desde el Dashboard de Supabase (Project Settings → Database → Reset password; Project Settings → API → rotar claves) en cuanto termine esta tarea — mismo criterio ya aplicado en el repo cuando `qa-validator` usó temporalmente una service-role key (ver nota pendiente de rotación de `<<EMAIL_3>>` en Docmost).
## Pasos de ejecución (en orden)
1. **Confirmar el link a PROD** (ya hecho): `supabase link --project-ref hwxkegbdnbrzvekrqxbd`.
2. **Dry-run obligatorio** (verificación pedida explícitamente por el usuario): `SUPABASE_DB_PASSWORD='<password>' supabase db push --linked --dry-run` — revisar que el SQL impreso coincide exactamente con el contenido de las 9 migraciones listadas arriba, sin sorpresas (ningún `DROP`/`TRUNCATE` no esperado).
3. **Aplicar**: `SUPABASE_DB_PASSWORD='<password>' supabase db push --linked` (sin `--dry-run`).
4. **Verificación post-push**: `SUPABASE_DB_PASSWORD='<password>' supabase migration list --linked` contra PROD — confirmar que las 9 migraciones ahora muestran `local == remote`, sin ninguna fila con `remote` vacío.
5. **Smoke check funcional mínimo** contra la app de producción (`https://upsell-evaluator.app`): login como agente no-admin y confirmar que el auto-check-in de asistencia sigue funcionando (era el flujo más frágil, con historial de bugs de RLS/`ON CONFLICT`).
6. **Actualizar Docmost**: refrescar `Estado_Actual_del_Proyecto` y `Migración_a_Dos_Proyectos_Supabase` para reflejar que PROD ya está al día (quitar la nota "Pendiente: aplicar ... a PROD").
7. **Recordar al usuario** la rotación de credenciales de PROD expuestas en el chat (ver sección de arriba) — no ejecutarla yo mismo sin que el usuario lo pida explícitamente, ya que rotar la `SUPABASE_SERVICE_ROLE_KEY`/`SUPABASE_SECRET_KEY` en vivo requeriría también actualizar las env vars de Vercel Production simultáneamente para no romper la app.
## Verificación end-to-end
- Paso 2 (dry-run) y paso 4 (migration list post-push) son la verificación central que el usuario pidió explícitamente ("siempre quiero hacer una verificación que no se va a romper nada").
- Paso 5 (smoke check en la app real de producción) cubre el caso ya conocido de esta base de código donde una migración de RLS "se ve bien" en el SQL pero falla en runtime por el gotcha de `ON CONFLICT DO NOTHING` + políticas SELECT (documentado en `docs/solutions/debugging/2026-07-01-rls-on-conflict-do-nothing-requires-select-policy.md`).
@@ -0,0 +1,195 @@
# Plan: Crear el Design System de BL Consultores como Component Library React
## Context
BL Consultores tiene un design system completo definido en Penpot (páginas Foundations, Components, Screens) con colores, tipografía, botones, formularios, navegación, tablas, gráficos, alertas y modales. El objetivo es exportar estos diseños desde Penpot y crear una librería de componentes React reutilizable usando `create-react-component-library` (aleleba/create-react-component-library), que ya soporta Storybook, testing y publicación npm.
La librería se integrará en `blconsultores_app` como dependencia local o npm para estandarizar toda la UI del proyecto.
## Fundamentos del Design System (extraídos de Penpot)
### Paleta de Colores (12 tokens)
| Token | Hex | Grupo |
|---|---|---|
| Blanco | `#FFFFFF` | Neutro |
| Dorado Institucional | `#EABF2D` | Brand primario |
| Ámbar | `#D4880F` | Brand secundario |
| Azul Navy | `#1B2A4A` | Brand / texto oscuro |
| Negro Profundo | `#1A1A2E` | Texto principal |
| Gris Oscuro | `#2D2D3F` | Texto / bordes |
| Gris Medio | `#6B6B7B` | Texto secundario |
| Gris Claro | `#F5F5F7` | Fondos de sección |
| Encuesta - Totalmente de Acuerdo | `#2CDEA3` | Semántica encuesta |
| Encuesta - De Acuerdo | `#F5E53E` | Semántica encuesta |
| Encuesta - En Desacuerdo | `#FAC132` | Semántica encuesta |
| Encuesta - Totalmente en Desacuerdo | `#DE3626` | Semántica encuesta |
### Tipografía (11 tokens)
| Token | Font | Size | Weight | Uso |
|---|---|---|---|---|
| Heading 50 | Montserrat | 50px | Bold | Hero titles |
| Heading 25 | Montserrat | 25px | Bold | Page titles |
| Heading 18 | Montserrat | 18px | Bold | Section headers |
| Subheading 22 | Montserrat | 22px | Normal | Subheaders |
| Montserrat Heading | Montserrat | 14px | Bold | Card titles |
| Montserrat Subheading | Montserrat | 14px | Bold | Subtitles |
| Montserrat SemiBold | Montserrat | 14px | Semibold | Labels |
| Montserrat Body | Montserrat | 14px | Normal | Body text |
| Montserrat Regular | Montserrat | 14px | Normal | Body text |
| Noeteric Bold | Noeteric-Bold | 14px | Bold | Logo/branding |
| Montserrat Bold | Montserrat | 14px | Bold | Emphasis |
### Tipografías del Library
- `Montserrat` (Regular, SemiBold, Bold)
- `Noeteric-Bold`
### Componentes definidos en Penpot (22 boards)
**Buttons (8 variantes):**
- Primary (Dorado bg, Azul Navy text)
- Outline Gold (Dorado border)
- Outline Dark Default / Hover
- Outline Light BG
- Ghost Default / Hover
- Circular (icon button)
- Icon Delete (rojo)
- Icon Edit
**Forms (5):**
- Input/Text (default + focus states)
- Input/Search
- Select/Dropdown
- Toggle Track
- Button/File Upload
**Navigation (6):**
- Breadcrumb
- Tabs
- Navbar/Admin
- Navbar/Admin Mobile (collapsed + overlay)
- Navbar/Pública
**Data Display (11):**
- Table/Encuestados, Table/Climas, Table/Semáforo
- Pagination
- Card/Editor Section, Card/Servicio, Card/Login BG
- Badge/Status Check, Badge/Order Number
- Chip/Removable
- ListItem/Factor
**Feedback (4):**
- Alert (Danger, Info, Success)
- Modal/Content
- Loader
**Charts (3):**
- Bar Comparative, Bar Single, Donut
**Layout (1):**
- Footer
## Arquitectura de la Library
```
create-react-component-library/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── index.tsx # Componente React con props tipadas
│ │ │ ├── Button.stories.tsx # Storybook stories (variantes, estados)
│ │ │ ├── Button.test.tsx # Unit tests (Jest)
│ │ │ ├── Button.cy.tsx # Component tests (Cypress)
│ │ │ └── style.scss # Estilos SCSS usando tokens
│ │ ├── Input/
│ │ ├── Select/
│ │ ├── Toggle/
│ │ ├── Navbar/
│ │ ├── Breadcrumb/
│ │ ├── Tabs/
│ │ ├── Table/
│ │ ├── Card/
│ │ ├── Alert/
│ │ ├── Modal/
│ │ ├── Badge/
│ │ ├── Chip/
│ │ ├── ListItem/
│ │ ├── Pagination/
│ │ ├── Loader/
│ │ ├── Footer/
│ │ ├── Chart/
│ │ └── index.tsx # Barrel export
│ ├── tokens/
│ │ ├── colors.ts # Design tokens (colores)
│ │ ├── typography.ts # Design tokens (tipografía)
│ │ └── index.ts
│ └── stories/
│ └── Introduction.mdx # Guía de uso
├── .storybook/
│ ├── main.js
│ └── preview.js
├── package.json
├── tsconfig.json
└── README.md
```
Cada componente sigue el patrón de `create-react-component-library`:
1. `index.tsx` — Componente React con props tipadas
2. `*.stories.tsx` — Storybook stories (variantes, estados)
3. `*.test.tsx` — Tests unitarios Jest
4. `*.cy.tsx` — Tests de componentes Cypress
5. `style.scss` — Estilos SCSS que usan los tokens
## Pasos de Implementación
### Fase 1: Configurar tokens de diseño
1. `src/tokens/colors.ts` — Todas las variables de color como constantes TypeScript + CSS variables
2. `src/tokens/typography.ts` — Todas las tipografías como objetos con fontFamily, fontSize, fontWeight, lineHeight
3. `src/tokens/index.ts` — Barrel export
### Fase 2: Implementar componentes por categoría (orden de prioridad)
1. **Buttons** (8 variantes) — Base de toda la UI interactiva
2. **Forms** (5 componentes) — Inputs, selects, toggles
3. **Navigation** (6 componentes) — Breadcrumbs, tabs, navbars
4. **Data Display** (11 componentes) — Tablas, cards, badges, chips
5. **Feedback** (4 componentes) — Alerts, modals, loader
6. **Charts** (3 componentes) — Gráficas con Chart.js
7. **Layout** (1 componente) — Footer
### Fase 3: Export barrel y documentación
1. `src/components/index.ts` — Exportar todos los componentes
2. Storybook Introduction page con guía de uso y ejemplos
3. README con instrucciones de instalación y uso
### Fase 4: Testing
1. Unit tests para cada componente (Jest + Testing Library)
2. Component tests con Cypress
3. Storybook visual regression (opcional)
## Integración con blconsultores_app
### package.json de blconsultores_app:
```json
{
"dependencies": {
"@blconsultores/design-system": "file:../create-react-component-library"
}
}
```
### Uso en componentes existentes:
```tsx
// Antes (Bootstrap inline)
<div className="btn btn-primary" style={{...}}>
// Después (Design System)
import { Button } from '@blconsultores/design-system'
<Button variant="primary" size="md">Click</Button>
```
## Verificación
- [ ] Storybook abre y muestra todos los componentes
- [ ] Todos los colores y tipografías coinciden con Penpot (verificar hex codes)
- [ ] Tests unitarios pasan (Jest)
- [ ] Tests de componentes pasan (Cypress)
- [ ] `npm link` funciona y los componentes se importan en blconsultores_app
- [ ] Export barrel funciona: `import { Button, Input, Table } from '@blconsultores/design-system'`
@@ -0,0 +1,225 @@
# Plan: Crear el Design System de BL Consultores como Component Library React
## Context
BL Consultores tiene un design system completo definido en Penpot (páginas Foundations, Components, Screens) con colores, tipografía, botones, formularios, navegación, tablas, gráficos, alertas y modales. El objetivo es exportar estos diseños desde Penpot y crear una librería de componentes React reutilizable usando la herramienta `create-react-component-library` (aleleba/create-react-component-library), que ya soporta Storybook, testing y publicación npm.
La librería se integrará en `blconsultores_app` como dependencia local o npm para estandarizar toda la UI del proyecto.
## Fundamentos del Design System (desde Penpot)
### Paleta de Colores
| Token | Hex | Uso |
|---|---|---|
| Blanco | `#FFFFFF` | Fondos, texto sobre oscuro |
| Azul Navy | `#1B2A4A` | Brand principal, headers, texto oscuro |
| Ámbar | `#D4880F` | Brand secundario, acentos |
| Dorado Institucional | `#EABF2D` | Brand primario, CTAs, highlights |
| Gris Oscuro | `#2D2D3F` | Texto body, bordes |
| Gris Medio | `#6B6B7B` | Texto secundario, placeholders |
| Gris Claro | `#F5F5F7` | Fondos de sección, cards |
| Negro Profundo | `#1A1A2E` | Texto principal, headers |
| Encuesta: Totalmente de Acuerdo | `#2CDEA3` | Semántica encuesta |
| Encuesta: De Acuerdo | `#F5E53E` | Semántica encuesta |
| Encuesta: En Desacuerdo | `#FAC132` | Semántica encuesta |
| Encuesta: Totalmente en Desacuerdo | `#DE3626` | Semántica encuesta |
### Tipografía
| Token | Font | Size | Weight | Uso |
|---|---|---|---|---|
| Heading 50 | Montserrat | 50px | Bold | Hero titles |
| Heading 25 | Montserrat | 25px | Bold | Page titles |
| Heading 18 | Montserrat | 18px | Bold | Section headers |
| Subheading 22 | Montserrat | 22px | Normal | Subheaders |
| Montserrat Heading | Montserrat | 14px | Bold | Card titles |
| Montserrat Subheading | Montserrat | 14px | Bold | Subtitles |
| Montserrat SemiBold | Montserrat | 14px | Semibold | Labels, buttons |
| Montserrat Body | Montserrat | 14px | Normal | Body text |
| Montserrat Regular | Montserrat | 14px | Normal | Body text |
| Noeteric Bold | Noeteric-Bold | 14px | Bold | Logo, branding |
### Tipografías del Library
- `Montserrat` (Regular, SemiBold, Bold)
- `Noeteric-Bold`
## Componentes a Implementar
### 1. Buttons (7 variantes)
- `Button/Primary` — Dorado bg, Azul Navy text, rounded
- `Button/Outline Gold` — Dorado border, transparent bg
- `Button/Outline Dark Default` — Gris Oscuro border
- `Button/Outline Dark Hover` — Hover state
- `Button/Outline Light BG` — Light bg variant
- `Button/Ghost Default` — Transparent, text only
- `Button/Ghost Hover` — Hover state
- `Button/Circular` — Circle shape, icon
- `Button/Icon Delete` — Red icon button
- `Button/Icon Edit` — Edit icon button
### 2. Forms
- `Input/Text` — Default state (border Gris Medio)
- `Input/Text Focus` — Focus state (border Dorado)
- `Input/Search` — Con icono de búsqueda
- `Select/Dropdown` — Custom select component
- `Toggle Track` — Toggle switch
- `Button/File Upload` — File upload button
### 3. Navigation
- `Breadcrumb` — Navegación jerárquica
- `Tabs` — Tab navigation
- `Navbar/Admin` — Admin panel navbar
- `Navbar/Admin Mobile` — Mobile collapsed
- `Navbar/Admin Mobile Overlay` — Mobile overlay
- `Navbar/Pública` — Public site navbar
### 4. Data Display
- `Table/Encuestados` — Tabla de encuestados con status
- `Table/Climas` — Tabla de climas organizacionales
- `Table/Semáforo` — Tabla con indicadores de color
- `Pagination` — Paginación
- `Card/Editor Section` — Card para editor
- `Card/Servicio` — Card de servicio (marketing)
- `Card/Login BG` — Card con fondo login
- `Badge/Status Check` — Badge de estado
- `Badge/Order Number` — Badge numérico
- `Chip/Removable` — Chip con botón de eliminar
- `ListItem/Factor` — List item para factores
### 5. Feedback
- `Alert/Danger` — Error alert
- `Alert/Info` — Info alert
- `Alert/Success` — Success alert
- `Modal/Content` — Modal container
- `Loader` — Loading spinner
### 6. Charts
- `Chart/Bar Comparative` — Gráfica de barras comparativa
- `Chart/Bar Single` — Gráfica de barra simple
- `Chart/Donut` — Gráfica donut
### 7. Layout
- `Footer` — Pie de página
## Arquitectura de la Library
```
create-react-component-library/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── index.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.cy.tsx
│ │ │ └── style.scss
│ │ ├── Input/
│ │ ├── Select/
│ │ ├── Toggle/
│ │ ├── Navbar/
│ │ ├── Breadcrumb/
│ │ ├── Tabs/
│ │ ├── Table/
│ │ ├── Card/
│ │ ├── Alert/
│ │ ├── Modal/
│ │ ├── Badge/
│ │ ├── Chip/
│ │ ├── ListItem/
│ │ ├── Pagination/
│ │ ├── Loader/
│ │ ├── Footer/
│ │ ├── Chart/
│ │ └── index.tsx # barrel export
│ ├── tokens/
│ │ ├── colors.ts # Design tokens (colores)
│ │ ├── typography.ts # Design tokens (tipografía)
│ │ └── index.ts
│ └── stories/
│ ├── Introduction.mdx
│ └── ...
├── .storybook/
│ ├── main.js
│ └── preview.js
├── package.json
├── tsconfig.json
└── README.md
```
### Tokens de diseño (src/tokens/)
- `colors.ts` — Exportar todos los colores como constantes TypeScript
- `typography.ts` — Exportar todas las tipografías como objetos con fontFamily, fontSize, fontWeight, lineHeight
- Estos tokens se reutilizan en cada componente SCSS y TSX
### Cada componente sigue el patrón:
1. `index.tsx` — Componente React con props tipadas
2. `*.stories.tsx` — Storybook stories (variantes, estados)
3. `*.test.tsx` — Tests unitarios Jest
4. `*.cy.tsx` — Tests de componentes Cypress
5. `style.scss` — Estilos SCSS que usan los tokens
## Pasos de Implementación
### Fase 1: Configuración del proyecto
1. Clonar/crear `create-react-component-library` como base
2. Configurar `package.json` con nombre `@blconsultores/design-system`
3. Configurar Storybook con soporte SCSS
4. Configurar Jest + Testing Library + Cypress Component Testing
5. Configurar tsconfig para la library
### Fase 2: Crear tokens de diseño
1. `src/tokens/colors.ts` — Todas las variables de color del design system
2. `src/tokens/typography.ts` — Todas las tipografías con sus propiedades
3. `src/tokens/index.ts` — Barrel export
### Fase 3: Implementar componentes (por categoría)
1. **Buttons** — 7+ variantes con estados hover/active/disabled
2. **Forms** — Input, Select, Toggle, File Upload
3. **Navigation** — Breadcrumb, Tabs, Navbar (Admin + Pública)
4. **Data Display** — Table, Card, Badge, Chip, ListItem, Pagination
5. **Feedback** — Alert, Modal, Loader
6. **Charts** — Bar Comparative, Bar Single, Donut
7. **Layout** — Footer
### Fase 4: Export barrel y documentación
1. `src/components/index.ts` — Exportar todos los componentes
2. Storybook Introduction page con guía de uso
3. README con instrucciones de instalación y uso
### Fase 5: Testing
1. Unit tests para cada componente
2. Component tests con Cypress
3. Visual regression (opcional)
### Fase 6: Integración
1. Publicar como package npm local (`npm link`)
2. Instalar en `blconsultores_app` como dependencia
3. Migrar componentes existentes del proyecto para usar la library
## Integración con blconsultores_app
### package.json de blconsultores_app:
```json
{
"dependencies": {
"@blconsultores/design-system": "file:../create-react-component-library"
}
}
```
### Uso en componentes existentes:
```tsx
// Antes (Bootstrap inline)
<div className="btn btn-primary" style={{...}}>
// Después (Design System)
import { Button } from '@blconsultores/design-system'
<Button variant="primary" size="md">Click</Button>
```
## Verificación
- [ ] Storybook abre y muestra todos los componentes
- [ ] Todos los colores y tipografías coinciden con Penpot
- [ ] Tests unitarios pasan (Jest)
- [ ] Tests de componentes pasan (Cypress)
- [ ] `npm link` funciona y los componentes se importan en blconsultores_app
- [ ] Export barrel funciona: `import { Button, Input, Table } from '@blconsultores/design-system'`
@@ -0,0 +1,73 @@
# Plan: skill `spark-ssh` para conectarse y ejecutar comandos en spark vía SSH
## Contexto
El usuario quiere que Claude Code pueda operar directamente sobre su servidor "spark" (<<SPARK_IP_1>>)
ejecutando comandos remotos vía SSH, sin tener que pedir manualmente la contraseña cada vez. Verificado
en el entorno actual:
- `~/.ssh/config` ya tiene un host `spark` configurado (`HostName <<SPARK_IP_1>>`, `User aleleba`), pero
la autenticación falla por key (`ssh -o BatchMode=yes spark``Permission denied (publickey,password)`)
— spark solo acepta password.
- La variable de entorno `SPARK_PASSWORD` ya existe en este entorno de desarrollo.
- `sshpass` **no está instalado**, pero está disponible vía `apt` (`sshpass 1.09-1`) y hay sudo sin
password (`sudo -n true` → OK), así que se puede instalar como parte del preflight de la skill.
- `/mnt/docker-nas` no existe en este entorno de desarrollo — es un mount NFS que solo existe **del lado
de spark**. Dentro de él, `/mnt/docker-nas/projects` es el mismo storage que `~/projects` aquí. Es
decir: un proyecto en `~/projects/<nombre>` en este entorno de desarrollo es exactamente
`/mnt/docker-nas/projects/<nombre>` visto desde spark (mismo filesystem compartido vía NAS).
El objetivo es crear una skill global (en `~/.claude/skills/`, siguiendo el patrón de las skills
existentes como `web-ui-test`, `aleleba-pr`) que documente el patrón de conexión y las convenciones de
seguridad/rutas para que Claude la use de forma consistente cada vez que el usuario pida ejecutar algo en
spark.
## Archivo a crear
`~/.claude/skills/spark-ssh/SKILL.md`
## Contenido de la skill
**Frontmatter:**
- `name: spark-ssh`
- `description`: explica que conecta a spark por SSH con password vía `SPARK_PASSWORD`, y que traduce
rutas entre `/mnt/docker-nas/projects` (en spark) y `~/projects` (en este entorno). Incluye triggers en
español: "conéctate a spark", "ejecuta esto en spark", "corre este comando en spark", "revisa spark".
- `effort: low`
- `argument-hint: "[comando o tarea a ejecutar en spark]"`
**Cuerpo (en español, consistente con el resto de skills del usuario):**
1. **Contexto fijo**: host `spark` ya está en `~/.ssh/config`; autenticación es por password (no key);
password vive en `$SPARK_PASSWORD`; mapeo de rutas `~/projects/<x>``/mnt/docker-nas/projects/<x>`.
2. **Preflight — instalar `sshpass` si falta** (idempotente):
```bash
which sshpass >/dev/null 2>&1 || sudo apt-get install -y sshpass
```
3. **Ejecutar un comando remoto**, usando siempre `sshpass -e` (nunca `-p`, que expone la password en
`ps`):
```bash
SSHPASS="$SPARK_PASSWORD" sshpass -e ssh spark '<comando remoto>'
```
Para comandos multilínea o con comillas complejas, usar heredoc remoto vía `ssh spark bash -s <<'EOF'`.
4. **Regla de rutas compartidas**: si el comando remoto opera sobre un proyecto que también existe
localmente en `~/projects/<nombre>`, la ruta equivalente en spark es
`/mnt/docker-nas/projects/<nombre>`. Para *editar* código de esos proyectos, preferir las tools locales
(Read/Edit) ya que es el mismo filesystem; usar SSH solo para **ejecutar** (builds, procesos, comandos
específicos del entorno/hardware de spark).
5. **Seguridad**: nunca imprimir/loggear `$SPARK_PASSWORD`; aplicar las reglas generales de confirmación
antes de comandos destructivos ejecutados en spark (rm -rf, docker rm, kill, etc.).
## Verificación
Después de crear el archivo:
```bash
which sshpass >/dev/null 2>&1 || sudo apt-get install -y sshpass
SSHPASS="$SPARK_PASSWORD" sshpass -e ssh spark 'hostname && whoami && ls /mnt/docker-nas/projects | head -5'
```
Confirmar que devuelve el hostname de spark y que `/mnt/docker-nas/projects` lista el mismo contenido que
`~/projects` localmente.
@@ -0,0 +1,145 @@
# Skill global: probar interfaces web con Playwright
## Contexto
El usuario trabaja siempre desde una sesión remota de VS Code (tunnel), sin display/GUI local. Necesita
una forma de que Claude pueda **probar interfaces de aplicaciones web** (navegar, interactuar, verificar
visualmente, detectar errores de consola) sin depender de un navegador con interfaz gráfica.
Investigación realizada (read-only):
- `~/.claude/skills/` solo tiene 3 skills globales hoy: `aleleba-pr`, `background-orchestrator`,
`docmost-context`. Todas siguen el mismo formato: `SKILL.md` con frontmatter
(`name`, `description`, `effort`, `argument-hint`) y cuerpo en Markdown.
- No hay `playwright` ni `@playwright/cli` instalados (ni global ni cache `~/.cache/ms-playwright`),
ni binarios `chromium`/`google-chrome` en el sistema.
- No hay ningún MCP server de Playwright/Chrome DevTools configurado.
- El plugin `c3po` (ya instalado en este entorno) trae un agente `browser.md` que usa
`npx @playwright/cli` (headless por defecto, sesiones nombradas, artefactos a disco) — es un patrón ya
probado en esta máquina, pero la nueva skill **no debe depender del plugin c3po** para que funcione en
cualquier proyecto/entorno donde se invoque como skill global.
- `npm config get prefix` apunta a un directorio de nvm en el home del usuario (`~/.nvm/versions/node/...`),
así que `npm install -g` **no necesita sudo**. La instalación del navegador vía
`npx @playwright/cli install-browser` tampoco. Solo `playwright install --with-deps` (deps de SO vía apt)
podría requerir sudo — se trata como fallback, no como paso por defecto.
## Objetivo de la skill
Crear `~/.claude/skills/web-ui-test/SKILL.md`, una skill **global** que:
1. Verifica si `@playwright/cli` (y su navegador Chromium) ya están instalados; si faltan, los instala
de forma global de manera automática (sin pedir permiso adicional más allá del prompt normal de Bash);
si ya están instalados, salta ese paso.
2. Usa el navegador **siempre en modo headless** (nunca `--headed`) porque el entorno es un tunnel remoto
sin display.
3. Dirige un flujo estándar de prueba de interfaz: abrir sesión → snapshot/interactuar → capturar
screenshots + snapshot de accesibilidad + errores de consola → cerrar sesión (siempre, incluso si algo
falla).
4. Reporta resultados de forma concisa (rutas de artefactos + hallazgos), sin volcar contenido crudo
(HTML/snapshot completo) al chat.
## Archivo a crear
`~/.claude/skills/web-ui-test/SKILL.md`
### Frontmatter
```yaml
---
name: web-ui-test
description: >-
Prueba interfaces de aplicaciones web (navegación, interacción, capturas, errores de consola) usando
un navegador headless vía Playwright CLI — pensado para correr desde un tunnel remoto de VS Code sin
GUI. Instala Playwright y su navegador automáticamente si no están presentes. Triggers: "prueba esta
interfaz", "prueba la web app", "test this UI", "revisa la interfaz", "haz un smoke test", "prueba el
flujo de login/registro/checkout", "captura screenshots de la app".
effort: medium
argument-hint: "[URL a probar, o descripción del flujo a validar]"
---
```
### Cuerpo (secciones)
**1. Preflight — verificar/instalar Playwright de forma global (idempotente, se salta si ya está listo)**
```bash
# ¿@playwright/cli y playwright globales disponibles?
npm ls -g --depth=0 2>/dev/null | grep -q "@playwright/cli" && CLI_OK=1 || CLI_OK=0
npm ls -g --depth=0 2>/dev/null | grep -qw "playwright" && PW_OK=1 || PW_OK=0
# ¿Navegador chromium ya descargado?
ls ~/.cache/ms-playwright 2>/dev/null | grep -qi chromium && BROWSER_OK=1 || BROWSER_OK=0
```
- Si `CLI_OK=0` o `PW_OK=0``npm install -g @playwright/cli playwright` (instalación global con `-g`;
no requiere sudo en este entorno porque el prefix de npm es un directorio de nvm en el home del usuario).
- Si `BROWSER_OK=0` → por defecto, `playwright install chromium` (usando el binario global instalado en
el paso anterior, sin `--with-deps`: solo descarga el binario de Chromium, sin tocar paquetes del
sistema operativo ni requerir sudo).
- **Fallback automático (no por defecto)**: si al abrir una sesión con `@playwright/cli` el error indica
librerías de sistema faltantes (ej. "missing libraries"/"host system is missing dependencies"),
reintentar automáticamente, sin preguntar, con `playwright install --with-deps chromium` (o
`sudo npx playwright install-deps chromium` si el anterior falla por permisos) y luego repetir la
operación que falló. Este camino solo se dispara ante ese error concreto, nunca de forma preventiva.
- Si todo ya está OK (`CLI_OK=1`, `PW_OK=1`, `BROWSER_OK=1`) → saltar directo al paso 2, sin ejecutar nada
de instalación.
**2. Reglas headless (no negociable)**
- Nunca pasar `--headed` ni cambiar `PWDEBUG`. Este entorno no tiene display; todo corre headless.
- Usar sesiones nombradas (`-s=<slug>`) en kebab-case para trazabilidad.
**3. Ciclo de vida de sesión — abrir → interactuar → cerrar**
Documentar (embebido, sin depender de archivos externos del plugin c3po) los comandos base de
`npx @playwright/cli`, tomados del reference ya validado en este entorno
(`~/.claude/plugins/cache/crew/c3po/0.4.1/agents/refs/playwright-cli.md`):
- `open <url>`, `goto`, `snapshot`, `click <ref>`, `fill <ref> "texto"`, `select`, `check/uncheck`,
`press`, `hover`, `screenshot [--full-page] --filename ...`, `console error`, `network`,
`eval "() => document.readyState"`, `close`, `close-all`, `kill-all`.
- Patrón obligatorio: cerrar la sesión siempre al final, incluso si falló algún paso intermedio
(equivalente a un `finally`).
**4. Verificación de carga de página**
Antes de interactuar, comprobar `document.readyState === "complete"` (máx. 3 reintentos con espera corta,
sin `sleep` arbitrario).
**5. Secuencia estándar de captura**
```bash
mkdir -p .local/screenshots
npx @playwright/cli -s=$SESSION open "$URL"
npx @playwright/cli -s=$SESSION eval "() => document.readyState"
npx @playwright/cli -s=$SESSION screenshot --filename .local/screenshots/$SESSION-viewport.png
npx @playwright/cli -s=$SESSION screenshot --full-page --filename .local/screenshots/$SESSION-full.png
npx @playwright/cli -s=$SESSION snapshot --filename .local/screenshots/$SESSION-snapshot.md
npx @playwright/cli -s=$SESSION console error
npx @playwright/cli -s=$SESSION close
```
Artefactos siempre a disco (`.local/screenshots/` en el directorio del proyecto actual), nunca volcar
snapshots/HTML crudo al chat.
**6. Flujos interactivos (cuando el usuario pide probar un flujo, no solo una página)**
Loop snapshot → act (click/fill/select) → re-snapshot tras cada cambio de DOM (los refs quedan obsoletos
tras mutaciones). Reportar cada paso relevante y detenerse si un elemento esperado no aparece tras
2 reintentos con snapshot fresco.
**7. Detección de app local vs URL remota**
- Si el usuario pide probar "la app" sin dar URL y hay un servidor de dev corriente/definible en el
proyecto (package.json con script `dev`/`start`, etc.), primero levantar el servidor (reutilizar lo que
ya haga la skill `run` si está disponible en ese proyecto) y esperar a que responda antes de abrir sesión
de Playwright.
- Si se da una URL explícita, usarla directamente.
**8. Formato de reporte final**
Resumen corto: URL probada, acciones realizadas, rutas de artefactos, errores de consola (o "ninguno"),
hallazgos de UX/bugs si los hay. No pegar el snapshot completo ni el HTML en el chat.
**9. Limpieza y recuperación**
- Cerrar sesión siempre. Si el cierre falla, ejecutar `npx @playwright/cli kill-all` como recuperación.
- Mencionar `npx @playwright/cli list` para ver sesiones huérfanas si algo quedó colgado de una corrida
anterior.
## Verificación
1. Confirmar que el archivo quedó bien formado: `cat ~/.claude/skills/web-ui-test/SKILL.md` y revisar que
el frontmatter parsea (mismo formato que `docmost-context/SKILL.md`).
2. Probar la skill de punta a punta contra una URL real (ej. `https://example.com`):
- Primera corrida: debe detectar que Playwright no está instalado, instalarlo (`npm install -g
@playwright/cli` + `npx @playwright/cli install-browser`), y luego completar la prueba.
- Segunda corrida: debe saltarse la instalación (ya presente) e ir directo a abrir sesión/capturar.
3. Verificar que se generaron los artefactos esperados en `.local/screenshots/` (viewport png, full-page
png, snapshot md) y que la sesión se cerró (`npx @playwright/cli list` no debe mostrarla activa).
4. Confirmar que el reporte en el chat es un resumen (no snapshot/HTML crudo).
@@ -0,0 +1,159 @@
# Plan: Etapa 4.1 — Navbar con menú lateral para Dashboard
## Contexto
El Dashboard de Ro-ut actualmente es un `<h1>Dashboard</h1>` sin navegación. La tarea 4.1 del plan de Etapa 4 ("Frontend: Dashboard funcional y gestión de usuarios") requiere integrar el componente `Navbar` del design system `@aleleba/ro-ut-ui` para dar al usuario una barra de navegación funcional con sidebar lateral.
El Navbar del UI kit es **chrome-only**: no envuelve el contenido de la página, sino que se coloca fuera y publica variables CSS (`--ro-ut-sidebar-width`, `--ro-ut-topbar-height`) para que el contenido reserve el espacio adecuado.
## Archivos a crear
### 1. `src/frontend/components/Dashboard/DashboardLayout/components/DashboardLayout.tsx`
Componente layout que envuelve el Dashboard con el Navbar. Sigue el patrón `connect` de Redux del proyecto:
```tsx
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router';
import { Navbar, TopNavbar, LeftNavbar, NavList, NavItem, IconNames } from '@aleleba/ro-ut-ui';
import { logInActions, ILogInPayload } from '@actions';
const { logInAction } = logInActions;
export type TDashboardLayoutProps = {
children: React.ReactNode;
setLogIn: ({ payload }: { payload: ILogInPayload }) => void;
connection: boolean;
token: string;
user: string | null;
};
const DashboardLayout = (props: TDashboardLayoutProps) => {
const { children, setLogIn, connection, token, user } = props;
const navigate = useNavigate();
// Auth redirect — mismo patrón que el Dashboard actual
useEffect(() => {
if ((connection === false) && (token === null)) {
navigate('/login');
}
}, [connection, token]);
const handleLogout = () => {
setLogIn({ payload: { connection: false, token: null, user: null } });
};
const navItems = [
{ label: 'Dashboard', to: '/dashboard', icon: IconNames.dashboard, active: true }
];
return (
<Navbar defaultExpanded>
<TopNavbar
brand={<img src="/assets/img/logo-ro-ut.svg" alt="RO-UT Logo" style={{ height: '32px' }} />}
actions={
<NavItem label={user || 'Usuario'} onClick={handleLogout} icon={IconNames.usuarioDentroApp} />
}
/>
<LeftNavbar>
<NavList>
{navItems.map(item => (
<NavItem key={item.to} label={item.label} to={item.to} icon={item.icon} active={item.active} />
))}
</NavList>
</LeftNavbar>
<div className="dashboard-content">{children}</div>
</Navbar>
);
};
const mapStateToProps = (state) => ({
connection: state.logIn.connection,
token: state.logIn.token,
user: state.logIn.user
});
const mapDispatchToProps = (dispatch) => ({
setLogIn: ({ payload }) => dispatch(logInAction(payload))
});
export default connect(mapStateToProps, mapDispatchToProps)(DashboardLayout);
```
**Decisiones clave:**
- `children` como prop hace el layout composable — cualquier vista futura se envuelve igual.
- El logout es local (dispatch Redux con `connection: false, token: null`). Si en el futuro se necesita invalidar sesión en el servidor, se crea un container con GraphQL mutation siguiendo el patrón de `LogIn/containers/queryGraphQLLogOut.ts`.
- El auth redirect se mueve del Dashboard al layout, centralizando la lógica de protección.
## Archivos a modificar
### 2. `src/frontend/components/Dashboard/Dashboard/components/Dashboard.tsx`
Simplificar a componente de contenido puro — sin Redux, sin useEffect, sin useNavigate:
```tsx
import React from 'react';
const Dashboard = () => (
<div className="container">
<div className="row">
<h1 style={{ color: '#000' }}>Dashboard</h1>
</div>
</div>
);
export default Dashboard;
```
### 3. `src/routes/index.tsx`
Envolver el Dashboard con el layout:
```tsx
import DashboardLayout from '../frontend/components/Dashboard/DashboardLayout/components/DashboardLayout';
const DASHBOARD = {
path: '/dashboard',
element: (
<DashboardLayout>
<Dashboard />
</DashboardLayout>
),
};
```
## CSS para el offset del contenido
El Navbar publica variables CSS en `document.documentElement` (runtime, vía JS). El `.dashboard-content` debe usarlas:
```css
.dashboard-content {
margin-left: var(--ro-ut-sidebar-width, 0px);
margin-top: var(--ro-ut-topbar-height, 0px);
padding: 1rem;
}
```
Esto asegura que el contenido se ajuste automáticamente cuando el sidebar se colapsa/expande, sin valores hardcodeados.
## Comportamiento responsive
El Navbar maneja la responsividad internamente:
- **Desktop:** sidebar visible (expandido o colapsado), topbar fija arriba.
- **Móvil:** menú hamburguesa en la topbar; sidebar se superpone como acordeón.
No se necesita CSS responsive adicional del layout.
## Items de navegación
Por ahora solo **Dashboard** (`/dashboard`). El array `navItems` en el componente es el lugar central para agregar nuevos items cuando se construyan las vistas de usuarios, empresas, etc. (tareas 4.24.5).
## Verificación
1. `npm run build` — compilar sin errores.
2. Navegar a `/dashboard` — ver Navbar con sidebar izquierdo y topbar con logo + nombre de usuario.
3. Click en logout — redirigir a `/login`.
4. Sidebar colapsar/expandir — el contenido se ajusta con las variables CSS.
5. Navegar a `/login` con sesión activa — redirigir a `/dashboard`.
6. Navegar directamente a `/dashboard` sin autenticar — redirigir a `/login`.
@@ -0,0 +1,227 @@
# Hacer `entrypoint.sh` agnóstico de UID/GID (soporte `HOST_UID`/`HOST_GID`)
## Fix post-implementación: resync espurio con GID 0
**Síntoma observado:** al correr el contenedor aparece, repetido muchas veces,
`groupmod: GID '0' already exists`, además de la advertencia normal de
`adduser` sobre que el home dir ya existe.
**Causa raíz:** `entrypoint.sh` se re-ejecuta más de una vez en el ciclo de
arranque (el propio script hace `exec sudo -u $HOME_USER bash -c "...
/usr/bin/entrypoint.sh"` para re-lanzarse como el usuario destino, y además el
contenedor puede reiniciar el entrypoint). En cada ejecución se vuelve a
calcular `DETECTED_UID`/`DETECTED_GID` con `stat` sobre `/home/${HOME_USER}`.
En este entorno (bind mount vía WSL2/Docker Desktop), el `stat` del volumen
reporta GID `0` (root) — es un artefacto de metadata del mount, no el GID real
del usuario del host. Como la rama de "usuario ya existe" recalcula
`TARGET_GID` de la misma manera que la de creación, en cada re-arranque
posterior a la creación intenta `groupmod -g 0 aleleba`, lo cual falla siempre
porque el GID 0 ya pertenece a `root`.
**Fix acordado con el usuario:** para un usuario que **ya existe**, solo se
resincroniza UID/GID si `HOST_UID`/`HOST_GID` vienen seteados **explícitamente**
por variable de entorno. La auto-detección vía `stat` del volumen montado se
usa **únicamente** en la rama de creación de un usuario nuevo (para heredar la
propiedad de un home pre-existente), no en cada reinicio de un usuario que ya
existe. Además, se descarta cualquier UID/GID detectado que sea `0` (root)
como no confiable, cayendo al default `1000` en ese caso, tanto en creación
como al calcular el target explícito.
Esto preserva el caso de uso principal (`HOST_UID=502 HOST_GID=502` fija el
usuario a 502:502 tanto en la creación como en reinicios posteriores, sin
error) y elimina el crash cuando no se pasa override y el mount reporta
metadata espuria.
### Cambios concretos sobre el bloque ya implementado
1. Guardar contra UID/GID `0` al calcular `DETECTED_UID`/`DETECTED_GID` (cerca
de la línea 10-13 actual de `entrypoint.sh`):
```bash
DETECTED_UID=$(stat -c '%u' "/home/${HOME_USER}" 2>/dev/null || echo 1000)
DETECTED_GID=$(stat -c '%g' "/home/${HOME_USER}" 2>/dev/null || echo 1000)
[ "$DETECTED_UID" = "0" ] && DETECTED_UID=1000
[ "$DETECTED_GID" = "0" ] && DETECTED_GID=1000
TARGET_UID="${HOST_UID:-$DETECTED_UID}"
TARGET_GID="${HOST_GID:-$DETECTED_GID}"
```
(Si `HOST_UID`/`HOST_GID` se pasan explícitos, siguen ganando siempre,
incluso si son `0` — esa sería una elección deliberada del operador, no un
artefacto de mount.)
2. En la rama `else` (usuario ya existe) del bloque en `entrypoint.sh` (~línea
88 actual), solo intentar el resync si hay override explícito:
```bash
else
# Usuario ya existe (imagen/volumen reusado) — resincronizar UID/GID
# SOLO si el operador lo pidió explícitamente vía HOST_UID/HOST_GID.
# No usar la detección automática del mount acá: en reinicios repetidos
# es una señal poco confiable (ver metadata GID 0 de bind mounts en
# WSL2/Docker Desktop) y causaba un groupmod fallido en cada arranque.
if [[ -n "${HOST_UID-}" || -n "${HOST_GID-}" ]]; then
CURRENT_UID=$(id -u "${HOME_USER}")
CURRENT_GID=$(id -g "${HOME_USER}")
CURRENT_GROUP=$(id -gn "${HOME_USER}")
if [ "$CURRENT_UID" != "$TARGET_UID" ] || [ "$CURRENT_GID" != "$TARGET_GID" ]; then
if [ "$CURRENT_GID" != "$TARGET_GID" ]; then
sudo groupmod -g "${TARGET_GID}" "${CURRENT_GROUP}"
fi
if [ "$CURRENT_UID" != "$TARGET_UID" ]; then
sudo usermod -u "${TARGET_UID}" "${HOME_USER}"
fi
sudo find / -xdev \( -user "${CURRENT_UID}" -o -group "${CURRENT_GID}" \) \
-exec chown -h "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
fi
fi
fi
```
3. Actualizar la sección de `readme.md` sobre `HOST_UID`/`HOST_GID` para
aclarar que, para un usuario ya existente (contenedor reiniciado/reusado),
el resync solo ocurre si se pasan `HOST_UID`/`HOST_GID` explícitos — la
auto-detección desde el volumen montado solo aplica la primera vez que se
crea el usuario.
### Verificación del fix
1. `bash -n entrypoint.sh`.
2. Reiniciar el contenedor sin `HOST_UID`/`HOST_GID` sobre un volumen ya
existente (el mismo que dio el error) → no debe aparecer más
`groupmod: GID '0' already exists`, ni ningún intento de resync.
3. Reiniciar el contenedor con `-e HOST_UID=502 -e HOST_GID=502` sobre un
usuario ya creado con otro UID/GID → debe correr `usermod`/`groupmod` una
vez y quedar en 502:502, sin error.
4. Repetir el arranque una tercera vez con el mismo `HOST_UID=502
HOST_GID=502` → no debe intentar `usermod`/`groupmod` de nuevo (ya
coincide), y no debe haber errores.
## Contexto
Hoy `entrypoint.sh` crea el usuario `HOME_USER` (default `vscode`) siempre con
`--uid 1000` hardcodeado (línea 70). Esto rompe cuando el `/home/${HOME_USER}`
se monta desde un volumen del host cuyo dueño real tiene otro UID/GID (por
ejemplo un usuario Linux del host distinto de 1000), generando problemas de
permisos entre el contenedor y los archivos montados.
El objetivo es que el UID/GID del usuario dentro del contenedor:
1. Se pueda fijar explícitamente vía `HOST_UID` / `HOST_GID`.
2. Si no se fijan, se detecte automáticamente a partir del dueño real de
`/home/${HOME_USER}` (cuando ya existe por venir de un volumen montado).
3. Si tampoco se puede detectar, caiga al comportamiento actual (UID/GID 1000),
preservando compatibilidad con quien no use esta variable.
4. Si el contenedor se reinicia/reusa y el usuario del sistema ya existía con
otro UID/GID, se resincronice (`usermod -u`, `groupmod -g`) y se re-asignen
los archivos que quedaron con el UID/GID viejo, para no dejar huérfanos.
## Cambio 1 — detección de UID/GID (nueva, antes de tocar `/home`)
Insertar justo después de resolver `HOME_USER` (después de la línea 6 actual,
antes de que cualquier otra parte del script toque `/home/${HOME_USER}`).
Es importante hacerlo **ahí y no más abajo**: el loop de `USER_ENV_` (líneas
~48-56 actuales) puede crear `/home/${HOME_USER}` con dueño `root:root` *antes*
de llegar al bloque de creación de usuario, si hay variables `USER_ENV_*`
seteadas y el directorio no viene de un bind mount. Si la detección corriera
después de ese loop, leería `root:root` en vez de caer al default 1000.
```bash
DETECTED_UID=$(stat -c '%u' "/home/${HOME_USER}" 2>/dev/null || echo 1000)
DETECTED_GID=$(stat -c '%g' "/home/${HOME_USER}" 2>/dev/null || echo 1000)
TARGET_UID="${HOST_UID:-$DETECTED_UID}"
TARGET_GID="${HOST_GID:-$DETECTED_GID}"
```
Compatible con `set -eu`: `${VAR:-default}` no dispara `set -u`, y el `|| echo
1000` garantiza que la sustitución de `stat` nunca falle bajo `set -e`.
## Cambio 2 — reemplazar el bloque de creación de usuario (líneas 68-80 actuales)
```bash
USER="$HOME_USER"
if ! id -u "$HOME_USER" > /dev/null 2>&1; then
sudo groupadd -g "${TARGET_GID}" "${HOME_USER}" 2>/dev/null || true
sudo adduser --disabled-password --gecos "" --uid "${TARGET_UID}" --gid "${TARGET_GID}" "${HOME_USER}"
sudo echo "$HOME_USER ALL=(ALL) NOPASSWD:ALL" | sudo tee -a /etc/sudoers.d/nopasswd > /dev/null
# Creating .vscode folder if it doesn't exist
if [ ! -d "/home/${HOME_USER}/.vscode" ]; then
sudo mkdir -p "/home/${HOME_USER}/.vscode"
fi
# Changing the property of the directory /home/${HOME_USER}/.vscode
sudo chown -R "${HOME_USER}" "/home/${HOME_USER}/.vscode"
else
# Usuario ya existe (imagen/volumen reusado) — resincronizar UID/GID si cambiaron
CURRENT_UID=$(id -u "${HOME_USER}")
CURRENT_GID=$(id -g "${HOME_USER}")
CURRENT_GROUP=$(id -gn "${HOME_USER}")
if [ "$CURRENT_UID" != "$TARGET_UID" ] || [ "$CURRENT_GID" != "$TARGET_GID" ]; then
if [ "$CURRENT_GID" != "$TARGET_GID" ]; then
sudo groupmod -g "${TARGET_GID}" "${CURRENT_GROUP}"
fi
if [ "$CURRENT_UID" != "$TARGET_UID" ]; then
sudo usermod -u "${TARGET_UID}" "${HOME_USER}"
fi
sudo find / -xdev \( -user "${CURRENT_UID}" -o -group "${CURRENT_GID}" \) \
-exec chown -h "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
fi
fi
```
Notas de diseño:
- `groupadd -g "${TARGET_GID}" "${HOME_USER}"` corre siempre (con `|| true`)
antes del `adduser`, porque `adduser --gid <numérico>` requiere que ya
exista un grupo con ese GID.
- A diferencia del borrador original (que solo comparaba UID), también se
compara GID y se resincroniza con `groupmod` si cambió — si no, un volumen
cuyo GID cambió quedaría con archivos "group-orphan".
- `id -gn` resuelve el nombre real del grupo primario actual (por si no
coincide con `${HOME_USER}`), en vez de asumirlo.
- El resync de archivos hace un solo `find` con `-user X -o -group Y` y
`chown -h uid:gid`, cubriendo tanto el caso de UID como de GID en un solo
pase.
- Se mantiene sin cambios el `sudo find "/home/${HOME_USER}" -xdev -exec chown
"${HOME_USER}" {} +` que ya existe más abajo (línea ~86, en la rama donde el
script ya corre como el usuario objetivo) — no es redundante, es una red de
seguridad adicional sobre `/home`, y sigue siendo válida tanto si el usuario
se creó de cero como si se resincronizó.
### Riesgos conocidos (documentar, no bloquean el cambio)
- Si `TARGET_UID`/`TARGET_GID` ya están tomados por otra cuenta/grupo del
sistema, `usermod -u` / `groupmod -g` fallarán (comportamiento actual de
esas herramientas; no se agrega manejo especial).
- El `find / -xdev` de resincronización recorre todo el filesystem del
contenedor (limitado por `-xdev` a no cruzar de montaje) — tiene un costo de
arranque en imágenes con muchos archivos, pero es el mismo patrón que ya usa
el script en la línea 86.
## Cambio 3 — actualizar `readme.md` para documentar las nuevas env vars
- En la sección "Environment Variables" (líneas 13-18), agregar `HOST_UID` y
`HOST_GID` junto a `HOME_USER`/`VSCODE_TUNNEL_NAME`, explicando que son
opcionales y que si no se setean se autodetectan desde el volumen montado o
caen a 1000.
- En la sección "Using this image as a base image in a Dockerfile" (líneas
148-172), agregar una nota breve indicando que el UID `1000` de ese ejemplo
es solo ilustrativo y que en runtime `entrypoint.sh` puede resincronizarlo
vía `HOST_UID`/`HOST_GID` si se pasan al contenedor final.
No se toca el `Dockerfile` (el `fixuid` hardcodeado a `vscode` en la línea 22
es código muerto hoy — no se invoca en `entrypoint.sh` — y está fuera del
alcance de este cambio).
## Verificación
1. `bash -n entrypoint.sh` para chequeo de sintaxis.
2. Levantar el contenedor sin `HOST_UID`/`HOST_GID` y sin volumen montado en
`/home/vscode` → debe crear el usuario con UID/GID 1000 (comportamiento
actual preservado).
3. Levantar el contenedor con `-e HOST_UID=1500 -e HOST_GID=1500` → el usuario
creado debe tener ese UID/GID (`id vscode` dentro del contenedor).
4. Montar un volumen en `/home/vscode` cuyo contenido pertenezca a un UID/GID
distinto de 1000 (sin pasar `HOST_UID`/`HOST_GID`) → el usuario creado debe
tomar ese UID/GID detectado (`stat -c '%u:%g' /home/vscode`).
5. Simular "imagen reusada": crear el usuario una vez, luego reiniciar el
contenedor con un `HOST_UID`/`HOST_GID` distinto → verificar que
`usermod`/`groupmod` corrieron y que archivos de prueba en `/home/vscode`
quedaron con el nuevo dueño (`ls -n /home/vscode`).
@@ -0,0 +1,84 @@
# Sincronizar migraciones de Supabase (dev + prod)
## Contexto
El repo tiene 37 archivos de migración en `supabase/migrations/`, el más reciente del
2026-07-22. La última verificación documentada de que ambas bases estaban al día es
`docs/plans/2026-06-08-supabase-dev-prod-finalization.md`, que confirma **9 migraciones
aplicadas y verificadas en ambas DBs** a esa fecha. Desde entonces se agregaron ~28
migraciones nuevas que probablemente nunca se empujaron a uno o ambos proyectos remotos
(streaks, ranking config, bonus settings, admin update policy, etc.). El objetivo es
detectar el gap real por ambiente y aplicarlo, sin asumir que "aplicar todo a ambos" es
seguro.
## Hallazgos clave (ya verificados, solo lectura)
- **Supabase CLI no está instalado globalmente**, pero `npx supabase@latest` funciona sin
instalación previa (resuelve a la v2.110.0).
- **Ya hay sesión autenticada**: `SUPABASE_ACCESS_TOKEN` está exportado en `~/.bashrc`, por
lo que `npx supabase projects list` ya respondió sin pedir login.
- **Dos proyectos remotos** (org `vercel_icfg_9zFCFZDSC4E6wody0k7rBxCB`):
| Ambiente | Nombre | project-ref |
|---|---|---|
| Production | `supabase-gfiber-prod` | `hwxkegbdnbrzvekrqxbd` |
| Preview/Dev | `supabase-gfiber-dev` | `vshcimexazocttjmyrqy` |
- El link local actual (`supabase/.temp/project-ref`) apunta a **prod** (`hwxkegbdnbrzvekrqxbd`,
`linked: true`); dev aparece `linked: false`. Habrá que re-linkear entre pasos.
- ⚠️ **Existen migraciones intencionalmente divergentes entre ambientes** — no todas las
migraciones deben aplicarse a ambos proyectos:
- `20260611210000_rollback_attendance_status_prod.sql` — deshace una migración anterior
**solo en prod** (prod seguía usando los códigos viejos de asistencia).
- `20260615120000_simplify_attendance_codes_dev.sql` — simplifica los códigos de
asistencia **solo en dev** ("DEV ONLY... Production is NOT touched", literal en el
comentario del archivo).
Un `supabase db push` ciego a ambos proyectos rompería este estado divergente a
propósito. Confirmé por grep que son las únicas dos migraciones con este marcador
(`_dev`/`_prod` en el nombre o comentario "DEV ONLY/PROD ONLY").
## Plan de ejecución
1. **Diagnóstico por ambiente** (solo lectura, no destructivo):
- `npx supabase link --project-ref hwxkegbdnbrzvekrqxbd` (si no sigue linkeado) y
`npx supabase migration list` → lista de migraciones locales vs. aplicadas en **prod**.
- `npx supabase link --project-ref vshcimexazocttjmyrqy` y `npx supabase migration list`
→ lo mismo para **dev**.
- Esto requiere el password de la DB de cada proyecto (`SUPABASE_DB_PASSWORD` está en
`.env` para dev; para prod habrá que sacarlo del vault de 1Password "GFiber Pilot
Extension" o de `vercel env pull --environment=production`, como se hizo en el plan de
finalización de junio).
- Registrar qué migraciones aparecen como "pendientes" en cada proyecto.
2. **Filtrar las migraciones dev/prod-only del diff genérico**:
- Si `20260611210000_rollback_attendance_status_prod.sql` aparece pendiente en **dev**,
no aplicarla ahí (es intencional que dev no la tenga).
- Si `20260615120000_simplify_attendance_codes_dev.sql` aparece pendiente en **prod**,
no aplicarla ahí (es intencional que prod no la tenga).
- Todas las demás migraciones pendientes (streaks, ranking config, bonus settings, etc.)
se tratan como gap real a cerrar en el proyecto donde falten.
3. **Aplicar el gap real**:
- `npx supabase db push --project-ref <ref>` en cada proyecto que tenga migraciones
pendientes reales (excluyendo las dev/prod-only según el paso 2).
- Confirmar con un segundo `migration list` que ambos remotos quedan con el estado
esperado (idéntico salvo las dos migraciones intencionalmente divergentes).
4. **Dejar el link local en un estado conocido** al terminar (documentar a cuál de los dos
quedó linkeado `supabase/.temp/project-ref`, ya que ambos comandos de push lo van
reescribiendo).
## Verificación
- `npx supabase migration list --project-ref hwxkegbdnbrzvekrqxbd` y
`... --project-ref vshcimexazocttjmyrqy` después del push, comparando que ambas listas
coincidan salvo las dos migraciones marcadas como divergentes a propósito.
- Smoke check opcional: revisar en el dashboard de cada proyecto (Table Editor) que las
tablas nuevas esperadas (`agent_streaks`, `ranking_config`, etc.) existan donde
correspondan.
## Nota de ejecución
Este trabajo (CLI de Supabase, migraciones de schema) cae dentro del scope del agente
`vercel-helper` según `CLAUDE.md`. Se recomienda delegarle la ejecución de los pasos 1-4
con este contexto ya resuelto (refs de proyecto, y la lista de migraciones dev/prod-only a
excluir), en vez de correr los comandos manualmente en la sesión principal.
@@ -0,0 +1,125 @@
# Skill global: cargar contexto de Docmost al iniciar conversación
## Contexto
El usuario quiere que, al iniciar cualquier conversación de Claude Code, se cargue automáticamente el
contexto del proyecto actual desde Docmost (MCP `mcp__docmost__*`): listar los spaces, identificar cuál
corresponde al proyecto en el que se está trabajando, y leer el contenido completo de todas sus páginas.
Investigación realizada (en modo solo-lectura):
- No existe ningún CLAUDE.md global ni hook `SessionStart` hoy en `~/.claude/settings.json` (solo hay un
hook `Notification` para email vía Gmail).
- Las **skills se activan por coincidencia de descripción**, no automáticamente al iniciar sesión. Para que
algo corra *siempre* al inicio, el único mecanismo real es un **hook `SessionStart`**, cuyo `stdout` se
inyecta como contexto adicional (esto es lo que documenta `hooks-guide.md` de Claude Code, ej. "Re-inject
context after compaction"). El hook (bash) no puede llamar herramientas MCP directamente — eso solo lo
puede hacer el modelo. Por eso el diseño es: **hook (bash) → inyecta instrucción → skill (markdown) →
modelo ejecuta las llamadas MCP reales**.
- Ya existe en `background-orchestrator/SKILL.md` una lógica de detección de proyecto reutilizable
(`package.json``pyproject.toml` → basename del repo git), que se reutiliza aquí tal cual.
- Verifiqué en vivo el MCP de Docmost: `list_spaces` devuelve `{id, name, slug, description, ...}`;
`list_pages({spaceId})` devuelve el árbol de páginas (`id, title, parentPageId`) sin contenido;
`get_page({pageId})` sí devuelve el contenido completo (`content`, markdown). Los nombres de space no
siempre calzan literal con el nombre del repo (ej. space "Create React SSR App" vs. un repo en
kebab-case), así que el match debe ser normalizado (minúsculas, sin espacios/guiones), no exacto.
Algunos spaces ya tienen 30+ páginas con contenido extenso.
Decisiones confirmadas con el usuario:
1. **Leer todo siempre** — al detectar el space, leer el contenido completo de todas sus páginas (sin
recortar ni resumir antes de cargar).
2. **Si no hay match** — preguntar al usuario (mostrar los spaces disponibles y dejar que elija uno o
indique que no corresponde ninguno).
3. **Alcance** — el hook solo actúa si el cwd está dentro de un repo git; fuera de un repo no intenta nada.
## Diseño
### 1. Script del hook — `~/.claude/hooks/docmost-session-start.sh` (nuevo, `chmod +x`)
Bash puro, sin llamadas MCP (no puede hacerlas). Solo detecta el proyecto y, si aplica, imprime a stdout
una instrucción para que el modelo invoque la skill. Si no está en un repo git, no imprime nada (`exit 0`
silencioso → no se inyecta contexto ni se gasta turno en esto).
```bash
#!/bin/bash
# Solo actuar dentro de un repo git (decisión confirmada con el usuario)
git rev-parse --show-toplevel >/dev/null 2>&1 || exit 0
PROJECT_NAME=$(jq -r '.name // empty' package.json 2>/dev/null)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(grep -oP '^\s*name\s*=\s*"\K[^"]+' pyproject.toml 2>/dev/null | head -1)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(basename "$(git rev-parse --show-toplevel)")
cat <<EOF
[docmost-context] Nueva conversación detectada en el proyecto git "$PROJECT_NAME" ($(pwd)).
Antes de responder al primer mensaje del usuario, invoca la skill "docmost-context" (Skill tool,
skill: docmost-context, args: "$PROJECT_NAME") para cargar el contexto del proyecto desde Docmost.
Hazlo una sola vez al inicio de esta conversación; no la vuelvas a invocar en turnos posteriores salvo
que el usuario lo pida explícitamente.
EOF
```
### 2. Registrar el hook en `~/.claude/settings.json` (editar)
Añadir la clave `SessionStart` dentro de `hooks` (se conserva el `Notification` existente). Se registran
tres matchers por separado (`startup`, `resume`, `clear`) — deliberadamente **sin** `compact`, para no
releer 30+ páginas completas cada vez que el contexto se compacta a mitad de conversación:
```json
"SessionStart": [
{ "matcher": "startup", "hooks": [{ "type": "command", "command": "/home/aleleba/.claude/hooks/docmost-session-start.sh" }] },
{ "matcher": "resume", "hooks": [{ "type": "command", "command": "/home/aleleba/.claude/hooks/docmost-session-start.sh" }] },
{ "matcher": "clear", "hooks": [{ "type": "command", "command": "/home/aleleba/.claude/hooks/docmost-session-start.sh" }] }
]
```
También añadir a `permissions.allow`: `"mcp__docmost__list_spaces"` (los demás —`list_pages`, `get_page`,
`search`— ya están permitidos desde `background-orchestrator`).
### 3. Skill global — `~/.claude/skills/docmost-context/SKILL.md` (nuevo)
```yaml
---
name: docmost-context
description: >-
Carga el contexto del proyecto actual desde Docmost (MCP) al inicio de una conversación: lista los
spaces, identifica cuál corresponde al proyecto del directorio actual, y lee el contenido completo
de todas sus páginas. Se activa automáticamente vía el hook SessionStart (ver
~/.claude/hooks/docmost-session-start.sh) — no requiere que el usuario la invoque a mano, aunque
también responde a "carga el contexto de Docmost", "lee el proyecto en Docmost", "/docmost-context".
effort: medium
argument-hint: "[nombre del proyecto, si ya se conoce]"
---
```
Cuerpo (workflow):
1. **Determinar el nombre del proyecto**: usar el argumento recibido si viene; si no, repetir la misma
detección de `background-orchestrator` (`package.json``pyproject.toml` → basename del repo git).
2. **Listar spaces**: `mcp__docmost__list_spaces`.
3. **Buscar coincidencia** comparando el nombre del proyecto contra `name` y `slug` de cada space,
normalizando (minúsculas, sin espacios/guiones/guiones bajos) — match exacto normalizado, no
substring débil.
- **Una sola coincidencia** → usarla directamente, sin confirmar con el usuario.
- **Cero coincidencias** → usar `AskUserQuestion` mostrando la lista de spaces disponibles, dejando
elegir uno existente o responder que ninguno corresponde (en ese caso, no cargar nada y continuar
la conversación normalmente).
- **Varias coincidencias ambiguas** → preguntar igual, mostrando solo los candidatos ambiguos.
4. **Leer el space completo**: `mcp__docmost__list_pages({spaceId})` para obtener todas las páginas, y
luego `mcp__docmost__get_page({pageId})` para **cada una** (contenido completo, sin omitir ninguna).
5. **No repetir el dump al usuario**: una vez cargado el contenido al contexto, responder con un mensaje
breve (1-2 líneas) indicando qué space y cuántas páginas se cargaron — no transcribir el contenido de
las páginas en el chat.
6. Ejecutar esto **una sola vez** por conversación (al primer turno, disparado por el hook); no repetir
en turnos siguientes salvo pedido explícito del usuario.
## Verificación
1. `chmod +x ~/.claude/hooks/docmost-session-start.sh` y ejecutarlo manualmente dentro de un repo git y
fuera de uno, confirmando que imprime la instrucción solo en el primer caso.
2. Iniciar una conversación nueva (`startup`) dentro de un repo con space conocido (ej. `Ro-ut`) y
confirmar que el modelo invoca la skill sin que el usuario lo pida, y que reporta el space/páginas
cargadas en 1-2 líneas.
3. Probar dentro de un repo **sin** space coincidente y confirmar que se dispara `AskUserQuestion` con la
lista de spaces.
4. Si en la prueba real el `stdout` plano del hook no llega como contexto, usar como fallback el formato
JSON documentado: `{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "..."}}`.
5. Confirmar que `/clear` también dispara la carga, y que `compact` (compactación automática) **no** la
repite.
@@ -0,0 +1,62 @@
# Mezclar los dos docker-compose en `vscode-server/docker-compose.yaml`
## Context
Existen dos entornos de desarrollo casi idénticos:
- [dev-environment/vscode-server/docker-compose.yaml](dev-environment/vscode-server/docker-compose.yaml) — usuario `aleleba`, túnel `vscode-aleleba`, rutas de host base `/home/aleleba/nas/docker/`.
- [dev-environment/vscode-server-telus/docker-compose.yaml](dev-environment/vscode-server-telus/docker-compose.yaml) — usuario `aleleba-telus`, túnel `vscode-aleleba-telus`, rutas de host base `/Volumes/docker/` (estilo Mac).
Se quiere un único archivo que combine ambos: conservar la identidad personal (`aleleba` / `vscode-aleleba`) pero usar la ruta base de host `/Volumes/docker/` del entorno telus, dejando comentada la ruta anterior de host. Además debe montar **los dos** volúmenes de proyectos (`projects` y `projects-telus`). El resultado se escribe **modificando** el archivo `vscode-server/docker-compose.yaml`.
## Decisiones confirmadas
- Túnel: `vscode-aleleba` — Usuario: `HOME_USER: aleleba` (sin cambios; ya son los valores de este archivo).
- Ruta base de host **activa**: `/Volumes/docker/`.
- Llaves SSH (`id_key`, `id_key.pub`, `id_rsa`, `id_rsa.pub`, `known_hosts`): usar también base uniforme `/Volumes/docker/projects/synology-apps-containers/dev-environment/...` (NO la ruta `/Users/a.barrientes/...` del archivo telus).
- Segundo volumen de proyectos `projects-telus` se monta en `/home/aleleba/projects-telus`.
- Todos los targets del contenedor usan `/home/aleleba/...` (porque el usuario es `aleleba`).
## Cambios a realizar (solo en `dev-environment/vscode-server/docker-compose.yaml`)
La sección `environment:` no cambia (ya tiene `HOME_USER: aleleba` y `VSCODE_TUNNEL_NAME: vscode-aleleba`).
Reescribir el bloque `volumes:` de modo que **cada** volumen tenga tres líneas:
1. `# - /volume1/docker/...` (comentada, se conserva).
2. `# - /home/aleleba/nas/docker/...` (comentada — la ruta anterior de este archivo, ahora comentada según lo pedido).
3. `- /Volumes/docker/...` (activa — la ruta base nueva).
Patrón por volumen (ejemplo con `projects`):
```yaml
# - /volume1/docker/projects:/home/aleleba/projects
# - /home/aleleba/nas/docker/projects:/home/aleleba/projects
- /Volumes/docker/projects:/home/aleleba/projects
```
Añadir el segundo volumen de proyectos justo después de `projects`:
```yaml
# - /volume1/docker/projects-telus:/home/aleleba/projects-telus
- /Volumes/docker/projects-telus:/home/aleleba/projects-telus
```
Aplicar el mismo patrón (línea activa `/Volumes/docker/...`, target `/home/aleleba/...`) a todos los volúmenes existentes:
`certs`, `.gnupg`, `.npmrc`, `gh config.yml`, `gh hosts.yml`, `.gitconfig`, las 5 llaves SSH/`known_hosts`, `docker.config.json`, `kubernetes.config`, `daemon.json`, `vscode-server/custom-scripts`, `vscode-server/extensions.json`, `.claude.json`, `claude.settings.json`, `claude-plans`, `claude-plugins`, `claude-sessions`, `claude-skills`, `claude-commands`, `claude-hooks`, `claude-agents`.
Notas de mapeo:
- Se conservan los sub-paths propios de este archivo (p. ej. `vscode-server/custom-scripts` y `vscode-server/extensions.json`, NO los `vscode-server-telus/...`) y `claude.settings.json` (NO `claude-telus.settings.json`).
- Las llaves SSH quedan en `/Volumes/docker/projects/synology-apps-containers/dev-environment/id_key``/home/aleleba/.ssh/id_key`, etc.
## Verificación
Desde `dev-environment/vscode-server/`:
```bash
docker compose config
```
Debe validar sin errores de YAML y mostrar el servicio `vscode-server` con:
- `HOME_USER=aleleba`, `VSCODE_TUNNEL_NAME=vscode-aleleba`.
- Todos los binds resueltos desde `/Volumes/docker/...`.
- Ambos montajes: `/home/aleleba/projects` y `/home/aleleba/projects-telus`.
(Opcional) Revisar visualmente que cada volumen tiene su línea `/home/aleleba/nas/docker/...` comentada.
@@ -0,0 +1,114 @@
# Etapa 2 — Backend: Company y Office CRUD
## Contexto
Ro-ut v2.1.5 tiene la arquitectura completa (SSR React + GraphQL embebido + PostgreSQL) pero solo implementa autenticación y un CRUD básico de usuarios. El schema SQL ya incluye las tablas `Company` y `Office` con todos los campos planeados (Etapa 1 completada). La Etapa 2 expone estas entidades vía GraphQL siguiendo el mismo patrón 4-archivos que User: **model → controller → schema → resolver**.
## Patrón a seguir (basado en User)
Cada entidad necesita 4 archivos nuevos + actualización de 3 archivos de registro:
| Capa | Patrón User | Company | Office |
|------|-------------|---------|--------|
| Model | `dataUsuario.ts` | `dataCompany.ts` | `dataOffice.ts` |
| Controller | `dataUsuario.ts` | `dataCompany.ts` | `dataOffice.ts` |
| Schema | `user.schema.ts` | `company.schema.ts` | `office.schema.ts` |
| Resolver | `user.resolver.ts` | `company.resolver.ts` | `office.resolver.ts` |
## Archivos a crear
### 1. Model — `src/server/models/apiPostgresModel/dataCompany.ts`
- `getCompany(id)` → SELECT por Id
- `getCompanies()` → SELECT sin filtro
- `insertCompany(company)` → INSERT RETURNING Id
- `editCompany(id, company)` → UPDATE
- `deleteCompany(id)` → DELETE
### 2. Model — `src/server/models/apiPostgresModel/dataOffice.ts`
- `getOffice(id)` → SELECT por Id
- `getOffices()` → SELECT sin filtro
- `getOfficesByCompany(companyId)` → SELECT por FK_Company
- `insertOffice(office)` → INSERT RETURNING Id
- `editOffice(id, office)` → UPDATE
- `deleteOffice(id)` → DELETE
### 3. Controller — `src/server/controllers/controllerGraphQL/dataCompany.ts`
- Mapea model results: `Id``id` (string)
- Exporta: `getCompany`, `getCompanies`, `insertCompany`, `editCompany`, `deleteCompany`
### 4. Controller — `src/server/controllers/controllerGraphQL/dataOffice.ts`
- Mapea model results: `Id``id` (string)
- Exporta: `getOffice`, `getOffices`, `getOfficesByCompany`, `insertOffice`, `editOffice`, `deleteOffice`
### 5. Schema — `src/server/GraphQL/schema/company.schema.ts`
- `@ObjectType('Company')` — campos: id, name, businessName, type, country, phone
- `@InputType('InputCompany')` — campos editables: name, businessName, type, country, phone
- `@ObjectType('CompaniesQuery')` — fields: `company(id)`, `companies()`
- `@ObjectType('CompanyMutation')` — mutations: `insertCompany`, `editCompany`, `deleteCompany`
- Cada field/mutation llama al controller correspondiente
### 6. Schema — `src/server/GraphQL/schema/office.schema.ts`
- `@ObjectType('Office')` — campos: id, name, address, email, city, country, lat, lng, company (FK)
- `@InputType('InputOffice')` — campos editables
- `@ObjectType('OfficesQuery')` — fields: `office(id)`, `offices()`, `officesByCompany(companyId)`
- `@ObjectType('OfficeMutation')` — mutations: `insertOffice`, `editOffice`, `deleteOffice`
### 7. Resolver — `src/server/GraphQL/resolvers/company.resolver.ts`
- `@Resolver(() => CompanyMutation)` — query `companyMutation()` → CompanyMutation
- `@Resolver(() => CompaniesQuery)` — query `companiesQuery()` → CompaniesQuery
### 8. Resolver — `src/server/GraphQL/resolvers/office.resolver.ts`
- `@Resolver(() => OfficeMutation)` — query `officeMutation()` → OfficeMutation
- `@Resolver(() => OfficesQuery)` — query `officesQuery()` → OfficesQuery
### 9. Tests — `src/server/tests/server/company/index.test.ts`
- Tests supertest para: insert, get, list, edit, delete (patrón exacto de `user/index.test.ts`)
### 10. Tests — `src/server/tests/server/office/index.test.ts`
- Tests supertest para: insert, get, list, getByCompany, edit, delete
## Archivos a modificar
### 1. `src/server/models/apiPostgresModel/index.ts`
```ts
export * from './dataLogIn';
export * from './dataUsuario';
export * from './dataCompany';
export * from './dataOffice';
```
### 2. `src/server/controllers/controllerGraphQL/index.ts`
```ts
export * from './dataUsuario';
export * from './dataLogin';
export * from './dataCompany';
export * from './dataOffice';
```
### 3. `src/server/GraphQL/schema/index.ts`
- Import `Company`/`InputCompany`/`CompaniesQuery`/`CompanyMutation` de `company.schema.ts`
- Import `Office`/`InputOffice`/`OfficesQuery`/`OfficeMutation` de `office.schema.ts`
- Asegurar que se exporten (type-graphql necesita los imports para registrar los decoradores)
### 4. `src/server/GraphQL/resolvers/index.ts`
- Import y export `CompanyMutationResolver`, `CompaniesQueryResolver` (o como se llamen)
- Import y export `OfficeMutationResolver`, `OfficesQueryResolver`
### 5. `src/server/GraphQL/server.ts`
- Agregar los 4 resolvers a `buildSchemaSync({ resolvers: [...] })`
## Ejecución
Este plan será ejecutado por un **agente en background** usando la skill global **background-orchestrator**. El agente se encarga de:
- Crear los 10 archivos nuevos siguiendo el patrón exacto de User
- Modificar los 5 archivos de registro
- Verificar con `npm run lint`, `npm run build` y `npm run test:backend`
- Crear el PR correspondiente en Gitea
## Verificación
1. `npm run lint` — sin errores
2. `npm run build` — compila sin errores (type-graphql registra todos los decoradores)
3. `npm run test:backend` — tests de integración verdes (requiere PostgreSQL con schema completo)
4. `npm run test:frontend` — tests frontend sin cambios
@@ -0,0 +1,158 @@
# Plan: Etapa 3 — Enforcement de permisos
## Contexto
Ro-ut tiene un campo `permissions` en la tabla `User` (JSONB) que se guarda y retorna, pero **nunca se verifica** antes de ejecutar operaciones. Los resolvers de User/Company/Office llaman a `userExtractor` que inyecta `req.usuario` con solo el **username** (`decoded.sub` = string `"aleleba"`), pero no verifica permisos.
### Restricciones del CI
El CI usa dos archivos SQL:
- **Integración tests** (`.gitea/workflows/main-workflow.yml` línea 91): `src/server/sql/ro-ut_app_pg.sql` — schema sin usuario seed
- **E2E tests** (línea 137): `src/server/sql/ro-ut_app_pg_with_user.sql` — schema + admin user seed con `permissions` todos `true`
Los tests de integración (`src/server/tests/configTest.ts`) usan un JWT hardcodeado:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGVsZWJhIiwiaWF0IjoxNjczMzc0NDA0fQ...
```
Que decodifica a `{"sub":"aleleba","iat":1673374404}`**sin campo `permissions`**.
Esto significa que los tests de integración **no usan BD** para autenticación — usan un JWT hardcodeado. Si implemento permission checking leyendo del JWT, los tests existentes fallarán porque `aleleba` no tiene `permissions` en el token.
## Plan de implementación
### 3.1 — Embed permisos en el JWT y actualizar `userExtractor`
**Archivo:** `src/server/controllers/controllerGraphQL/dataLogin.ts`
Al firmar el access token, incluir `permissions` en el payload (líneas 29 y 70):
```ts
// logIn (línea 29):
const sessionToken = jwt.sign(
{ sub: resultJson.user, permissions: parsedPermissions },
config.authJwtSecret as Secret
);
// getLogIn (línea 70):
const sessionToken = jwt.sign(
{ sub: resultJson.user, permissions: parsedPermissions },
config.authJwtSecret as Secret
);
```
Donde `parsedPermissions` es el objeto permissions parseado (JSON.parse si es string, o el objeto directo).
**Archivo:** `src/server/middlewares/userExtractor.ts`
Modificar para que `req.usuario` sea un objeto con `user` y `permissions`:
```ts
const usuario = decoded.sub;
const permissions = decoded.permissions ?? null;
req.usuario = { user: usuario, permissions };
```
### 3.2 — Actualizar el token de sesión para tests
**Archivo:** `src/server/tests/configTest.ts`
Actualizar `sessionToken` para incluir `permissions` en el JWT payload, de modo que los tests existentes sigan pasando. El token actual decodifica a `{"sub":"aleleba","iat":1673374404}` — se necesita `{"sub":"aleleba","permissions":{"adminUser":{"ViewUser":true,"CreateUser":true,"EditUser":true,"DeleteUser":true}},"iat":1673374404}` firmado con la misma secret (`7d84e455e8457d4a5c7c66562aca16b4a411ad6a743ea0f7b4f1dc56aa3c9eaf`).
### 3.3 — Crear función de verificación de permisos
**Nuevo archivo:** `src/server/middlewares/permissionChecker.ts`
```ts
export function checkPermission(
usuario: { user: string; permissions: any },
permission: string
): void {
if (!usuario?.permissions?.adminUser?.[permission]) {
throw new Error(`Permission denied: required ${permission}`);
}
}
```
### 3.4 — Verificar permisos en los resolvers de User
**Archivo:** `src/server/GraphQL/schema/user.schema.ts`
En cada resolver, después de `userExtractor`, llamar `checkPermission`:
- `UsersQuery.user()` y `UsersQuery.users()``checkPermission(usuario, 'ViewUser')`
- `UserMutation.insertUser()``checkPermission(usuario, 'CreateUser')`
- `UserMutation.editUser()``checkPermission(usuario, 'EditUser')`
- `UserMutation.deleteUser()``checkPermission(usuario, 'DeleteUser')`
### 3.5 — Definir y verificar permisos para Company y Office
**Archivos:** `src/server/GraphQL/schema/company.schema.ts`, `src/server/GraphQL/schema/office.schema.ts`
Añadir campos en `AdminUser` (el tipo ya existe en user.schema.ts, se extiende):
```ts
// En AdminUser:
@Field() ViewCompany?: boolean;
@Field() CreateCompany?: boolean;
@Field() EditCompany?: boolean;
@Field() DeleteCompany?: boolean;
@Field() ViewOffice?: boolean;
@Field() CreateOffice?: boolean;
@Field() EditOffice?: boolean;
@Field() DeleteOffice?: boolean;
```
Verificar en cada resolver de Company/Office con `checkPermission`.
### 3.6 — Tests
**Nuevo archivo:** `src/server/tests/server/permissions/index.test.ts`
Tests que verifiquen:
1. Usuario con todos los permisos → todas las operaciones pasan (existing behavior)
2. Usuario con `ViewUser: false``users()` y `user()` denegados
3. Usuario con `CreateUser: false``insertUser()` denegado
4. Usuario con `DeleteUser: false``deleteUser()` denegado
5. Comportamiento similar para Company y Office
**Actualizar:** `src/server/tests/server/company/index.test.ts` y `src/server/tests/server/office/index.test.ts` — añadir tests de permisos.
## Archivos a modificar
| Archivo | Acción |
|---------|--------|
| `src/server/controllers/controllerGraphQL/dataLogin.ts` | Modificar: incluir `permissions` en JWT payload |
| `src/server/middlewares/userExtractor.ts` | Modificar: inyectar `permissions` en `req.usuario` |
| `src/server/middlewares/permissionChecker.ts` | **Nuevo**: función de verificación |
| `src/server/GraphQL/schema/user.schema.ts` | Modificar: llamar `checkPermission` en cada resolver |
| `src/server/GraphQL/schema/company.schema.ts` | Modificar: añadir permisos Company, llamar `checkPermission` |
| `src/server/GraphQL/schema/office.schema.ts` | Modificar: añadir permisos Office, llamar `checkPermission` |
| `src/server/tests/configTest.ts` | Modificar: actualizar sessionToken con permissions |
| `src/server/tests/server/permissions/index.test.ts` | **Nuevo**: tests de enforcement |
## Verificación local
```bash
npm run test:backend
```
Todos los tests existentes deben seguir pasando (el sessionToken actualizado tiene todos los permisos en `true`). Los nuevos tests verifican que sin permisos se deniega la operación.
## Verificación en CI
El workflow `.gitea/workflows/main-workflow.yml` ya ejecuta `npm run test:backend` en el job `integration-back-end-testing` (línea 95). La verificación de permisos se integra automáticamente:
1. **Job `integration-back-end-testing`** (línea 62-103): aplica schema con `ro-ut_app_pg.sql`, ejecuta `npm run test:backend`. Los tests de integración usan el JWT hardcodeado de `configTest.ts` — al actualizar el token con `permissions: {adminUser: {ViewUser: true, ...}}`, los tests existentes pasan.
2. **Job `end-to-end-testing`** (línea 106-166): aplica schema con `ro-ut_app_pg_with_user.sql` (seed con `permissions` todos `true`), build y corre Cypress E2E. El login real genera JWTs con `permissions` embebidos (dataLogin.ts), así que el enforcement funciona en producción.
3. **Job `unit-front-end-testing`** (línea 19-34): tests React — no afectados.
4. **Job `cypress-components-testing`** (línea 36-59): Cypress components — no afectados.
## Desarrollo del ticket con agent-orchestrator
**IMPORTANTE:** Este ticket debe desarrollarse usando la skill `agent-orchestrator` para orquestar un agente en background que ejecute la implementación completa. El flujo es:
1. Invocar la skill `agent-orchestrator`
2. Pasar este plan como instrucción al agente
3. El agente ejecutará todos los cambios de código en background
4. Verificar que `npm run test:backend` pase
5. Crear PR con los cambios
@@ -0,0 +1,115 @@
# Plan: Fix Button styles not loading in Storybook
## Context
The Button component in Storybook renders with **no styling** — buttons appear as plain HTML elements with default browser styles. The screenshot shows "Primary", "Outline Gold", "Outline Dark" etc. all as unstyled rectangles. The root cause is that Tailwind CSS utility classes are never being processed.
## Root cause analysis
The Button component (`src/components/Button/index.tsx`) uses Tailwind utility classes like:
- `w-btnPrimary`, `h-btn`, `bg-gold`, `text-amber`, `text-btnPrimary`, `font-bold`
- `rounded-btn`, `px-5`, `gap-2`, `flex`, `items-center`, `justify-center`
- `border-gold`, `border-black`, `border-white`, `border-danger`
- `hover:bg-amber`, `disabled:opacity-50`, etc.
But **none of these utilities are being generated** because:
1. **`tailwind.config.js` is not read by Tailwind v4.** Tailwind CSS v4 requires CSS-based configuration via the `@theme` directive. The JS config file is silently ignored.
2. **No CSS file imports Tailwind.** The Button component only imports `Button.scss` (which only defines border utilities, not Tailwind base/utilities). There is no `@import "tailwindcss"` anywhere in the source.
3. **`.storybook/preview.js` does not import any global CSS.** Storybook renders stories without any CSS entry point that would trigger Tailwind's utility generation.
## Fix
### 1. Create `src/styles/tailwind.css`
A CSS file that imports Tailwind and defines all custom design tokens using the `@theme` directive (v4 syntax):
```css
@import "tailwindcss";
@theme {
/* Colors */
--color-gold: #EABF2D;
--color-amber: #D4880F;
--color-black: #1A1A2E;
--color-danger: #DE3626;
--color-muted: #9AA0A6;
--color-gray: #6B6B7B;
--color-input-border: #DADCE0;
/* Border radius */
--radius-btn: 6px;
--radius-4px: 4px;
--radius-pill: 20px;
--radius-circle: 28px;
--radius-none: 0px;
/* Font sizes */
--text-btn: 11px;
--text-btnPrimary: 12px;
--text-btnNormal: 12px;
--text-btnCircular: 20px;
/* Heights */
--height-btn: 40px;
--height-btnLg: 60px;
--height-btnCircle: 56px;
--height-btnIcon: 36px;
/* Widths */
--width-btnPrimary: 170px;
--width-btnOutlineGold: 230px;
--width-btnOutline: 110px;
--width-btnOutlineLight: 140px;
--width-btnGhost: 110px;
--width-btnFileUpload: 200px;
--width-btnCircle: 56px;
--width-btnIcon: 36px;
/* Font family */
--font-family-sans: 'Source Sans Pro', system-ui, sans-serif;
}
```
Key changes from the old `tailwind.config.js`:
- `fontSize``--text-*` (v4 uses `text` not `fontSize`)
- `borderRadius``--radius-*` (v4 uses `radius` not `borderRadius`)
- `height``--height-*` (v4 uses `height` not `height`)
- `width``--width-*` (v4 uses `width` not `width`)
- `fontFamily``--font-family-*` (v4 uses `font-family` prefix)
- Font weight removed from text tokens (v4 handles fontWeight via `font-*` utilities)
- Changed font from `'sourcesanspro'` to `'Source Sans Pro'` (proper font name)
### 2. Update `.storybook/preview.js`
Import the Tailwind CSS file globally so all stories get the styles:
```js
import '../src/styles/tailwind.css';
export const parameters = {
// ... existing config
};
export const tags = ['autodocs'];
```
This ensures the `@import "tailwindcss"` in the CSS file is processed by PostCSS, generating all utility classes and applying the `@theme` design tokens.
## Files to modify
| File | Action |
|------|--------|
| `src/styles/tailwind.css` | **Create** — Tailwind import + `@theme` with all design tokens |
| `.storybook/preview.js` | **Modify** — Add `import '../src/styles/tailwind.css';` at the top |
## Verification
1. Run `npm run storybook` in the worktree
2. Open Storybook at `localhost:3000`
3. Check that the "Primary" story shows a styled gold button (not a plain rectangle)
4. Check that "Outline Gold" shows a button with gold border and amber text
5. Check that "Outline Dark" shows a button with black border
6. Check that "Ghost" shows a pill-shaped button with hover effects
7. Verify all 9 variants render with correct dimensions, colors, and typography
@@ -0,0 +1,43 @@
# Plan: Actualizar formato de páginas de agentes en Docmost
## Contexto
El usuario quiere que las páginas de agentes en Docmost usen **formato de tablas** (en vez de listas) para identificar información más rápido. Además, quiere que el TASK.md del agente incluya **checkboxes** por tarea y que el agente **reporte en Docmost** al completar cada tarea y cada commit.
El bug de tablas en `update_page` ya fue corregido, así que las tablas ahora funcionan correctamente.
## Skills a modificar
### 1. [agent-instructions/SKILL.md](/home/aleleba/.claude/skills/agent-instructions/SKILL.md)
**Cambios:**
- Agregar **checkboxes** (`- [ ]` / `- [x]`) en las tasks del TASK.md para que el agente marque progreso por tarea
- Agregar regla en la sección "DOCMOST DURANTE EL TRABAJO" que obligue al agente a:
- Reportar en su subpágina de Docmost **por cada commit** (qué hizo, archivos modificados)
- Reportar **por cada tarea completada** (marcar checkbox + resumen en Docmost)
- Agregar una tabla de **checklist de tareas** en el TASK.md donde cada tarea tenga su checkbox
### 2. [agent-launcher/SKILL.md](/home/aleleba/.claude/skills/agent-launcher/SKILL.md)
**Cambios:**
- En el paso 7 (registro en Docmost), crear la subpágina del agente con **formato de tablas** en vez de listas:
- Tabla con: Nombre, Session ID, Tarea, Worktree, Rama, Estado, Inicio
- Tabla para secciones de progreso (Progreso, Razonamiento, Subagentes, Archivos, Validación, Resultado, Link PR)
- En el paso 7a (página "Agentes Activos"), usar **tabla** para listar agentes activos
### 3. [agent-archiver/SKILL.md](/home/aleleba/.claude/skills/agent-archiver/SKILL.md)
**Cambios:**
- Actualizar la regla de tablas (línea 31) para reflejar que las tablas ahora funcionan con `update_page`
- Actualizar referencias a "lista" → "tabla" donde corresponda
### 4. [agent-orchestrator/SKILL.md](/home/aleleba/.claude/skills/agent-orchestrator/SKILL.md)
**Cambios:**
- Actualizar referencias a las skills hijas si cambian el formato de Docmost
## Verificación
1. Leer las 4 files después de los cambios para confirmar que el formato es consistente
2. Verificar que las tablas markdown usen formato correcto (filas separadoras `| --- |` en todas las tablas)
3. Confirmar que los checkboxes en TASK.md usan el formato `- [ ]` / `- [x]` estándar de markdown
@@ -0,0 +1,14 @@
# Actualizar dependencias con `npm run check-updates`
## Contexto
El proyecto `@aleleba/create-react-component-library` (v1.4.2) es un starter kit para bibliotecas de componentes React con Storybook, Webpack 5, TypeScript y Jest.
## Plan
1. Ejecutar `npm run check-updates` que corre `npx npm-check-updates -u && npm i`
- `-u` actualiza `package.json` con las versiones más recientes disponibles
- `npm i` instala las nuevas versiones y actualiza `package-lock.json`
2. Revisar el output para documentar qué dependencias se actualizaron
## Archivos modificados
- `package.json` (versiones actualizadas)
- `package-lock.json` (sincronizado con nuevas versiones)
@@ -0,0 +1,117 @@
# Plan: Hacer `02_sudo_wrapdocker.sh` compatible con x86 y ARM
## Context
El script `02_sudo_wrapdocker.sh` configura Docker-in-Docker (DinD) dentro de un contenedor. Funciona en x86 (Ubuntu en Synology NAS) pero falla en ARM (Ubuntu en Mac M-series) porque el script fue escrito para **cgroup v1** con jerarquías individuales, mientras que Ubuntu moderno usa **cgroup v2** con jerarquía unificada.
Además, tu screenshot muestra un error secundario: `/etc/docker/daemon.json` es un directorio en vez de un archivo, lo que impide que dockerd inicie.
## Problemas identificados
| Problema | Línea | Impacto en ARM |
|----------|-------|-----------------|
| `dmsetup mknodes` sin guard | 8 | Error si no existe en ARM |
| Mount de tmpfs en `/sys/fs/cgroup` | 17-21 | En cgroup v2 ya está montado → falla |
| `for SUBSYS` sobre `/proc/1/cgroup` | 32-62 | `0::/``cut -d: -f2` da vacío → mount falla |
| Checks de `devices` cgroup | 67-70 | Nunca coinciden en v2 → warnings falsos |
| `/etc/docker/daemon.json` es directorio | N/A | dockerd no puede leer config |
## Implementación
### Paso 1: Detectar versión de cgroup
Insertar después de la línea 12:
```bash
# Detect cgroup version
CGROUP_VERSION=1
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
CGROUP_VERSION=2
elif grep -q '0::/' /proc/1/cgroup 2>/dev/null; then
CGROUP_VERSION=2
fi
```
### Paso 2: Guardar `dmsetup`
```bash
if command -v dmsetup >/dev/null 2>&1; then
dmsetup mknodes 2>/dev/null || true
fi
```
### Paso 3: Reemplazar bloque de cgroups (líneas 14-62)
```bash
# First, make sure that cgroups are mounted correctly.
CGROUP=/sys/fs/cgroup
: {LOG:=stdio}
if [ "$CGROUP_VERSION" = "2" ]; then
# cgroup v2: unified hierarchy already mounted at /sys/fs/cgroup
# No need to mount individual subsystems
:
else
# cgroup v1: mount individual subsystem hierarchies
[ -d $CGROUP ] || mkdir $CGROUP
mountpoint -q $CGROUP ||
mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || {
echo "Could not make a tmpfs mount. Did you use --privileged?"
exit 1
}
if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security
then
mount -t securityfs none /sys/kernel/security || {
echo "Could not mount /sys/kernel/security."
echo "AppArmor detection and --privileged mode might break."
}
fi
for SUBSYS in $(cut -d: -f2 /proc/1/cgroup); do
[ -z "$SUBSYS" ] && continue
[ -d $CGROUP/$SUBSYS ] || mkdir $CGROUP/$SUBSYS
mountpoint -q $CGROUP/$SUBSYS ||
mount -n -t cgroup -o $SUBSYS cgroup $CGROUP/$SUBSYS
echo $SUBSYS | grep -q ^name= && {
NAME=$(echo $SUBSYS | sed s/^name=//)
ln -s $SUBSYS $CGROUP/$NAME
}
[ $SUBSYS = cpuacct,cpu ] && ln -s $SUBSYS $CGROUP/cpu,cpuacct
done
fi
```
### Paso 4: Hacer conditional los warnings de devices (líneas 67-70)
```bash
# Only check for devices cgroup on v1 systems
if [ "$CGROUP_VERSION" = "1" ]; then
grep -q :devices: /proc/1/cgroup ||
echo "WARNING: the 'devices' cgroup should be in its own hierarchy."
grep -qw devices /proc/1/cgroup ||
echo "WARNING: it looks like the 'devices' cgroup is not mounted."
fi
```
### Paso 5: Fix del `/etc/docker/daemon.json`
El archivo `/etc/docker/daemon.json` en el contenedor es un **directorio** en vez de un archivo. Esto se debe probablemente a un volumen montado incorrectamente o a una capa de imagen corrupta. Solución:
- Verificar si el volumen montado en el host (`daemon-mac-telus.json`) es correcto
- Si es un directorio, eliminarlo y recrearlo como archivo
- O agregar un paso en el script que verifique y corrija esto antes de iniciar dockerd
## Archivos a modificar
| Archivo | Cambio |
|---------|--------|
| `dev-environment/vscode-server-telus/custom-scripts/02_sudo_wrapdocker.sh` | Reescribir bloque de cgroups (líneas 8-70) |
## Verificación
1. **En x86 (cgroup v1):** Comportamiento idéntico al actual — monta cada subsystem, crea symlinks, warn de devices.
2. **En ARM (cgroup v2):** Salta mounts individuales, usa jerarquía unificada, no genera warnings falsos.
3. **Graceful degradation:** Si `dmsetup` no existe, continúa sin error.
@@ -0,0 +1,42 @@
# Fix CI Build Failure - postcss-loader SCSS Error
## Context
The CI build (`test-build-package` job) fails because `postcss-loader` is in the webpack SCSS processing chain but there's no `postcss.config.js`. When postcss receives the SCSS content, it fails to parse SCSS-specific syntax (like `//` comments) as CSS, causing the build to error out.
The error from the CI log:
```
SyntaxError (1:1) /workspace/p-lao/bl-consultores-ds/src/components/Button/Button.scss Unknown word //
```
## Root Cause
In `webpack.config.ts`, the SCSS rule chain is:
```
style-loader → postcss-loader → css-loader → sass-loader
```
`sass-loader` compiles SCSS → CSS, but `postcss-loader` is placed BEFORE `css-loader` in the chain, meaning it receives the raw SCSS output from `sass-loader` but without a config file, it fails to parse it.
## Fix
Remove `postcss-loader` from the SCSS processing chain in `webpack.config.ts`. The `sass-loader` already compiles SCSS to CSS, and postcss is not needed for this project (no `postcss.config.js` exists, and Tailwind v4 handles CSS processing differently).
### File to modify: `webpack.config.ts`
Remove the `postcss-loader` entry from the SCSS rule's `use` array. The chain should go:
```
style-loader → css-loader → sass-loader
```
### Steps
1. Edit `webpack.config.ts` — remove the `postcss-loader` block from the SCSS rule
2. Commit and push to the `agente-button-ds` branch
3. CI should pass after push
## Verification
After pushing, verify on Gitea that:
- `test-build-package` job succeeds (build step)
- `unit-front-end-testing` passes
- `cypress-components-testing` passes
@@ -0,0 +1,141 @@
# Plan: Conectar PostgreSQL de Luminarrh de forma segura y encriptada
## Contexto
Tienes una base de datos PostgreSQL (imagen `postgres:18`) corriendo en Docker en tu Synology NAS, contenida en `luminarrh/`. Actualmente:
- **Sin encriptación**: No hay SSL configurado en PostgreSQL
- **Puerto expuesto**: 5436 → 5432 sin restricción de hosts
- **Sin pg_hba.conf**: No hay control de acceso por IP ni requisito de SSL
Necesitas que la base se conecte de forma **segura y encriptada** desde:
1. **Internet** → dominio `senabed.p-lao.com`
2. **Red local** → rango `<<INTERNAL_IP_2>>/24`
Tú te encargas del enrutamiento y port forwarding del router. La contraseña se mantiene igual (`<<DB_PASSWORD_1>>`).
---
## Pasos de implementación
### 1. Generar certificado SSL
Generar un certificado SSL autofirmado para el dominio `senabed.p-lao.com`:
```bash
# Generar clave privada y certificado
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /home/aleleba/projects/synology-apps-containers/luminarrh/ssl/server.key \
-out /home/aleleba/projects/synology-apps-containers/luminarrh/ssl/server.crt \
-subj "/CN=senabed.p-lao.com" \
-addext "subjectAltName=DNS:senabed.p-lao.com"
```
Los archivos se guardan en `luminarrh/ssl/` y se montan como solo lectura en el contenedor.
### 2. Actualizar `postgresql.conf`
Agregar configuración SSL y restricciones de red:
```conf
# Conexiones
listen_addresses = '*'
max_connections = 500
# SSL
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server.crt'
ssl_key_file = '/etc/postgresql/ssl/server.key'
ssl_min_protocol_version = 'TLSv1.2'
# Memoria
shared_buffers = 2GB
maintenance_work_mem = 128MB
# Timezone
timezone = 'America/Guatemala'
log_timezone = 'America/Guatemala'
```
### 3. Crear `pg_hba.conf` con control de acceso
Crear un archivo `pg_hba.conf` que:
- **Requiera SSL** para todas las conexiones externas
- **Restrinja por IP** a `<<INTERNAL_IP_2>>/24` (red local) y la IP pública del NAS (desde internet)
- Permita conexiones locales sin SSL (dentro del contenedor)
```conf
# TYPE DATABASE USER ADDRESS METHOD
# Conexión local (dentro del contenedor) - sin SSL requerido
local all all peer
# Conexiones desde red local <<INTERNAL_IP_2>>/24 - SSL requerido
hostssl all all <<INTERNAL_IP_2>>/24 scram-sha256
# Conexiones desde internet (IP pública del NAS) - SSL requerido
hostssl all sarh2 <IP_PUBLICA_NAS>/32 scram-sha256
# Rechazar todo lo demás
host all all 0.0.0.0/0 reject
```
Se usa `hostssl` (no `host`) para que PostgreSQL **rechace automáticamente** cualquier conexión que no use SSL.
### 4. Actualizar `docker-compose.yaml`
```yaml
version: '3.8'
services:
postgres-luminarrh:
image: postgres:18
restart: always
environment:
POSTGRES_USER: sarh2
POSTGRES_PASSWORD: <<DB_PASSWORD_1>>
POSTGRES_DB: sarh_db
PGDATA: /var/lib/postgresql/18/docker
ports:
- '5436:5432/tcp'
network_mode: databases
volumes:
- /volume1/docker/postgres/postgres-luminarrh/data:/var/lib/postgresql
- /volume1/docker/projects/synology-apps-containers/luminarrh/postgresql.conf:/etc/postgresql/postgresql.conf
- /volume1/docker/projects/synology-apps-containers/luminarrh/luminarrh_demo.sql:/docker-entrypoint/initdb.d/luminarrh_demo.sql
- /volume1/docker/projects/synology-apps-containers/luminarrh/pg_hba.conf:/etc/postgresql/pg_hba.conf
- /volume1/docker/projects/synology-apps-containers/luminarrh/ssl:/etc/postgresql/ssl:ro
command: postgres -c config_file=/etc/postgresql/postgresql.conf -c hba_file=/etc/postgresql/pg_hba.conf
```
Cambios clave:
- **Eliminar puerto UDP** (5432/udp) — PostgreSQL no lo usa
- **Mount del SSL** como `ro` (solo lectura)
- **Mount del pg_hba.conf** explícito
- **Mantener contraseña** `<<DB_PASSWORD_1>>`
### 5. Crear README.md como guía de conexión
Crear `luminarrh/README.md` con instrucciones completas de cómo conectarse usando el certificado SSL.
---
## Resumen de archivos a modificar/crear
| Archivo | Acción |
|---------|--------|
| `luminarrh/postgresql.conf` | **Modificar** — agregar SSL, TLS min version |
| `luminarrh/pg_hba.conf` | **Crear** — control de acceso por IP + SSL (hostssl) |
| `luminarrh/docker-compose.yaml` | **Modificar** — mounts SSL, eliminar UDP |
| `luminarrh/ssl/server.key` | **Crear** — clave privada SSL |
| `luminarrh/ssl/server.crt` | **Crear** — certificado SSL |
| `luminarrh/README.md` | **Crear** — guía de conexión con certificado |
---
## Verificación (la hacemos juntos)
1. **Verificar psql instalado**: `psql --version` — si no está, instalar `postgresql-client`
2. **Verificar SSL activo**: `psql "host=senabed.p-lao.com port=5436 dbname=sarh_db user=sarh2 sslmode=require sslcert=... sslkey=... sslrootcert=..."` — debe conectar sin error
3. **Verificar rechazo sin SSL**: `psql "host=senabed.p-lao.com port=5436 dbname=sarh_db user=sarh2 sslmode=disable"` — debe rechazar
4. **Verificar conexión desde red local**: intentar conectar desde un host en `<<INTERNAL_IP_2>>/24` con SSL — debe conectar
5. **Verificar logs**: revisar `/var/log/postgresql/` para ver conexiones aceptadas/rechazadas
@@ -0,0 +1,140 @@
# Fine-tuning LoRA de Qwen3.6-35B-A3B para MCPs y skills de Claude Code
## Contexto
El usuario sirve `RedHatAI/Qwen3.6-35B-A3B-NVFP4` en producción vía vLLM en su DGX Spark, y quiere un LoRA que le enseñe al modelo a:
1. Usar correctamente el MCP de Penpot (diseño) y los otros 4 MCPs conectados (gitea, github-personal, docmost, atlassian).
2. Seguir sus "skills" de Claude Code (en español) sin saltarse pasos ni reglas — su queja principal es que el modelo olvida reglas/pasos al ejecutar tareas largas.
Se investigó a fondo antes de diseñar: se leyó el `chat_template.jinja` real del modelo (7764 bytes), se hizo SSH a spark para verificar hardware/disco/contenedores en vivo, se documentaron a fondo las 5 tools del Penpot MCP (arquitectura, schemas, reglas no-obvias), se inventariaron los 5 skills reales y los 5 MCP servers, y se lanzaron dos diseños independientes (uno priorizando un framework turnkey, otro priorizando control total con `transformers`+`peft`+`trl`) que convergieron en casi todos los puntos técnicos. Este plan sintetiza ambos.
**Resultado esperado:** un adapter LoRA entrenado, mergeado y re-cuantizado a NVFP4, que reemplaza el checkpoint actual en `~/models/` en spark como *drop-in* — sin tocar el `docker-compose.yaml` de producción.
---
## Hallazgos que determinan el diseño
- **El modelo NO es un MoE clásico.** `config.json` de `Qwen/Qwen3.6-35B-A3B` declara `model_type: "qwen3_5_moe"`, `architectures: ["Qwen3_5MoeForConditionalGeneration"]` — ya soportado en `transformers` ≥5.2 (verificado, no requiere `trust_remote_code`). De 40 capas: **30 son `linear_attention`** (Gated DeltaNet) y solo **10 son `full_attention`** (`full_attention_interval: 4`). Además tiene 256 expertos ruteados (top-8) + 1 shared expert siempre-activo, y un vision tower (SigLIP, ~27 bloques).
- **Los expertos ruteados son `nn.Parameter` 3D, no `nn.Linear`.** PEFT/bitsandbytes no los puede envolver como LoRA/QLoRA estándar. Son ~32B de los ~35B params totales (91%).
- **GB10 (SM121) no tiene kernels de linear attention** (`causal_conv1d`/`fla` sin build sm121 según la doc de HF) → Gated DeltaNet corre en el fallback lento de PyTorch. Es el cuello de botella de velocidad y hay que aceptarlo (vale la pena un experimento acotado de 20 min probando `flash-linear-attention` por si el build sí funciona vía Triton JIT, ya que es sospechoso que una librería sin extensiones CUDA "no tenga build" — pero no bloqueante).
- **Existe precedente casi idéntico**: [kreuzhofer/dgx-spark-unsloth-qwen3.5-training](https://github.com/kreuzhofer/dgx-spark-unsloth-qwen3.5-training) hace LoRA BF16 de Qwen3.5-35B-A3B (misma arquitectura) en un solo DGX Spark, y NVIDIA publica un playbook oficial de Unsloth para DGX Spark.
- **Existe la receta exacta de cuantización NVFP4** usada por RedHatAI (`recipe.yaml` del repo): `QuantizationModifier(scheme="NVFP4", ignore=["re:.*lm_head","re:visual.*","re:model.visual.*","re:.*mlp.gate$","re:.*embed_tokens$","re:.*shared_expert_gate$","re:.*linear_attn.*"])`. Nota clave: **las capas Gated DeltaNet (`linear_attn`) quedan en BF16 en producción** — si el LoRA se aplica ahí, el merge no pierde precisión de cuantización.
- **El chat template usa formato XML tipo Hermes para tool calls** (`<tool_call><function=X><parameter=Y>valor</parameter></function></tool_call>`, NO JSON), con `role: "tool"` como rol de primera clase que el template convierte a `<tool_response>`, y `reasoning_content` como campo separado (no meter `<think>` a mano en `content`). Con `preserve_thinking=true` (como en producción), todos los turnos assistant previos preservan su `<think>` al re-serializar.
- **Los transcripts reales de Claude Code (`~/.claude/projects/*/*.jsonl`) están casi vacíos de uso de MCPs** (10 archivos, 735 líneas, 0 de 216 `tool_use` son `mcp__*`) — no sirven como fuente principal. En cambio `~/.claude/plans/*.md` (28 archivos, ~200KB) sí tiene español real del usuario y es útil para estilo/plantillas de reporte.
- **Discrepancia sin resolver entre las dos investigaciones**: no coinciden en si las proyecciones de Gated DeltaNet están fusionadas (`in_proj_qkvz`, `in_proj_ba`, por analogía con Qwen3-Next) o separadas (`in_proj_qkv`, `in_proj_z`, `in_proj_a`, `in_proj_b`, según lectura directa del índice de tensores real). **Esto se resuelve empíricamente en la Fase 0** inspeccionando el checkpoint real descargado — no se asume ninguna de las dos.
- **Hallazgo de seguridad, fuera del alcance de este plan pero a resolver antes/en paralelo**: el mismo token (`<<SPARK_PASSWORD_1>>`) está reusado como Bearer de gitea, github-personal y penpot MCP, y parece ser la misma password de spark. Rotar esos tokens y dejar de reusar la password de spark como bearer HTTP.
---
## Decisiones de diseño
1. **Framework**: stack primario `transformers` + `peft` + `trl` (control total, sin dependencia de que un framework de alto nivel ya soporte esta arquitectura tan nueva). Probar `unsloth` como acelerador opcional (puede dar 1.5-2x velocidad) una vez que el pipeline base funcione — no bloqueante, con fallback documentado al stack puro si falla la carga.
2. **Memoria de entrenamiento**: **LoRA BF16 puro, no QLoRA**. Los expertos ruteados (91% de los params) no son cuantizables por bnb, así que QLoRA ahorraría ~4-5% de memoria a cambio de fidelidad numérica y una dependencia con bugs abiertos. No vale la pena.
3. **Módulos objetivo de LoRA**: atención completa (`q/k/v/o_proj`, 10 capas) + proyecciones de Gated DeltaNet (30 capas, nombres exactos a confirmar en Fase 0) + `shared_expert.{gate,up,down}_proj` (40 capas — el único FFN que ve el 100% de los tokens). **Excluidos**: router (`mlp.gate`), expertos ruteados, vision tower. Esto cubre 40/40 capas para aprender formato/comportamiento sin tocar conocimiento factual ni arriesgar colapso de expertos. r=32, alpha=64, dropout=0.05.
4. **Chat template / masking**: construir el dataset con `messages` + `tool_calls` estructurados (formato TRL/OpenAI, `arguments` como dict) y **dejar que `apply_chat_template` genere el XML** — nunca escribirlo a mano. Verificar primero si TRL reconoce nativamente el chat template de Qwen3.6 y aplica el masking de loss vía `assistant_only_loss=True`; si no, fallback a copiar el `.jinja` con `{% generation %}...{% endgeneration %}` manual alrededor de la salida assistant.
5. **Ubicación de archivos**: todo el código (pipeline de dataset, scripts de training/merge/cuantización, docker-compose del entorno de training, configs, dataset generado) vive en **`~/projects/ai-projects/qwen3-6-lora/`** (= `/mnt/docker-nas/projects/ai-projects/qwen3-6-lora/` visto desde spark, mismo storage NFS) — una subcarpeta nueva dentro de `ai-projects`, no `ai-projects` en sí. Esa subcarpeta es la raíz de **un repo git nuevo** creado al arrancar la Fase 0 (`git init` dentro de `qwen3-6-lora/`, con `.gitignore` excluyendo binarios de modelo, `data/raw/` sin sanitizar, y cualquier checkpoint). El dataset final ya sanitizado (`data/train.jsonl`, `data/eval.jsonl`) y los schemas de MCP sí se versionan — son el activo más valioso del proyecto y lo que justifica tener historia de git. Los **binarios del modelo** (checkpoint base BF16 ~72GB, merge, cuantizado) se mantienen en disco **local rápido de spark** (no en el NFS ni en git — no tiene sentido versionar cientos de GB de pesos) en un directorio nuevo dedicado; solo el checkpoint NVFP4 final se copia a `~/models/` en spark (requisito del `docker-compose.yaml` de vLLM). Publicar el repo a gitea/github (con la skill `aleleba-pr`) queda como paso posterior, ya en fase de ejecución, no parte de este plan.
6. **Ventana operativa**: entrenar localmente en Spark, aceptando parar vLLM ~1-2 noches (confirmado con el usuario). No se renta GPU en la nube.
7. **Alcance**: solo texto por ahora (confirmado con el usuario) — el vision tower queda congelado; un bucket con capturas de Penpot puede agregarse después sin rehacer el pipeline.
---
## Estructura de directorios
```
~/projects/ai-projects/qwen3-6-lora/ # repo git nuevo (git init en Fase 0), subcarpeta de ai-projects
├── .gitignore # excluye data/raw/, out/*.safetensors, cualquier checkpoint
├── docker-compose.yml # contenedor de training (NGC pytorch, con volúmenes persistentes)
├── scripts/
│ ├── 00_verify_hardware.py # smoke tests: flash_attn, sdpa, fla (opcional), sm121
│ ├── 01_inspect_modules.py # resuelve la discrepancia de nombres in_proj_* contra el checkpoint real
│ ├── 02_dump_mcp_schemas.py # tools/list contra los 5 MCP servers -> data/schemas/*.json
│ ├── 03_build_replay.py # genera ~700 ejemplos de replay contra el vLLM de producción (antes de pararlo)
│ ├── 04_sanitize.py # scrubbing de secretos (gitleaks/detect-secrets + regex) sobre fuentes crudas
│ ├── 05_build_dataset.py # arma los buckets A-K -> data/train.jsonl, data/eval.jsonl
│ ├── 06_validate_dataset.py # render con apply_chat_template, assert de máscara de loss, filtro de longitud
│ ├── 10_train.py # LoRA con transformers+peft+trl (+ unsloth opcional)
│ ├── 20_merge_lora.py # merge streaming shard-a-shard, preserva tensores MTP
│ ├── 21_quantize_nvfp4.py # llm-compressor, receta clonada de RedHatAI, reinjerta MTP
│ └── 30_eval_suite.py # las 4 puertas de evaluación (parser real, checklists de skills, etc.)
├── data/
│ ├── schemas/ # {penpot,gitea,github-personal,docmost,atlassian}.json
│ ├── raw/ # fuentes sanitizadas (skills, agents, plans, replay)
│ ├── train.jsonl / eval.jsonl
│ └── chat_template_train.jinja # solo si hace falta el fallback manual de masking
└── out/ # adapters, logs de eval (NO los checkpoints de modelo completo)
```
En spark (fuera del NFS, disco local rápido — confirmar ruta exacta en Fase 0, p.ej. `/home/aleleba/ft-models/`):
```
Qwen--Qwen3.6-35B-A3B/ # checkpoint base BF16 descargado de HF (~72GB)
Qwen3.6-35B-A3B-mcp-bf16/ # merge del LoRA (~72GB)
Qwen3.6-35B-A3B-mcp-NVFP4/ # resultado final, se copia a ~/models/ para servir
```
---
## Fases de implementación
### Fase 0 — Infraestructura y verificación (sin tocar producción)
0. Crear la subcarpeta `~/projects/ai-projects/qwen3-6-lora/` y hacer `git init` ahí dentro (no en `ai-projects` directamente), con el `.gitignore` de arriba, y un primer commit con la estructura base de carpetas. Todo el código/scripts/dataset sanitizado de las fases siguientes se commitea a medida que se produce.
1. El contenedor `jupyter-pyt` que ya está corriendo en spark (imagen `nvcr.io/nvidia/pytorch:25.12-py3`) **no tiene volumen persistente** — no lo parches in-place. Crear un servicio nuevo (`docker-compose.yml` de arriba) con bind mounts a `~/projects/ai-projects` y al directorio local de modelos, misma imagen base (trae `flash_attn` y `nvidia-modelopt` preinstalados).
2. Instalar `transformers>=5.2` (verificar versión exacta que declare soporte de `qwen3_5_moe`), `peft`, `trl`, `accelerate`, `datasets`, `bitsandbytes` (solo para `adamw_8bit`), con `PIP_CONSTRAINT` fijando la versión de `torch` de la imagen NGC (`--no-deps` en transformers) para no romper el build ARM64/Blackwell.
3. `scripts/00_verify_hardware.py`: probar `flash_attn` real (forward simple), y si falla usar `attn_implementation="sdpa"`. Probar (opcional, 20 min) si `flash-linear-attention` compila y da backward en SM121 — usarlo si funciona, descartarlo si no.
4. Descargar el checkpoint base: `hf download Qwen/Qwen3.6-35B-A3B` (~72GB). Verificar que `chat_template.jinja` pese igual (7764 bytes) que el de producción — confirma que son el mismo template.
5. **`scripts/01_inspect_modules.py`** (resuelve la discrepancia): cargar el modelo con `device_map="meta"` e imprimir todos los `nn.Linear`/`nn.Parameter` de una capa `linear_attention` (p.ej. layer 0) y una `full_attention` (p.ej. layer 3). Esto define los nombres reales a usar en `target_modules` — no proceder a la Fase 3 sin este dato confirmado.
### Fase 1 — Extracción de schemas y datos de replay (vLLM sigue corriendo)
6. `scripts/02_dump_mcp_schemas.py`: conectar a los 5 MCP servers (`mcp__penpot__*`, `gitea`, `github-personal`, `docmost`, `atlassian`) y volcar `tools/list` real a JSON. Esto confirma también si el deployment remoto de Penpot tiene filesystem deshabilitado (si `import_image` no aparece, o `export_shape` pierde `filePath`, hay que entrenar solo sobre las tools que sí existen — no inventar).
7. `scripts/03_build_replay.py`: generar ~700 ejemplos de "replay" (anti-forgetting) pidiéndole al **modelo en producción actual** (antes de pararlo) que responda prompts genéricos en español/código/razonamiento con `preserve_thinking=true`. Esto ancla el replay a la distribución real del modelo base, mejor que mezclar un corpus externo.
### Fase 2 — Construcción del dataset
8. `scripts/04_sanitize.py`: gate de scrubbing de secretos sobre las 5 SKILL.md, los 7 agents, los `~/.claude/plans/*.md`, y cualquier transcript usado — regex + `gitleaks`/`detect-secrets`, sustituyendo (no borrando) tokens/passwords/IPs/emails por placeholders estables. El build debe fallar si sobrevive algún patrón conocido.
9. `scripts/05_build_dataset.py`: construir ~2500-3000 ejemplos en buckets: Penpot MCP (~300, cubriendo cada regla no-obvia documentada: `insertChild` no `appendChild`, orden invertido de `children` en flex, re-fijar `growType` tras `resize()`, `storage` entre llamadas, no loguear lo que se retorna, leer `"Tool execution failed: ..."` como texto y autocorregirse), los otros 4 MCPs (~800 combinados), adherencia a skills — el bucket más importante para la queja principal del usuario, con checklist explícito de reglas en el `<think>` inicial, auto-corrección a mitad de trayectoria, fidelidad de la plantilla de "Reporte final", y escenarios que tientan a violar cada regla `NUNCA/SIEMPRE/OBLIGATORIO` real de los 5 skills (~450), delegación a subagentes (~180), negativos/no-tool (~200), manejo de errores (~150), y el replay de la Fase 1 (~700, ~25% del total). Dejar 1 de las 5 skills completamente fuera del training como held-out de generalización.
10. `scripts/06_validate_dataset.py`: renderizar cada ejemplo con `apply_chat_template(tools=..., tokenize=False)` y verificar que no lanza excepción, que el texto contiene `<function=...>` bien formado, y que la máscara de `assistant_masks` (con `return_assistant_tokens_mask=True`) no está vacía y no cubre system/tool/user. Filtrar (no truncar) ejemplos que excedan la longitud máxima (8192, con ventana deslizante para skills largas).
### Fase 3 — Entrenamiento
11. Configurar `LoraConfig` con los `target_modules` confirmados en la Fase 0 (attention + GDN + shared_expert), `r=32, alpha=64, dropout=0.05`. Verificar con `model.print_trainable_parameters()` (~0.11% esperado) y un conteo por módulo antes de arrancar cualquier run largo.
12. `docker compose stop vllm` (no `down`, conserva volúmenes). Dry run de 20 pasos: confirmar memoria pico <90GB, loss bajando. Luego run completo (2 épocas, LR 1e-4 cosine, gradient checkpointing, `save_steps=50`). Mitigar el riesgo de OOM al cargar el checkpoint (mmap + tensores CUDA compitiendo por la misma pool unificada) con `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` y purgando page cache antes de cargar.
13. `docker compose start vllm` en cuanto termine el training — producción vuelve, el adapter queda en disco.
### Fase 4 — Merge y evaluación
14. `scripts/20_merge_lora.py`: merge shard-a-shard (streaming, pico de memoria ~2GB en vez de cargar el modelo completo dos veces) del adapter sobre el checkpoint BF16. Preservar los tensores `mtp.*` (que `from_pretrained` descarta al cargar) copiándolos del checkpoint original — si se pierden, `--speculative-config` de producción no arranca.
15. Evaluación en 4 puertas, sin pasar a la siguiente sin cerrar la anterior: (1) eval-loss offline por bucket, especialmente el bucket de replay separado del resto; (2) validez de tool-calls usando el **parser real de producción** (`qwen3_coder`/`qwen3` de vLLM, no una regex propia) contra ~200 prompts held-out; (3) checklists de adherencia a reglas por skill (baseline medido con el modelo actual antes de comparar) + test de no-activación (frases cercanas a triggers que no deben disparar la skill); (4) prueba en vivo sirviendo el **BF16 mergeado directo** (no `--enable-lora` — hay incompatibilidades de nombres de módulos entre PEFT y vLLM para esta arquitectura, documentadas como riesgo abierto) contra los 5 MCP reales y las 5 skills de punta a punta.
16. **Puerta de decisión**: si no mejora sobre el baseline en la puerta 3, volver a la Fase 2 antes de gastar tiempo en cuantizar.
### Fase 5 — Re-cuantización y despliegue
17. `scripts/21_quantize_nvfp4.py`: clonar exactamente la receta de RedHatAI (`llm-compressor`, mismo `ignore` list) sobre el modelo mergeado, con `moe_calibrate_all_experts=True` (obligatorio, si no la mayoría de los 256 expertos quedan sin calibrar) e idealmente calibrando con una muestra del propio dataset de fine-tuning en vez de un corpus genérico.
18. Reinjertar tensores MTP en el checkpoint cuantizado. Copiar `chat_template.jinja` original (no el de training).
19. Mover el checkpoint actual a `.bak` (35GB, sobra espacio) antes de reemplazar — rollback = revertir el nombre y `docker compose up -d`. Copiar el nuevo checkpoint a `~/models/` en spark.
20. Arrancar vLLM con la config de producción **completa y sin modificar**, incluido `--speculative-config`. Si no arranca, el problema son los tensores MTP. Si arranca, correr el smoke test de las 5 skills contra los MCPs reales una vez más.
---
## Riesgos principales
| Riesgo | Mitigación |
|---|---|
| Copiar `target_modules` mal (nombres fusionados vs separados) → adapter vacío, run de horas perdido | Fase 0 verifica empíricamente antes de configurar LoRA; assert de `print_trainable_parameters()` antes de cada run |
| Masking de loss mal hecho (entrenar sobre system/tool_response) | Validación obligatoria de la máscara sobre 20 ejemplos antes de arrancar (Fase 2, paso 10) |
| Cuelgue del host por OOM en memoria unificada (no da error limpio) | `expandable_segments:True`, purga de page cache, considerar `systemd-run --scope -p MemoryMax` |
| Pérdida de tensores MTP en el merge → rompe `--speculative-config` en producción | Merge streaming los preserva por diseño; verificación explícita antes de desplegar |
| `--enable-lora` no soporta bien esta arquitectura en vLLM (issue abierto) | No usarlo como validación principal; servir el BF16 mergeado directo para evaluar |
| Fuga de secretos al dataset (tokens/passwords reales en skills/plans/transcripts) | Gate de sanitización que falla el build si sobrevive algún patrón; rotar los tokens reusados de una vez |
| Overfitting a 5 skills / tool-call spam | Skill held-out, ~25% replay auto-destilado, LR conservador, medir no-activación |
| Entrenamiento lento (fallback PyTorch de Gated DeltaNet) | Aceptar 10-15h como run nocturno; experimento opcional con `flash-linear-attention` |
---
## Verificación end-to-end
1. Fase 0 completa cuando `01_inspect_modules.py` confirma los nombres reales y `00_verify_hardware.py` corre sin excepciones.
2. Fase 2 completa cuando `06_validate_dataset.py` pasa sobre el 100% del dataset (render limpio + máscara no vacía) y el gate de secretos no encuentra nada.
3. Fase 3 completa cuando el dry-run de 20 pasos no OOMea y el loss baja de forma consistente en el run completo.
4. Fase 4: comparar explícitamente las 4 puertas contra el baseline del modelo actual (medido *antes* de entrenar) — la mejora debe ser medible, no asumida. La prueba real de éxito es servir el BF16 mergeado y ejecutar de punta a punta al menos una tarea real por cada uno de los 5 MCPs y las 5 skills contra el propio Claude Code apuntando a ese endpoint.
5. Fase 5: `docker compose up -d` de producción arranca sin errores con la config completa (incluido MTP/speculative decoding) sirviendo el checkpoint nuevo, con el `.bak` disponible para rollback inmediato.
@@ -0,0 +1,49 @@
# Plan: Add luminarrh app to Kubernetes cluster
## Context
Agregar la app LuminarRH (ASP.NET Core 9 + Angular) de un amigo al cluster. La DB Postgres ya está corriendo en el host <<INTERNAL_IP_1>>:5436. La imagen está en Docker Hub privado (`luminarsystems/luminarrh:1.0.0`).
## Archivos a crear
### 1. `apps/luminarrh/00-ns-and-sa.yaml`
Namespace `luminarrh` + ServiceAccount `luminarrh`.
### 2. `apps/luminarrh/00-pullsecret.yaml`
Docker registry secret (`kubernetes.io/dockerconfigjson`) para `luminarsystems/luminarrh:1.0.0`.
- **Pendiente**: token de Docker Hub (username: `luminarsystems`, read-only token que pasa Lester).
- El `.dockerconfigjson` se genera con `echo '{"auths":{"https://index.docker.io/v1/":{"username":"luminarsystems","password":"<TOKEN>","email":"<EMAIL>","auth":"<base64>"}}}' | base64 -w0`
### 3. `apps/luminarrh/01-luminarrh-deployment.yaml`
Deployment con:
- `image: luminarsystems/luminarrh:1.0.0`
- `imagePullPolicy: IfNotPresent`
- `imagePullSecrets: [{name: regcred}]`
- containerPort: 8080
- envFrom con configMap + secret (como en el handoff)
- Probes contra `/health` en puerto 8080
- Resources: requests 250m CPU / 256Mi, limits 1 CPU / 512Mi
- **Pendiente**: `Jwt__Key` (generar con `openssl rand -base64 64`)
- **Pendiente**: `ConnectionStrings__SarhConnection` con password real
### 4. `apps/luminarrh/02-luminarrh-svc.yaml`
Service ClusterIP, port 80 → targetPort 8080.
### 5. `apps/luminarrh/03-ingress.yaml`
Ingress Traefik — **pendiente dominio**. Placeholder con `luminarrh.<dominio>` hasta que me pases el dominio.
### 6. `clusters/cluster/apps-kustomization/luminarrh-kustomization.yaml`
Kustomization estándar siguiendo el patrón de las otras apps.
## Archivos a modificar
- `clusters/cluster/apps-kustomization/luminarrh-kustomization.yaml` — crear nuevo
- `apps/luminarrh/` — crear directorio y archivos
## Pendientes (espero tus datos)
1. **Docker Hub token** (read-only) para el pull secret
2. **Dominio/URL pública** para el ingress y Jwt__Issuer/Jwt__Audience
3. **Jwt__Key** — lo genero con `openssl rand -base64 64`
4. **Password de conexión** a la DB — en el handoff dice `<<DB_PASSWORD_1>>` pero confirmá si es el correcto para k8s
## Verificación
- `git diff` para revisar los cambios
- `git push` triggera Flux CD que aplica los manifests
@@ -0,0 +1,87 @@
# Fix slow/failing `chown -R` in entrypoint.sh on Mac ARM bind mounts
## Context
On macOS (Apple Silicon), Docker Desktop bind-mounts host project folders (e.g.
`/home/aleleba/projects/telus/telus-board-report`) into the container over a virtualized
filesystem (virtiofs/gRPC-FUSE) that does not support changing file ownership from inside
the container. `entrypoint.sh:79` runs:
```bash
sudo chown -R ${HOME_USER} /home/${HOME_USER}
```
This recurses into those bind-mounted directories too, including huge `node_modules`
trees, and every single file inside them fails with `Operation not permitted`. Two real
problems come from this, not just one:
1. **Speed**`chown -R` walks every file individually; over tens of thousands of
`node_modules`/`.venv` entries this is very slow.
2. **Correctness (bigger issue found during investigation)** — the script starts with
`set -eu`. `chown -R` still returns a non-zero exit status once it finishes (even
though it keeps going and reports every failure), and this line is not guarded by
`|| true` or an `if`. Confirmed locally: a failing `chown` under `set -e` aborts the
script immediately at that line. That means once this line fails, **everything after
it in entrypoint.sh never runs** — no `.ssh` permission fixup, no custom-scripts, no
extension install, and critically the final `code tunnel ...` command that actually
starts the VS Code tunnel. So this isn't just "slow," it can silently prevent the
container from ever reaching the tunnel start step on a run where a permission error
occurs.
The user has confirmed ownership doesn't actually need to change on these mounted
folders — the Dockerfile already does `chmod -R a+rwX /home` at build time (`Dockerfile:41`),
and the host-mounted files are already world-writable (777), so `aleleba` can read/write
them regardless of `chown` succeeding.
## Fix
Edit `entrypoint.sh:79`, replacing the whole-home recursive `chown` with a `find -xdev`
based version that:
- **Stays on the container's own filesystem** (`-xdev`) so it never descends into
separately-mounted bind volumes like `/home/${HOME_USER}/projects/...` — this is what
fixes the slowness, without hardcoding folder names like `node_modules`/`.venv`
(it works generically for any bind-mounted subtree, not just those two).
- **Never aborts the script** even if an unexpected chown failure occurs elsewhere, by
appending `|| true` (this is the correctness fix — restores the guarantee that the
rest of entrypoint.sh, including the VS Code tunnel startup, always runs).
```bash
sudo find "/home/${HOME_USER}" -xdev -exec chown "${HOME_USER}" {} + 2>/dev/null || true
```
This is a single-line change to `entrypoint.sh`; no other recursive chown/chmod calls in
the script (lines 72, 132, 137, 142) are touched, since those aren't the ones hitting
bind-mounted project directories.
**This is not a Mac-only fix.** `-xdev` skips any directory that is a separate mount
point (different device id) from `/home/${HOME_USER}` itself — that's true for bind
mounts on Linux/Synology just as much as on macOS. The difference is only in symptom:
- On **macOS** (virtiofs/gRPC-FUSE), `chown` on the bind-mounted files fails outright
(`Operation not permitted`), which — combined with the `set -e` bug above — can abort
the whole entrypoint before the tunnel starts.
- On **Linux**, the same `chown -R` walk into `node_modules`/`.venv` succeeds file-by-file
(root can chown across a Linux bind mount), so there's no error output, but it still
pays the same per-file syscall cost — this is almost certainly why the user's other,
working Linux/Synology environment is *also* slow to start, just silently.
Because the fix skips crossing the mount boundary rather than special-casing specific
folder names or the host OS, one change fixes both the correctness bug (Mac) and the
startup latency (all environments) with no behavior change for anything that lives on
the container's own filesystem (`.ssh`, dotfiles, `.vscode*`, etc. are chowned exactly as
before).
## Verification
1. Read the diff to confirm only line 79 changed and the semantics (still chowning to
`HOME_USER`, still running as root via `sudo`) are preserved.
2. Locally simulate the `set -e` abort scenario (same repro used during investigation:
a `chown -R` hitting a permission-denied/missing path under `set -eu` kills the
script) and confirm the new `find -xdev ... || true` line does **not** abort a
`set -eu` script even when it encounters a permission error.
3. Rebuild the image (`docker build .`) to confirm the Dockerfile/entrypoint still builds
cleanly.
4. If possible, run the container with a bind-mounted `node_modules`-heavy directory
(mirroring the user's Mac setup) and confirm: no `Operation not permitted` spam, the
entrypoint completes quickly, and the `code tunnel` command still launches at the end
of the log.
@@ -0,0 +1,84 @@
# Plan: Dockerfile user-agnostic con sudo
## Context
El Dockerfile hardcodea el usuario `aleleba` y no crea la imagen para múltiples usuarios. Cuando se ejecuta con `HOME_USER=aleleba-telus`, el entrypoint.sh intenta sourcear `/home/aleleba-telus/.bashrc` que no existe → "Permission denied".
**Objetivo:** Instalar todo como root en paths globales. Cualquier `HOME_USER` funciona — el entrypoint crea el usuario, le da sudo, y copia la configuración.
## Cambios (solo 3)
### 1. Eliminar creación hardcoded de usuario (líneas 7-11)
Eliminar estas líneas del Dockerfile:
```dockerfile
RUN sudo adduser --disabled-password --gecos "" --uid 1000 ${HOME_USER}
RUN sudo echo "$HOME_USER ALL=(ALL) NOPASSWD:ALL" | sudo tee -a /etc/sudoers.d/nopasswd > /dev/null
USER ${HOME_USER}
WORKDIR /home/${HOME_USER}
```
El entrypoint.sh ya hace todo esto al runtime:
- Crea el usuario si no existe (línea 56)
- Le da sudo (línea 58)
- Copia `/usr/bin/.bashrc` al home del usuario (línea 99)
### 2. Mover `.bashrc` a global (líneas 172-179)
Cambiar todos los `~/.bashrc` a `/usr/bin/.bashrc`:
```bash
# Antes
RUN echo "PATH=..." | sudo tee -a ~/.bashrc
RUN echo "export JAVA_HOME=..." | sudo tee -a ~/.bashrc
RUN echo "export ANDROID_HOME=..." | sudo tee -a ~/.bashrc
RUN echo "export ANDROID_SDK_ROOT=..." | sudo tee -a ~/.bashrc
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' | sudo tee -a ~/.bashrc
RUN echo "export EMSDK_QUIET=1" | sudo tee -a ~/.bashrc
RUN echo "source /emsdk/emsdk_env.sh" | sudo tee -a ~/.bashrc
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' | sudo tee -a ~/.bashrc
# Después — todo a /usr/bin/.bashrc
RUN echo "PATH=..." | sudo tee -a /usr/bin/.bashrc
RUN echo "export JAVA_HOME=..." | sudo tee -a /usr/bin/.bashrc
RUN echo "export ANDROID_HOME=..." | sudo tee -a /usr/bin/.bashrc
RUN echo "export ANDROID_SDK_ROOT=..." | sudo tee -a /usr/bin/.bashrc
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' | sudo tee -a /usr/bin/.bashrc
RUN echo "export EMSDK_QUIET=1" | sudo tee -a /usr/bin/.bashrc
RUN echo "source /emsdk/emsdk_env.sh" | sudo tee -a /usr/bin/.bashrc
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' | sudo tee -a /usr/bin/.bashrc
```
El entrypoint.sh ya se encarga de copiar `/usr/bin/.bashrc` a cada home.
### 3. Limpiar redundancias (líneas 155-159)
Eliminar comentarios de "already installed" — no aportan valor.
## Todas las herramientas siguen disponibles
Nada cambia en las instalaciones — todo está en paths globales:
| Herramienta | Dónde |
|---|---|
| `code` | `/usr/bin/code` (base image) |
| `docker` | `/usr/bin/docker` |
| `docker-compose` | `/usr/local/bin/docker-compose` |
| `gh` | `/usr/bin/gh` |
| `node`/`npm`/`nvm` | `/usr/local/bin/` + `/root/.nvm` |
| `python3`/`pip` | `/usr/bin/python3` |
| `pandas`/`numpy`/etc | `pip install` global |
| `kubectl` | `/usr/bin/kubectl` |
| `flux` | `/usr/bin/flux` |
| `conan` | `pip install` global |
| `emsdk` | `/emsdk` |
| `android-sdk` | `/opt/android-sdk` |
| `claude` | `/usr/local/bin/claude` |
Los binarios están en `/usr/bin` y `/usr/local/bin` — paths que **cualquier usuario** puede usar. Solo cambia que ya no está restringido al usuario `aleleba`.
## Verificación
1. `docker build` — sin errores
2. `docker run -e HOME_USER=aleleba` — funciona (mismo usuario)
3. `docker run -e HOME_USER=aleleba-telus` — funciona (nuevo usuario, sin permission denied)
4. Dos contenedores con diferente `HOME_USER` coexisten
5. Cada usuario tiene sudo: `sudo apt-get update` funciona
6. Todos los binarios accesibles: `code`, `docker`, `node`, `python3`, `adb`, `emcc`, `claude`, `gh`, `kubectl`
@@ -0,0 +1,719 @@
---
name: agent-orchestrator
description: >-
Turns this Claude Code session (VS Code extension) into an orchestrator that launches autonomous agents
in tmux, each in its own git worktree/branch, monitors them, talks to them, documents them in Docmost, and
opens PRs via the aleleba-pr skill — NEVER merging. ACTIVATE ONLY when the user explicitly says one of:
"quiero dejar esto trabajando en background", "ejecuta esto solo", "lanza un agente para esto",
"deja esto corriendo en background", "trabaja esto en background" (or a very close variant). Also handles
monitoring ("¿qué está haciendo AGENT_NAME?", "revisa el estado de los agentes") and validation/archival
("apruebo AGENT_NAME", "/aprobar AGENT_NAME"). Do NOT activate on any other phrasing.
effort: high
---
# agent-orchestrator — Orquestación de agentes en background
Convierte la sesión madre en un orquestador de agentes autónomos. Cada agente corre en
su propia **sesión tmux interactiva**, en su propio **git worktree** sobre una **rama dedicada**, se
**documenta en Docmost** y abre su **PR con la skill `aleleba-pr`**. **Los agentes nunca hacen merge**; el
merge solo lo hace la conversación principal y con validación explícita del usuario.
---
## TRIGGER — Cuándo activarse (ESTRICTO)
Esta skill **solo** se activa para **lanzar** un agente cuando el usuario dice explícitamente una de estas
frases (o variante muy cercana):
- "quiero dejar esto trabajando en background"
- "ejecuta esto solo"
- "lanza un agente para esto"
- "deja esto corriendo en background"
- "trabaja esto en background"
Si el usuario no usa estas frases, **NO se activa** bajo ninguna circunstancia.
> **Regla de desempate ante duda**: si no estás seguro de si la frase del usuario cuenta como "variante muy
> cercana" a las de la lista, trátala como que **NO** activa la skill — pregúntale directamente al usuario
> si quiere que lances un agente en background, en vez de asumirlo. Es preferible preguntar una vez de más
> que activar el modo background sin que el usuario lo haya pedido.
Una vez existan agentes, la skill **también** atiende:
- **Monitoreo**: "¿qué está haciendo AGENT_NAME?", "revisa el estado de los agentes".
- **Desbloqueo**: "responde a AGENT_NAME que ...", "dile a AGENT_NAME ...".
- **Validación/archivo**: "apruebo AGENT_NAME", "valido el trabajo de AGENT_NAME", `/aprobar AGENT_NAME`.
---
## REGLAS DE SEGURIDAD (madre y agente)
Reglas que la madre y todos los agentes deben seguir en todas las fases.
### Política de MERGE
- **Los AGENTES NUNCA mergean** (ni `git merge`/`git rebase` hacia `main`/`master`/`dev`,
ni vía MCP de Gitea/GitHub). El trabajo del agente **termina en el PR**.
- **La conversación PRINCIPAL (madre) SÍ puede mergear**, pero **SOLO con validación
explícita del usuario para ese merge concreto** (cada vez). Sin un "sí, mergea" explícito
del usuario, no se mergea. Recuerda que en este entorno mergear a `master` **dispara el
deploy a producción** (GitOps) — confírmalo con el usuario antes.
### NUNCA atribuir a Claude — REGLA ABSOLUTA
**NUNCA agregues `Co-Authored-By: Claude` en NINGÚN commit.** Esto es absolutamente prohibido.
No agregues `Co-Authored-By: Claude`, "Generated with Claude Code", "🤖",
"creado con Claude" ni nada similar en **commits, PRs, comentarios de código, mensajes,
ni Docmost**. Esta regla aplica tanto a la madre como a los agentes (y a sus subagentes).
Los mensajes deben ir sin firma de Claude.
**En cada commit, verifica que el mensaje NO contenga ninguna mención a Claude, Co-Authored-By, ni emojis de robots.**
### Aislamiento
- La sesión madre **nunca** toca el árbol de trabajo del agente: cada agente vive aislado
en su worktree.
- No exponer el `GMAIL_APP_PASSWORD` en logs ni en Docmost.
---
## FASE 1: LANZAR
### 1. Identificar el proyecto → `PROJECT_NAME`
En este orden de prioridad:
```bash
PROJECT_NAME=$(jq -r '.name // empty' package.json 2>/dev/null)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(grep -oP '^\s*name\s*=\s*"\K[^"]+' pyproject.toml 2>/dev/null | head -1)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
echo "PROJECT_NAME=$PROJECT_NAME"
```
### 2. Pedir la tarea
Si el usuario no describió la tarea concretamente, **pídesela** antes de continuar.
### 3. Generar `AGENT_NAME`
Slug descriptivo basado en la tarea: minúsculas, guiones, sin espacios ni caracteres especiales,
prefijo `agente-`. Ejemplos: `agente-auth-endpoint`, `agente-fix-login-bug`, `agente-refactor-payments`.
### 4. Preparar `.worktrees` y crear el worktree
> **IMPORTANTE: El worktree SIEMPRE se crea dentro de la carpeta `.worktrees/` del proyecto actual.**
> Esta carpeta se llama `.worktrees/` y está en la raíz del proyecto en el que estás trabajando en ese momento.
> No uses ningún otro nombre ni ruta para los worktrees.
```bash
mkdir -p .worktrees
grep -qxF '.worktrees/' .gitignore 2>/dev/null || echo '.worktrees/' >> .gitignore
if [ -f .dockerignore ]; then grep -qxF '.worktrees/' .dockerignore || echo '.worktrees/' >> .dockerignore; fi
git worktree add ".worktrees/$AGENT_NAME" -b "$AGENT_NAME"
```
### 5. Crear en Docmost la subpágina del agente (ANTES de lanzar tmux)
> **Este paso va ANTES de lanzar tmux**, para que `TASK.md` pueda incluir el `pageId`/`spaceId` reales desde
> el primer momento — el agente no debe adivinarlos ni buscarlos.
En el space del proyecto `PROJECT_NAME` (localizar con `mcp__docmost__list_spaces` / `mcp__docmost__search`).
> **Regla concreta para encontrar el space** (el mismo criterio que usa la skill `docmost-context`): compara
> `PROJECT_NAME` contra el `name` y el `slug` de cada space **normalizando** ambos lados — minúsculas, sin
> espacios/guiones/guiones bajos, y sin el scope de npm si lo tiene (`@algo/nombre``nombre`). Si no hay
> match exacto tras normalizar, prueba un match por contención (uno contiene al otro). Si tras eso **sigue
> sin haber un match claro** (cero coincidencias, o varias ambiguas), **NO adivines ni "uses el más
> adecuado"**: usa `AskUserQuestion` mostrando la lista de spaces disponibles (nombre + descripción) y deja
> que el usuario elija uno, o cree uno nuevo si ninguno corresponde.
> **Formato de tablas en Docmost:** las tablas markdown deben enviarse en formato de varias líneas (una línea
> por fila), NO colapsadas en una sola línea, para que Docmost las renderice correctamente. Usa tablas
> **siempre que se pueda** — tanto para "Agentes Activos" como para la subpágina de cada agente.
a) Página **"Agentes Activos"** — si no existe, créala (`create_page`) con DOS tablas/secciones:
- **"Agentes Activos"**: columnas Nombre, Session ID, Tarea, Estado, Inicio, Worktree, Rama.
- **"Historial"**: columnas Nombre, Fecha de cierre, Resumen breve — vacía (solo encabezado) al crearla.
Es un **registro permanente**: se le van agregando filas al archivar cada agente (FASE 4), pero
**NUNCA se borra ni se sobrescribe una fila ya existente en ella**, bajo ninguna circunstancia.
Agrega la fila del nuevo agente en la tabla "Agentes Activos" con Session ID = "pendiente" (aún no existe
la sesión tmux). Para añadir la fila, lee la página completa primero y reescríbela con `update_page`,
**preservando íntegra la sección "Historial"** tal cual estaba (tabla → no se rompe).
b) **Subpágina** bajo "Agentes Activos" llamada `AGENT_NAME` (`create_page` con `parentPageId`) → esto te da
el `PAGE_ID`. Escríbela en **formato de tabla** (para poder editarla luego con `update_page`):
- Tabla con columnas: Campo, Valor — filas para: Nombre del agente, **Session ID** (= "pendiente" por
ahora), Tarea asignada, Worktree, Rama git, Sesión tmux, Estado (= "lanzando"), Inicio
- Secciones con formato de tabla: **Progreso (por fase)**, **Razonamiento y decisiones**, **Subagentes utilizados**,
**Archivos modificados**, **Validación funcional** (qué se probó con `web-ui-test` antes del PR, o
motivo de omisión), **Estado CI** (resultado de `ci-developer`: verde directo, o verde tras fixes —con
cuáles—, o bloqueado —con el diagnóstico—), **Resultado final**, **Link al PR**.
c) **NO** crees una página genérica "Documentación". La documentación del proyecto vive en las **páginas
temáticas bajo la página principal del proyecto** en su space (p.ej. la página "Ro-ut" con sus subpáginas
Arquitectura, Estructura, Variables de Entorno, etc.). El trabajo del agente debe **reflejarse
actualizando esas páginas** (y creando las que falten) — esto lo hace el agente como parte de su tarea
(idealmente una fase "actualizar documentación") y se verifica en el archivado (FASE 4, paso 2).
Con `PAGE_ID` y `SPACE_ID` ya en mano, continúa al paso 6.
### 6. Configurar permisos + instrucciones y lanzar tmux (modo INTERACTIVO)
> **LECCIONES CRÍTICAS DE LANZAMIENTO** (aprendidas a la mala — detalle en "Troubleshooting" al final):
> 1. **NO uses `| tee`** en el comando de `claude`. La tubería le quita el TTY → `claude` cae en modo
> no-interactivo (print), corre **un solo turno y sale**, y no puede pedir/saltar permisos. Para el log usa
> `tmux pipe-pane` (paso g).
> 2. **NO pases el prompt largo como argumento posicional.** Con instrucciones grandes da
> `ENAMETOOLONG: name too long` y `claude` no arranca. Escribe las instrucciones completas a un archivo
> **dentro del worktree** (`TASK.md`) y pasa un **posicional CORTO** que le diga que lo lea.
> 3. **`--dangerously-skip-permissions` NO cubre las herramientas MCP** (gitea/docmost **siguen pidiendo**
> aprobación), ni la lectura de archivos **fuera del cwd**, ni a veces comandos bash con expansión
> ("Contains expansion"). Solución: un **`.claude/settings.local.json`** en el worktree con una
> **allow-list de MCP** + `defaultMode: bypassPermissions` (paso b).
> 4. **Copia dentro del worktree** todo archivo que el agente deba leer (p.ej. el plan), y referencia rutas
> **relativas** (`./PLAN.md`) en `TASK.md`, para evitar prompts por lecturas fuera del cwd.
```bash
mkdir -p /tmp/agents
WT_ABS="$(pwd)/.worktrees/$AGENT_NAME"
# (a) Instrucciones COMPLETAS del agente DENTRO del worktree: escribe "$WT_ABS/TASK.md" con la
# tarea del usuario + el bloque "INSTRUCCIONES DEL AGENTE HIJO" (ver FASE 3, sustituyendo
# $AGENT_NAME, $PROJECT_NAME y el curl final con valores reales). El PAGE_ID y SPACE_ID de Docmost
# YA existen (paso 5) — inclúyelos reales, no como placeholder; el agente no debe buscarlos.
# Si hay un plan u otros archivos de referencia, CÓPIALOS al worktree (p.ej. cp <plan> "$WT_ABS/PLAN.md")
# y referencia ./PLAN.md (ruta relativa) dentro de TASK.md.
# (b) Allow-list de permisos para que NO pida aprobación (incluye MCP, que el flag NO cubre):
mkdir -p "$WT_ABS/.claude"
#
# ÚNICA FORMA VÁLIDA de crear settings.local.json: con el comando Bash de abajo (heredoc), NO con
# el tool Write ni el tool Edit — ambos pueden quedar bloqueados por el clasificador de permisos al
# escribir este contenido. Ejecuta EXACTAMENTE este comando, sin variantes:
cat > "$WT_ABS/.claude/settings.local.json" << 'ENDJSON'
{
"permissions": {
"defaultMode": "bypassPermissions",
"allow": [
"mcp__gitea", "mcp__docmost", "mcp__github-personal", "mcp__atlassian",
"Bash", "Read", "Edit", "Write", "Glob", "Grep", "Task", "WebFetch", "WebSearch"
]
}
}
ENDJSON
# El permiso Write(.worktrees/**/.claude/settings.local.json) en tu allow-list sigue siendo necesario
# para que este comando Bash no pida aprobación. Si ves un bloqueo aun así, consulta la fila
# correspondiente en TROUBLESHOOTING — NO intentes editar ~/.claude/settings.json como alternativa.
# (b2) Instalar dependencias en el worktree (los worktrees no heredan node_modules):
# Los worktrees de git no tienen node_modules (está en .gitignore). Sin esto, npm test / npm run build
# fallan con "Cannot find module" o "next/package.json not found".
# ⚠️ NO usar symlink a node_modules del repo raíz — Turbopack lo rechaza ("Symlink points out of filesystem root").
# Solución correcta: npm install real dentro del worktree.
( cd "$WT_ABS" && npm install )
# (c) Excluir el andamiaje del PR (no se commitea; info/exclude no se versiona):
EXCL="$(git rev-parse --git-common-dir)/info/exclude"
for p in "TASK.md" "PLAN.md" ".claude/settings.local.json"; do
grep -qxF "$p" "$EXCL" 2>/dev/null || echo "$p" >> "$EXCL"
done
# (d) Credenciales SMTP (env de la madre, fallback a ~/.bashrc):
# ⚠️ El formato real en ~/.bashrc es SIN comillas (`export GMAIL_ADDRESS=<<EMAIL_1>>`), no
# `GMAIL_ADDRESS="<<EMAIL_1>>"`. Un patrón que exija comillas (`GMAIL_ADDRESS="\K[^"]+`) NO matchea
# nada contra ese formato y deja la variable vacía en silencio — el agente-hijo arranca sin credenciales
# y `mailer` falla más tarde sin que lo notes aquí. El patrón de abajo acepta AMBOS formatos (con o sin
# comillas):
: "${GMAIL_ADDRESS:=$(grep -oP 'GMAIL_ADDRESS=\K"?[^"\n]+' ~/.bashrc | tail -1 | tr -d '"')}"
: "${GMAIL_APP_PASSWORD:=$(grep -oP '<<GMAIL_APP_PASSWORD_LITERAL_1>>"?[^"\n]+' ~/.bashrc | tail -1 | tr -d '"')}"
# Verificación OBLIGATORIA antes de seguir — si alguna queda vacía, el correo de mailer fallará más
# adelante sin aviso claro; resuélvelo ahora, no lo descubras al final:
[ -z "$GMAIL_ADDRESS" ] && echo "⚠️ GMAIL_ADDRESS quedó vacío — revisa el formato real en ~/.bashrc"
[ -z "$GMAIL_APP_PASSWORD" ] && echo "⚠️ GMAIL_APP_PASSWORD quedó vacío — revisa el formato real en ~/.bashrc"
# (e) Posicional CORTO (las instrucciones completas están en ./TASK.md):
SHORT="Eres un agente autonomo en background. Lee y ejecuta al pie de la letra ./TASK.md (y los archivos de referencia que indique, p.ej. ./PLAN.md) en la raiz de tu worktree. No pidas confirmacion; empieza ya."
# (f) Lanzar SIN '| tee' (conserva el PTY de tmux → interactivo):
tmux new-session -d -s "$AGENT_NAME" \
-e AGENT_NAME="$AGENT_NAME" -e PROJECT_NAME="$PROJECT_NAME" -e WORKTREE_PATH="$WT_ABS" \
-e GMAIL_ADDRESS="$GMAIL_ADDRESS" -e GMAIL_APP_PASSWORD="$GMAIL_APP_PASSWORD" \
"cd '$WT_ABS' && claude --dangerously-skip-permissions \"$SHORT\"; bash"
# (g) Log SIN romper el TTY:
sleep 1
: > /tmp/agents/$AGENT_NAME.log
tmux pipe-pane -o -t "$AGENT_NAME" "cat >> /tmp/agents/$AGENT_NAME.log"
```
> El `; bash` deja la sesión viva tras terminar para inspeccionarla. Modo interactivo (no `-p`) permite
> `tmux send-keys` para desbloquear/responder y que el hook `Notification` dispare al esperar input.
**Verificación inmediata (OBLIGATORIA): confirma que arrancó bien y sin prompts.**
```bash
sleep 12
tmux list-panes -t "$AGENT_NAME" -F '#{pane_current_command}' # 'node'/'claude' (puede verse 'bash' si claude es hijo — verifica también el transcript)
grep -c "could not start\|ENAMETOOLONG" /tmp/agents/$AGENT_NAME.log # DEBE ser 0
grep -c "Do you want to proceed" /tmp/agents/$AGENT_NAME.log # DEBE ser 0
```
> Si ves `ENAMETOOLONG` → el posicional es muy largo (usa el corto + TASK.md). Si ves "Do you want to
> proceed" → faltó/insuficiente la allow-list de MCP, o el agente lee algo fuera del cwd (cópialo al worktree).
> Si `pane_current_command` queda en `bash` y el transcript no avanza → `claude` salió; revisa el log limpio.
**Después de 1 minuto de trabajo confirmado, el agente está corriendo.** No necesitas seguir verificando
constante ni mandándole mensajes. Con que veas que después de ~60s está procesando (pane con contenido,
log con actividad, "Calculating..." o "Sublimating...") es suficiente para saber que avanzó.
> **NO revises el agente cada 30s.** Si está corriendo a los 60s, confía en que continuará. Revisar
> constantemente consume tokens y tiempo innecesariamente. Solo interviene si ves errores claros
> ("ENAMETOOLONG", "Do you want to proceed", pane en `bash` sin actividad después de 2-3 min).
> **No es tu responsabilidad monitorear el agente minuto a minuto.** Con confirmar que arrancó bien
> y que después de 1 minuto está procesando, tu trabajo de lanzamiento termina.
### 7. Capturar `SESSION_ID` desde el JSONL del proyecto
En modo interactivo el `session_id` no sale limpio en el log; se obtiene del transcript JSONL (el nombre del
archivo ES el session id):
```bash
sleep 4
ENC=$(echo "$WT_ABS" | sed 's/[^a-zA-Z0-9]/-/g')
SESSION_ID=$(ls -t ~/.claude/projects/$ENC/*.jsonl 2>/dev/null | head -1 | xargs -r basename | sed 's/\.jsonl$//')
# Fallback si quedó vacío: el JSONL más reciente que mencione el worktree
[ -z "$SESSION_ID" ] && SESSION_ID=$(find ~/.claude/projects -name '*.jsonl' -newermt '-90 seconds' 2>/dev/null \
| xargs -r grep -l "$WT_ABS" 2>/dev/null | head -1 | xargs -r basename | sed 's/\.jsonl$//')
echo "SESSION_ID=$SESSION_ID"
```
> ⚠️ **El Session ID cambia cada vez que relanzas la sesión** (p.ej. para corregir permisos). Captúralo
> **después del último relanzamiento exitoso** y, si ya lo registraste en Docmost, **actualízalo** (ver
> "Troubleshooting"). Para monitorear NO dependas de un ID fijo: usa **siempre el `.jsonl` más reciente**
> del dir de transcripts del worktree (`ls -t ~/.claude/projects/$ENC/*.jsonl | head -1`). La validación/archivo
> se hace por **nombre** del agente, así que un ID desfasado no rompe el flujo, pero manténlo correcto.
### 8. Actualizar Docmost con el Session ID real (MCP `mcp__docmost__*`)
Ya con `SESSION_ID` capturado (paso 7) y la sesión tmux corriendo, **actualiza** (no crees de nuevo) lo que
se creó en el paso 5, sustituyendo "pendiente" por los valores reales:
a) **Subpágina `AGENT_NAME`**: `update_page` para poner Session ID = `SESSION_ID`, Sesión tmux = `AGENT_NAME`,
Estado = "corriendo". Mantén el formato de tabla.
b) **Página "Agentes Activos"**: `update_page` para poner el Session ID real en la fila del agente. Es
lista de filas, no se rompe.
> Anota también el `PAGE_ID` de la subpágina en `TASK.md` del agente si por algún motivo no quedó ya escrito
> en el paso 6(a) — pero lo normal es que ya esté ahí desde el arranque.
> La madre **comparte** con el agente la responsabilidad de mantener Docmost: si tuviste que relanzar y el
> Session ID cambió, **actualiza la subpágina y el bloque en "Agentes Activos"** con el ID en vivo.
### 9. Confirmar al usuario
Informa: nombre de la sesión tmux, `SESSION_ID`, worktree, rama git, que puede desconectarse del tunnel y
recibirá un correo cuando el agente necesite input o termine, y que al reconectar puede pedir
"¿qué está haciendo AGENT_NAME?" o "revisa el estado de todos los agentes".
---
## FASE 2: MONITOREO
> **El monitoreo confiable es por el TRANSCRIPT JSONL, no por `capture-pane`.** `tmux capture-pane` suele
> volver **vacío** porque `claude` usa pantalla alterna; y `pane_current_command` muestra `bash` aunque
> `claude` (node) esté vivo como hijo. Usa el `.jsonl` más reciente del worktree como fuente de verdad.
### Comandos de monitoreo
```bash
AGENT_NAME=... # el agente a revisar
ENC=$(echo "$(pwd)/.worktrees/$AGENT_NAME" | sed 's/[^a-zA-Z0-9]/-/g')
JSONL=$(ls -t ~/.claude/projects/$ENC/*.jsonl 2>/dev/null | head -1) # transcript EN VIVO (no un ID fijo)
# ¿Vivo y avanzando? (líneas crecen y la última actividad es reciente)
echo "lineas=$(wc -l < "$JSONL") | hace $(( $(date +%s) - $(stat -c %Y "$JSONL") ))s"
tmux has-session -t "$AGENT_NAME" 2>/dev/null && echo "tmux SI"
# ¿Bloqueado en un prompt de permiso? (DEBE ser 0; si >0 falta allow-list o lee fuera del cwd)
grep -c "Do you want to proceed" /tmp/agents/$AGENT_NAME.log
# Últimas acciones del agente (herramientas):
grep '"type":"assistant"' "$JSONL" | tail -n 15 | jq -r '.message.content[]? | select(.type=="tool_use") | "[" + .name + "] " + ((.input.command // .input.file_path // .input.description // "") | tostring | .[0:90])'
# Progreso real en la rama (commits / cambios):
( cd .worktrees/$AGENT_NAME && echo "commits: $(git rev-list --count HEAD ^master 2>/dev/null)"; git log --oneline HEAD ^master | head )
# Panel en vivo legible (limpiando escapes ANSI) — útil para ver spinners "Ruminating…"/subagentes:
sed -r 's/\x1b\[[0-9;?]*[a-zA-Z]//g; s/\x1b\][^\x07]*\x07//g; s/\r/\n/g' /tmp/agents/$AGENT_NAME.log | sed '/^[[:space:]]*$/d' | tail -n 25
tmux list-sessions # todos los agentes activos
```
### Desbloquear / responder / instruir
(se encola y se procesa al cerrar el turno actual; no interrumpe):
```bash
tmux send-keys -t "$AGENT_NAME" -l "tu mensaje (usa -l para texto literal en una sola linea)"
sleep 1; tmux send-keys -t "$AGENT_NAME" Enter
```
> Un transcript "congelado" varios minutos **no** siempre es un bloqueo: si hay un **subagente** corriendo o
> un turno largo de "Ruminating…", el `.jsonl` principal no crece hasta que el turno cierra. Confirma con el
> panel limpio (verás `Agent(...) Running…` o `Ruminating… (Xm)`) y con que el **log** se siga escribiendo
> (`stat -c %Y` del log reciente). Un bloqueo real se ve como `Do you want to proceed` en el log o el
> proceso `claude` ausente con el panel en un prompt de `bash`.
La madre también puede leer la subpágina del agente en Docmost vía MCP para un resumen sin comandos bash.
---
## FASE 3: INSTRUCCIONES DEL AGENTE (`TASK.md`)
Contenido de `TASK.md` que se escribe dentro del worktree de cada agente.
Este archivo es lo que el agente lee y ejecuta al inicio.
> El siguiente bloque, con la **tarea del usuario** al inicio, es el contenido de `TASK.md`.
> Sustituye `$AGENT_NAME`, `$PROJECT_NAME`, el **pageId de la subpágina**, el **spaceId**,
> el **Session ID** y los valores del `curl` por los reales antes de escribir el archivo.
> El agente arranca con un posicional CORTO que solo le dice "lee y ejecuta ./TASK.md".
```
TAREA: <descripción concreta de la tarea del usuario>
Este archivo (TASK.md) son tus instrucciones COMPLETAS. Léelas y ejecútalas literalmente, en el orden en
que aparecen. No asumas que un paso ya se cumplió, que no aplica, o que puedes saltarlo — salvo que el
propio paso te lo diga explícitamente (ver, por ejemplo, la excepción del paso 2 en "PASOS OBLIGATORIOS
ANTES DE TERMINAR").
Eres un agente autónomo en background. Trabajas en el worktree .worktrees/$AGENT_NAME sobre la rama
$AGENT_NAME del proyecto $PROJECT_NAME.
Tu página de Docmost tiene pageId: $PAGE_ID (guárdala y úsala para actualizarla siempre).
Tu spaceId es: $SPACE_ID.
INSTRUCCIONES CRÍTICAS DE TRABAJO
- **Guarda el pageId de tu página de Docmost** en este archivo TASK.md (línea de arriba) y úsalo SIEMPRE para actualizar tu página. No busques el pageId cada vez — ya está aquí.
- **Documenta tu avance en Docmost con tablas**: Cada vez que actualices tu página, usa formato de tabla multi-línea (una línea por fila, con separador `| --- |`). Las tablas facilitan identificar información rápido.
ERRORES DE API Y CÓMO MANEJARLOS
- **Error "Connection closed mid-response"**: Si ves este error, NO intentes rehacer el mismo cambio en un solo bloque grande. En su lugar, haz los cambios **paso a paso, archivo por archivo**, y haz commits frecuentes. Cada turno debe ser pequeño y concreto.
- **Crea y modifica archivos por partes**: No intentes escribir archivos grandes de una sola vez. Divide el trabajo en cambios pequeños y confirmados.
NOTIFICAR AL USUARIO POR CORREO (SMTP) — vía el subagente `mailer`
- **Invoca el subagente `mailer` SIEMPRE que necesites input del usuario o tengas un problema que no puedas resolver solo.** No esperes a terminar la tarea.
- **Invócalo cuando**:
- Te bloqueas con un error que no puedes resolver (API errors, permisos, dependencias faltantes, etc.)
- Necesitas que el usuario te dé una decisión o aclaración
- Encuentras un problema crítico en el codebase que cambia el alcance
- Terminas la tarea (paso 5 de "PASOS OBLIGATORIOS ANTES DE TERMINAR", ya documentado)
- **Qué pasarle**: AGENT_NAME, PROJECT_NAME, y el asunto + cuerpo del correo ya redactados por ti.
- **Importante**: El correo es tu forma de salir del bloqueo. Es mejor enviar un correo que quedarse atascado.
- **NO cierres tu sesión ni termines el proceso después de que `mailer` envíe CUALQUIER correo** (bloqueo, necesita input, o finalización de la tarea). Tu trabajo no acaba con el correo: te quedas **esperando en el mismo turno** la respuesta del usuario (aprobación o corrección) vía `tmux send-keys`. Deja una pregunta o un resumen claro como tu última salida y espera ahí — nunca salgas de `claude` ni des la tarea por cerrada hasta que el usuario apruebe explícitamente ("apruebo $AGENT_NAME") o te indique una corrección.
PERMISOS Y AUTONOMÍA
- Tienes todos los permisos: bash, leer/escribir archivos, commits, MCPs, instalar dependencias, lo que haga
falta. No pidas permiso para operaciones de rutina; toma decisiones técnicas razonables por tu cuenta.
- **NO es bloqueo — resuélvelo tú mismo y sigue** (ejemplos concretos):
- Elegir entre dos formas igualmente válidas de implementar algo.
- Un error de lint/tipos/test que puedes arreglar leyendo el mensaje de error.
- Decidir nombres de variables, archivos, o el orden de parámetros de una función nueva.
- Instalar una dependencia que falta.
- Cualquier duda de implementación que no cambia lo que el usuario pidió.
- **SÍ es bloqueo real — pausa y notifica** (ejemplos concretos):
- Te faltan credenciales, permisos o acceso que **nadie más que un humano puede otorgarte**.
- Las instrucciones de la tarea se contradicen entre sí de forma irreconciliable.
- Completar la tarea tal como se pidió requeriría **romper una regla de seguridad de este documento**
(mergear, forzar push, commitear en `main`/`master`/`dev`, etc.).
- Un error técnico que **ya intentaste resolver, con un diagnóstico distinto cada vez, y sigue fallando**
(no repitas el mismo intento una y otra vez — si no tienes una hipótesis nueva, es bloqueo).
- Encontraste algo en el codebase que cambia el alcance de forma significativa y el usuario debería
decidir cómo seguir.
- Para pausar: deja una pregunta clara y concreta como tu última salida y espera; el hook `Notification`
avisará al usuario por correo y él te responderá vía tmux. No sigas trabajando en otra cosa mientras
esperas.
WORKTREE Y GIT
- Todos tus cambios van en la rama $AGENT_NAME. No toques ninguna otra rama.
- Haz commits atómicos y descriptivos a medida que avanzas, no todo al final.
- **NO commitees el andamiaje del orquestador**: `TASK.md`, `PLAN.md` y `.claude/settings.local.json` (ya
están en `.git/info/exclude`). Revisa `git status` antes de commitear y no uses `git add -A` a ciegas si
aparecieran; el PR debe contener solo los cambios reales de la tarea.
- Cuando termines, invoca el subagente `pr-shipper` para crear el PR desde $AGENT_NAME hacia la rama
principal (él usa la skill `aleleba-pr` internamente). No uses ningún otro método para crear el PR.
- NUNCA hagas merge de ningún PR ni de ninguna rama hacia main/master/dev, ni con git merge/rebase ni vía
MCP de Gitea/GitHub. Tú (agente) **nunca** mergeas; tu trabajo termina en el PR creado. (El merge solo lo
puede hacer la conversación principal, y únicamente con validación explícita del usuario.)
- **NUNCA atribuyas a Claude — REGLA ABSOLUTA**: nada de `Co-Authored-By: Claude` en NINGÚN commit.
Tampoco "Generated with Claude Code", "🤖", "creado con Claude" ni nada similar en commits, PR
(título/cuerpo), comentarios de código, ni Docmost. Sin firma de Claude.
**Verifica cada commit antes de hacer push — si tiene Co-Authored-By, está prohibido.**
SUBAGENTES (herramienta Task)
- Tienes 7 subagentes custom globales en ~/.claude/agents/: `developer`, `code-reviewer`, `qa-validator`,
`pr-shipper`, `ci-developer`, `docmost-reporter`, `mailer`. Todos usan `model: inherit` (heredan tu
modelo) y NO restringen `tools:` — heredan TODAS tus herramientas, incluidos TODOS los MCP conectados
(docmost, gitea, github, atlassian, penpot, etc.), sin que haga falta listarlos. No necesitas decirles
nada sobre permisos de MCP en el prompt.
- Los subagentes comparten tu mismo filesystem y worktree — NO crean su propio worktree ni rama.
Cuándo invocar cada uno:
- `developer` — implementa una porción acotada de código dentro de archivo(s) que TÚ le asignas
explícitamente. Puedes lanzar VARIOS `developer` EN PARALELO (varias llamadas Task en un solo mensaje)
cuando el trabajo se divide en archivos DISJUNTOS — asigna a cada uno una lista que no se solape con la
de los demás.
- `code-reviewer` — revisa el diff de uno o varios `developer` ANTES de commitear: bugs, seguridad,
simplificación. Solo inspecciona, nunca edita ni commitea.
- `qa-validator` — envuelve `web-ui-test` para validar funcionalmente en navegador real, antes del PR.
- `pr-shipper` — envuelve `aleleba-pr` para abrir el PR al final. Nunca mergea.
- `ci-developer` — invócalo justo DESPUÉS de `pr-shipper`: monitorea los checks de CI del PR (GitHub vía
`gh`, Gitea vía MCP) hasta que terminan; si alguno falla, lee el log, lo diagnostica, lo arregla
(commit + push él mismo) y vuelve a monitorear, repitiendo hasta que queden en verde o concluya que el
fallo no es arreglable por él. No documentes ni notifiques como terminado hasta tener su resultado.
- `docmost-reporter` — actualiza tu subpágina de Docmost con un checkpoint. Invócalo tras CADA commit o
fase importante, no solo al final.
- `mailer` — envía el correo SMTP (bloqueo o finalización) en vez de correr el curl tú mismo.
PROTOCOLO DE CONCURRENCIA (CRÍTICO — evita carreras en git)
- Los `developer` NUNCA hacen `git add` ni `git commit` — solo editan sus archivos asignados y te
devuelven un resumen completo. El commit lo haces SIEMPRE TÚ, nunca un subagente.
- **Única excepción: `ci-developer`.** Él sí commitea y hace push directamente cuando arregla un check de
CI, porque corre DESPUÉS de `pr-shipper`, en un ciclo secuencial y cerrado (poll → diagnóstico → fix →
push → poll) donde ningún otro subagente toca git al mismo tiempo. No lo invoques en paralelo con
`developer` ni con otro `ci-developer`.
- Si lanzaste varios `developer` en paralelo, espera a que TODOS terminen. Opcionalmente lanza UN
`code-reviewer` sobre el diff agregado de todo el lote (es solo lectura, no hay carrera en revisar en
paralelo a los commits). Luego procesa el commit de forma SECUENCIAL: por cada developer, en orden,
`git add <sus archivos>` + commit, ANTES de pasar al siguiente. Nunca mezcles `git add` de dos
developers en un mismo commit salvo que la tarea lo requiera explícitamente.
- Cada subagente no tiene memoria compartida ni contexto persistente: su respuesta final de texto es LO
ÚNICO que vas a ver de él. Dale en el prompt solo lo que no pueda averiguar por sí mismo (rutas exactas,
decisiones ya tomadas, resultados de otros subagentes que necesite) — evita pegarle diffs completos o
transcripciones largas si puede obtenerlos él mismo con Read/Grep/git diff. Usa su resumen para alimentar
a `docmost-reporter` y para responder si la sesión madre te pregunta algo.
DOCMOST DURANTE EL TRABAJO — vía el subagente `docmost-reporter` — ¡OBLIGATORIO, NO LO OLVIDES!
- Tu subpágina está bajo "Agentes Activos" en el space del proyecto $PROJECT_NAME. Su pageId te lo pasa la
madre en TASK.md. Pásaselo (junto al spaceId) a `docmost-reporter` en CADA invocación — no asumas que lo
recuerda de una invocación anterior.
- **CHECKPOINT OBLIGATORIO: invoca `docmost-reporter` al terminar CADA fase / tras CADA commit importante**
— no solo al final. Pásale: qué hiciste, decisiones y por qué, y el progreso (fase X ✅).
- **REPORTAR POR CADA COMMIT Y CADA TAREA:**
- Cada commit → invoca `docmost-reporter` con: hash, archivos cambiados, qué hiciste.
- Cada tarea del checklist completada → invoca `docmost-reporter` con: nombre de la tarea, qué hiciste,
resultado.
- No esperes al final para documentar.
- Si te bloqueas, ANTES de invocar `mailer`, invoca `docmost-reporter` para poner estado =
"esperando-aprobación" y describir exactamente qué necesitas y por qué.
CHECKLIST DE TAREAS (marca con `- [x]` al completar cada una, y reporta en Docmost vía `docmost-reporter`):
- [ ] Analizar la tarea y entender el alcance
- [ ] Explorar el codebase relevante (archivos, funciones, módulos afectados)
- [ ] Planificar la implementación (qué cambiar, cómo, y cómo dividirlo en archivos disjuntos si vas a
paralelizar con varios `developer`)
- [ ] Implementar los cambios de código (uno o varios subagentes `developer`; opcionalmente revisado por
`code-reviewer` antes de cada commit)
- [ ] Escribir pruebas si aplica (parte del alcance de `developer`)
- [ ] Validar funcionalmente con el subagente `qa-validator` (si tocó interfaz web)
- [ ] Crear el PR con el subagente `pr-shipper`
- [ ] CI del PR en verde (subagente `ci-developer`) o bloqueo notificado si no fue arreglable
- [ ] Actualizar la documentación en Docmost con el subagente `docmost-reporter`
PASOS OBLIGATORIOS ANTES DE TERMINAR (en este orden exacto)
Sigue estos 7 pasos EN ORDEN, uno a la vez, del 1 al 7. No te saltes ninguno, no los reordenes, y no
empieces el siguiente hasta haber terminado el actual — la única excepción es cuando el propio paso te dice
explícitamente que lo omitas (como el paso 2 cuando tu tarea no tocó interfaz web).
1. Commit final de cualquier cambio pendiente en tu rama (tú, el agente-hijo — nunca un subagente
`developer`).
2. **VALIDACIÓN FUNCIONAL — invoca el subagente `qa-validator`** (obligatoria antes del PR si tu tarea
tocó una interfaz web): pásale el flujo/URL concreto y si el servidor de desarrollo ya está corriendo
(puerto) o debe levantarlo él mismo. Si reporta FALLA, corrige (tú o un nuevo `developer`) y vuelve a
invocarlo. Si tu tarea es puramente backend/CLI/librería, omite este paso y dilo explícitamente en el
paso 5 (`docmost-reporter`).
3. **Crea el PR — invoca el subagente `pr-shipper`**, pasándole la descripción de la tarea y un resumen
del diff completo. Nunca mergea.
4. **Espera el CI en verde — invoca el subagente `ci-developer`**, pasándole plataforma
(GitHub/Gitea), owner/repo, número de PR, rama y rama base. Espera su resultado antes de seguir:
- **`GREEN`** → continúa al paso 5.
- **`BLOCKED`** → **NO envíes el correo de finalización.** Trátalo como cualquier otro bloqueo: primero
invoca `docmost-reporter` con estado = "esperando-aprobación" y el diagnóstico completo que te dio
`ci-developer` (qué check, qué error, qué se intentó, por qué no es arreglable por él); luego invoca
`mailer` con un correo de **bloqueo** (no de finalización) describiendo el mismo diagnóstico; y te
quedas esperando en el mismo turno la respuesta del usuario (aprobación de un enfoque, corrección, o
una decisión que solo él puede tomar) vía `tmux send-keys` — no avances a los pasos 5/6 hasta
resolverlo. Si el usuario responde con una corrección o autorización, aplícala (tú o un `developer`) y
vuelve a invocar `ci-developer`.
5. **Documenta — invoca el subagente `docmost-reporter`**, pasándole todo el contexto necesario (no tiene
memoria de turnos previos): resumen ejecutivo, decisiones técnicas y por qué, archivos modificados por
todos los developers, evidencia de `qa-validator` (o motivo de omisión), estado de CI reportado por
`ci-developer` (verde, y si hubo fixes de CI cuáles, y si `ci-developer` descartó alguna review
`CHANGES_REQUESTED` obsoleta de un bot — con qué commit la corrigió y qué check volvió a pasar), link al
PR de `pr-shipper`, y pídele que además actualice tu fila en "Agentes Activos" con estado =
"completado - pendiente validación".
6. **Envía el correo de finalización — invoca el subagente `mailer`** (una sola vez, y SOLO si `ci-developer`
devolvió `GREEN`), con AGENT_NAME, PROJECT_NAME, y asunto/cuerpo ("Agente $AGENT_NAME termino - PR listo
y checks en verde - pendiente validacion" + resumen breve + "dile a Claude: 'apruebo $AGENT_NAME'").
7. **Después de enviar el correo, NO salgas ni cierres la sesión.** Tu tarea no está terminada hasta que
el usuario apruebe explícitamente ("apruebo $AGENT_NAME") o pida una corrección (en cuyo caso la
implementas —tú o un `developer`—, repites la validación con `qa-validator` y el CI con `ci-developer`, y
vuelves a este paso).
```
> **Por qué el correo lo manda el agente y no un hook `Stop`**: en modo interactivo `claude` no termina solo,
> idlea tras cada turno, así que un hook `Stop` dispararía en cada turno (spam). El correo de finalización
> como paso 5 garantiza un único aviso, en el momento correcto. El hook `Notification` (gateado) cubre el
> caso "necesita input".
---
## FASE 4: ARCHIVAR
Disparo: "apruebo AGENT_NAME" / "valido el trabajo de AGENT_NAME" / `/aprobar AGENT_NAME`.
Pasos en orden.
### 1. Leer la subpágina del agente en Docmost
```
mcp__docmost__get_page({pageId: "AGENT_NAME"})
```
### 2. Integrar el trabajo en la documentación del proyecto
(NO crear una página genérica "Documentación"): actualiza las **páginas temáticas bajo la
página principal del proyecto** (p.ej. "Ro-ut" → Arquitectura, Estructura de Carpetas,
Variables de Entorno, CI/CD, Estado Actual, etc.) para que reflejen los cambios del
trabajo, y **crea las páginas que falten**. Normalmente el agente ya las actualizó como
parte de su tarea: aquí **verifica** que estén al día y **completa lo que falte**.
Regla de tablas: las tablas markdown deben enviarse en formato de varias líneas (una línea por fila), NO colapsadas en una sola línea, para que Docmost las renderice correctamente.
### 3. Borrar la subpágina del agente
```
mcp__docmost__delete_page({pageId: "AGENT_NAME"})
```
Solo después de que el paso 2 ya integró su contenido relevante en la documentación del proyecto — esa
información queda preservada ahí y en el "Historial" del paso 4, así que la subpágina en sí ya no hace falta.
### 4. Mover al agente de "Agentes Activos" a su sección "Historial" — NUNCA borres el historial
Lee la página "Agentes Activos" completa (`get_page`) antes de tocarla. Luego, con un solo `update_page`:
- Quita la fila del agente de la tabla **"Agentes Activos"** (ya terminó, no sigue activo).
- Agrega una fila NUEVA a la tabla **"Historial"** con: Nombre del agente, fecha de cierre, y un resumen
breve (1-2 líneas) de qué hizo — basado en lo leído en el paso 1.
- **Preserva TAL CUAL todas las filas que ya existían en "Historial"**. Es un registro permanente: bajo
NINGUNA circunstancia se borra, se vacía ni se sobrescribe una fila anterior de esa tabla — ni al
archivar este agente ni en ninguna otra edición futura de esta página. Si la encuentras vacía y crees
que un agente previo la borró por error, es un bug a corregir (avísalo), no algo a repetir.
### 5. Preguntar al usuario (y esperar respuesta antes de continuar)
a) **Merge:** "¿Quieres que **yo** (conversación principal) haga el merge del PR ahora,
o lo mergeas tú?" Advierte que mergear a `master` **dispara el deploy a producción**
(GitOps). Solo mergea si el usuario lo **valida explícitamente** ("sí, mergea"). Si
valida, la madre mergea vía MCP de Gitea (`pull_request_write`/merge) o `git`.
**Los agentes nunca mergean; la madre solo con este OK explícito.**
b) **Rama:** "¿Elimino la rama `AGENT_NAME` o la dejo?" Recuerda: si el PR **no** se ha
mergeado, **no** borres la rama (el PR se quedaría sin origen). Bórrala solo tras el
merge (o si el usuario lo pide a sabiendas de que descarta el PR).
### 6. Eliminar el worktree
(si el usuario quiere seguir probando localmente antes del merge, **déjalo** y elimínalo después):
```bash
git worktree remove .worktrees/$AGENT_NAME --force
```
### 7. Eliminar la rama (si corresponde)
(PR ya mergeado o el usuario lo pidió):
```bash
git branch -d $AGENT_NAME # local
# remota (si aplica): git push origin --delete $AGENT_NAME
```
### 8. Matar la sesión tmux si aún existe
```bash
tmux kill-session -t $AGENT_NAME 2>/dev/null
```
### 9. Limpiar artefactos
```bash
rm -f /tmp/agents/$AGENT_NAME.log /tmp/agents/$AGENT_NAME.prompt
```
(El andamiaje `TASK.md` / `PLAN.md` / `.claude/settings.local.json` vive dentro del
worktree y se va con `git worktree remove` del paso 6; no requiere limpieza aparte.)
### 10. Confirmar al usuario
- Agente archivado
- Trabajo integrado en la documentación del proyecto (con link a la página de Docmost)
- Estado del worktree/rama
- Link al PR
- Si el usuario validó el merge en el paso 5, indícalo como hecho; si no, deja claro
que el PR queda listo para mergear (por él o por la madre cuando lo valide).
**Los agentes nunca mergean; la madre solo con validación explícita del usuario.**
---
## TROUBLESHOOTING
Problemas reales encontrados y su solución.
| Síntoma | Causa | Solución |
| --- | --- | --- |
| El subagente `mailer` "falla" o el correo de bloqueo/finalización **nunca llega**, aunque las credenciales en `~/.bashrc` son correctas | La sesión madre (extensión VS Code) **no es un shell de login** — no sourcea `~/.bashrc` automáticamente, así que `$GMAIL_ADDRESS`/`$GMAIL_APP_PASSWORD` llegan vacías al paso 6(d) de FASE 1. El fallback por `grep` de ese paso buscaba el formato `GMAIL_ADDRESS="valor"` (con comillas), pero el formato real en `~/.bashrc` es `export GMAIL_ADDRESS=valor` (sin comillas) — el patrón no matcheaba nada y la variable quedaba vacía **en silencio**, sin error visible en ese momento; el fallo solo aparecía después, dentro del tmux del agente-hijo, cuando `mailer` reportaba "FALTA GMAIL_ADDRESS" | Ya corregido: el paso 6(d) usa un patrón que acepta ambos formatos (`grep -oP 'GMAIL_ADDRESS=\K"?[^"\n]+' ~/.bashrc \| tail -1 \| tr -d '"'`) y **verifica inmediatamente** si quedó vacía, imprimiendo una advertencia ahí mismo en vez de descubrirlo más tarde. Verificado manualmente: con este patrón se extraen `GMAIL_ADDRESS`/`GMAIL_APP_PASSWORD` correctamente y un correo de prueba enviado con el mismo comando `curl` de `mailer.md` llegó sin problema. |
| `claude` corre un turno y **sale** (panel queda en `bash`), no se puede desbloquear | `\| tee` en el comando le quita el TTY → modo no-interactivo (print) | Lanzar **sin `\| tee`**; usar `tmux pipe-pane -o` para el log |
| `Claude Code could not start: ENAMETOOLONG: name too long` | Prompt largo pasado como **argumento posicional** | Instrucciones en `TASK.md` dentro del worktree + **posicional CORTO** que lo lea |
| Pide "Do you want to proceed?" para **gitea/docmost (MCP)** aun con `--dangerously-skip-permissions` | El flag **no cubre MCP** | `.claude/settings.local.json` con `allow: ["mcp__gitea","mcp__docmost",...]` + `defaultMode: bypassPermissions` |
| Pide aprobación para **leer el plan** | Lectura **fuera del cwd** (p.ej. `~/.claude/plans/...`) | **Copiar** el archivo al worktree y referenciarlo relativo (`./PLAN.md`) |
| Pide aprobación en comandos bash con `${...}`/`$(...)` ("Contains expansion") | Guardia de expansión; `--permission-mode bypassPermissions` solo **no** basta | Usar `--dangerously-skip-permissions` **+** la allow-list de settings |
| **`npm` falla en el worktree** — "Cannot find Next.js package" / "next/package.json not found" | Los worktrees de git no heredan `node_modules` (está en `.gitignore`) | `( cd "$WT_ABS" && npm install )` antes de lanzar tmux. ⚠️ NO usar symlink — Turbopack lo rechaza ("Symlink points out of filesystem root") |
| **Write/Edit bloqueado al crear `settings.local.json`** (incluso con "sí autorizo" explícito, y aunque antes se intentara editar `~/.claude/settings.json` para forzar `defaultMode: bypassPermissions` y luego revertirlo — ese "proceso de 3 pasos" viejo (probado 2026-06-24) también queda bloqueado igual, porque editar `~/.claude/settings.json` está sujeto al mismo clasificador) | El clasificador intercepta el contenido sensible independientemente del destino (Write/Edit) y del contexto previo de autorización | Ya resuelto: el paso 6(b) de FASE 1 usa **directamente** el heredoc de Bash (`cat > ... << 'ENDJSON'`), sin pasar por Write/Edit ni por editar `~/.claude/settings.json`. El Bash tool no intercepta el contenido del archivo escrito. El permiso `Write(.worktrees/**/.claude/settings.local.json)` en el allow-list sigue siendo necesario. **No reintroduzcas el proceso de 3 pasos** — quedó documentado aquí solo como historial de por qué se abandonó. |
| El **agente no documenta en Docmost** (se concentra en el código) | Instrucción débil ("al tomar decisiones importantes") | Checkpoint **obligatorio por fase/commit** en FASE 3 + la madre comparte la responsabilidad |
| Docmost: tabla se "colapsa" en una línea al editar | Se envió el markdown de la tabla como un string de **una sola línea** en el `body` | Enviar la tabla con **cada fila en su propia línea** (incluida la fila `\| --- \|`) — así renderiza igual de bien en `update_page` que en `create_page`; no hace falta usar listas |
| El **Session ID en Docmost no coincide** con el vivo | El ID **cambia en cada relanzamiento** | Capturarlo tras el **último** relanzamiento y actualizar subpágina + bloque en "Agentes Activos" |
| `capture-pane` vuelve **vacío** / `pane_current_command`=`bash` con `claude` vivo | TUI usa pantalla alterna; `claude` (node) es proceso hijo | Monitorear por el **`.jsonl` más reciente** del worktree, no por capture-pane |
| Transcript "congelado" varios minutos pero **no** está bloqueado | Hay un **subagente** o un turno largo de "Ruminating…" (el `.jsonl` principal no crece hasta cerrar el turno) | Confirmar con panel limpio (`Agent(...) Running…`/`Ruminating…`) y que el **log** se siga escribiendo |
| `gh pr checks <pr> --watch` se corta a los ~10 min con checks aún `pending` | Timeout del Bash tool (máx. 600000ms), no un fallo del CI | **No es un bloqueo**: relanzar `gh pr checks <pr> --watch` y seguir esperando |
| `git commit` del agente falla: `error: gpg failed to sign the data` / `gpg-agent: error binding socket to '.../.gnupg/S.gpg-agent': Operation not supported` / `no agent running` | `~/.gnupg` vive en un mount **NFS** (no soporta sockets Unix, `bind()` devuelve `EOPNOTSUPP`) **y** `/run/user/<uid>` no existe ni se puede crear (`Permission denied`) — sin ningún lugar válido para el socket, `gpg-agent` no puede arrancar. No confundir con `gpg: Oops: lock already held by us`, que es contención transitoria por varios agentes concurrentes usando `~/.gnupg` al mismo tiempo (ese sí se resuelve solo reintentando) | Copiar el homedir de GnuPG a un directorio local (no-NFS) y firmar desde ahí: `mkdir -p /tmp/<user>-gnupg && chmod 700 /tmp/<user>-gnupg && rsync -a --exclude='._*' ~/.gnupg/ /tmp/<user>-gnupg/ && chmod 700 /tmp/<user>-gnupg/private-keys-v1.d`, confirmar que el fingerprint coincide con `git config --get user.signingkey`, probar con `echo test \| GNUPGHOME=/tmp/<user>-gnupg gpg --clear-sign`, y commitear con `GNUPGHOME=/tmp/<user>-gnupg git commit ...`. Verificar después con `git log -1 --show-signature` (debe decir `Good signature`). El `--exclude='._*'` es necesario — NFS deja artefactos silly-rename (`._private-keys-v1.d`) ilegibles que rompen un `rsync`/`cp -r` sin filtrar. Detalle completo: `docs/solutions/tooling/gpg-agent-nfs-homedir-signing-failure-2026-07-13.md` (repo `gfiber-pilot-extension`). Fix real y durable pendiente a nivel de host: mover `~/.gnupg` fuera del NFS, o provisionar `/run/user/<uid>` correctamente — mientras eso no pase, **cualquier agente que firme commits en esta máquina puede volver a pegar con esto**. |
### Regla de oro tras lanzar
Corre la **verificación inmediata**`ENAMETOOLONG`=0, `Do you want to proceed`=0,
transcript creciendo. Si algo falla, **mata la sesión y relanza** corrigiendo; no
dejes al agente atascado en prompts. Tras el relanzamiento definitivo, **sincroniza
el Session ID en Docmost**.
---
## REFERENCIAS RÁPIDAS (skills y subagentes)
- **`aleleba-pr`** — pipeline de entrega (commit, push, PR). El agente-hijo no la invoca directamente:
la invoca a través del subagente `pr-shipper` (paso 3 de "PASOS OBLIGATORIOS ANTES DE TERMINAR"). La
madre sí la invoca directamente desde FASE 4 (merge).
- **`docmost-context`** — carga de contexto del proyecto. Se activa vía hook al inicio de sesión (para la
madre; el agente-hijo ya recibe su contexto en TASK.md).
- **`web-ui-test`** — pruebas de UI con Playwright headless. El agente-hijo no la invoca directamente: la
invoca a través del subagente `qa-validator` (paso 2 de "PASOS OBLIGATORIOS ANTES DE TERMINAR").
- **Subagentes (`~/.claude/agents/*.md`)**`developer`, `code-reviewer`, `qa-validator`, `pr-shipper`,
`ci-developer` (monitorea y arregla el CI del PR hasta verde o bloqueo, justo después de `pr-shipper`),
`docmost-reporter`, `mailer`. Ver "SUBAGENTES (herramienta Task)" en FASE 3.
@@ -0,0 +1,261 @@
---
name: aleleba-pr
description: Full delivery pipeline — creates a branch if needed, commits, pushes, and opens a PR. Auto-detects GitHub vs Gitea and uses the right tool. All output (commits, PR titles, PR bodies) must be written in English. Triggers: "aleleba-pr", "create a PR", "push and PR", "ship this", "crea un PR", "crea una rama".
effort: medium
argument-hint: "[optional PR description]"
---
# aleleba-pr — Commit, Push and PR
Custom delivery pipeline. Gets work into the remote repository with a well-written PR, regardless of whether the repo is on GitHub or Gitea.
**Language rule: ALL output must be in English** — commit messages, branch names, PR titles, PR bodies, table content, test plan steps. No exceptions.
## Safety rules
### NEVER
- Force push (`--force`, `--force-with-lease`)
- `git add -A` or `git add .` — always stage specific files
- Add **ANY Claude attribution**`Co-Authored-By: Claude`, "Generated with Claude Code", the 🤖 emoji,
"created with Claude" or anything similar — in commit messages, PR titles, PR bodies, code comments, or
anywhere else. No Claude signature, ever.
- Push without explicit user confirmation
- Use `HEAD` instead of the explicit branch name when pushing
- **Commit on `main`, `master`, or `dev`** — always create a feature branch first with `git checkout -b {branch-name}` before any commit
### ALWAYS
- Check the branch before any operation
- Ask for confirmation before pushing
- Use HEREDOC for commit messages
- Derive the PR title from the current branch name
- Write everything in English: commit messages, PR title, PR body, branch name slugs
---
## Pre-flight — Branch and platform check
This step runs **once at the start**.
### 1. Read repo context
```bash
git branch --show-current
git remote get-url origin
git status --short
git log --oneline -5
```
### 2. Check if we are on a protected branch
**ALWAYS check the current branch before doing ANYTHING else — including commits.**
If the current branch is `main`, `master`, or `dev` → **STOP and create a feature branch first. NEVER commit on these branches.**
**2a. Look for PRNameGenerator in the repo root:**
```bash
ls PRNameGenerator.ts PRNameGenerator.js 2>/dev/null | head -1
```
- If `PRNameGenerator.ts` exists: run `npx ts-node PRNameGenerator.ts`. Use the output as the new branch slug.
- If `PRNameGenerator.js` exists: run `node PRNameGenerator.js`. Use the output as the new branch slug.
**2b. If no PRNameGenerator exists** → infer the branch name from the current diff:
- Run `git diff --stat` to understand what changed
- Use format `feat/description-with-hyphens` (new feature) or `fix/description-with-hyphens` (bug fix)
- All lowercase, hyphen-separated, no special characters, **in English**
- Examples: `feat/add-user-authentication`, `fix/null-pointer-on-login`
**2c. Create the branch — this MUST happen before any commit:**
```bash
git checkout -b {branch-name}
```
Verify with `git branch --show-current` that you are now on the new branch before proceeding.
If already on a feature branch (not `main`, `master`, or `dev`) → proceed directly to Step 1.
### 3. Detect platform
Read the remote URL:
- Contains `github.com`**GitHub** mode (use `gh` CLI)
- Contains `gitea.p-lao.com`**Gitea** mode (use MCP `mcp__gitea__pull_request_write`)
- Other domain → warn the user and ask which tool to use
### 4. Detect base branch
```bash
git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo "UNRESOLVED"
```
If unresolved, try `main`, then `dev`, then `master`. Store as `{base-branch}`.
---
## Step 1: Commit (if there are pending changes)
Check working tree state:
- **Clean tree AND unpushed commits exist** → skip directly to Step 2
- **Nothing to commit and nothing to push** → report "Nothing to ship" and exit
- **Uncommitted changes exist** → proceed with the commit
**Show the diff for review:**
```bash
git diff --stat
git diff --name-only
```
**Analyze the changes** to write a descriptive conventional commit message in English (`feat:`, `fix:`, `chore:`, `refactor:`, etc.).
**Stage specific files** (never `git add -A`):
```bash
git add {file1} {file2} ...
```
**Create the commit with HEREDOC** — no Co-Authored-By or any authorship trailer:
```bash
git commit -m "$(cat <<'EOF'
type: concise description in imperative mood in English
Explanation of what changed and why (if applicable), in English.
EOF
)"
```
If a pre-commit hook fails → report the error, do NOT use `--no-verify`. Ask the user to fix it and retry.
---
## Step 2: Push
Show the commits about to be pushed:
```bash
git log origin/{base-branch}..HEAD --oneline 2>/dev/null || git log --oneline -5
```
**Ask for explicit user confirmation** before pushing. Show:
- Target: `origin/{branch-name}`
- Number of commits to push
- List of commits
Once confirmed:
```bash
git push origin {branch-name}
```
If push fails because the branch has no upstream → add `-u`:
```bash
git push -u origin {branch-name}
```
---
## Step 3: Create PR
**Build the title** from the current branch name:
- Branch: `feat/add-user-auth` → Title: `feat/add-user-auth: Add user authentication`
- The description after `:` must be a readable English summary of the diff changes
**Build the PR body** by analyzing the full diff. Everything must be written in English. **Do NOT append any
Claude attribution footer** ("Generated with Claude Code", 🤖, etc.) — the PR body ends at the Test Plan:
```
## Summary
- {bullet 1 describing the main change}
- {bullet 2 if there are more relevant changes}
## Changes
| File | Change |
|------|--------|
| `path/to/file` | Description of the change |
| ... | ... |
## Test Plan
- [ ] {test step 1}
- [ ] {test step 2}
```
### If GitHub:
```bash
gh pr create \
--title "{title}" \
--body "$(cat <<'EOF'
{body built above}
EOF
)"
```
If an open PR already exists for this branch (`gh pr view 2>/dev/null`), update the body instead of creating a new one:
```bash
gh pr edit --body "$(cat <<'EOF'
{body}
EOF
)"
```
### If Gitea:
Extract `owner` and `repo` from the remote URL:
- HTTPS: `https://gitea.p-lao.com/owner/repo.git``owner=owner`, `repo=repo`
- SSH: `<<EMAIL_2>>:owner/repo.git` → same
Invoke the MCP tool:
```
mcp__gitea__pull_request_write:
method: "create"
owner: {owner}
repo: {repo}
head: {branch-name}
base: {base-branch}
title: {title}
body: {body built above}
```
---
## Step 4: Final report
Show a summary of what was done:
```
═══════════════════════════════════════
ALELEBA-PR — SHIP MANIFEST
═══════════════════════════════════════
Platform: GitHub / Gitea
Branch: {branch-name}
Base: {base-branch}
PR: {pr-url}
Status: DONE
═══════════════════════════════════════
```
Omit sections that did not apply (e.g., no commit step if the tree was already clean).
---
## Failure modes
| Situation | Behavior |
|-----------|----------|
| On protected branch | Auto-create feature branch using PRNameGenerator or inferred name |
| PRNameGenerator fails | Warn, infer branch name from diff |
| Push rejected (remote ahead) | Report error, suggest `git pull --rebase origin {branch}` |
| `gh` not authenticated | Report, suggest `gh auth login` |
| PR already exists (GitHub) | Update body with `gh pr edit` instead of creating new |
| Gitea MCP fails | Report error and print the PR body so user can create it manually |
| Nothing to ship | Report cleanly and exit without error |
---
## Usage examples
```
/aleleba-pr
```
Full pipeline from any state.
```
/aleleba-pr adds dark mode support
```
Uses the provided description as extra context for the PR title and body.
@@ -0,0 +1,164 @@
---
name: docmost-context
description: >-
Carga el contexto del proyecto actual desde Docmost (MCP) al inicio de una conversación: lista los
spaces, identifica cuál corresponde al proyecto del directorio actual, y lee solo las páginas
más relevantes (arquitectura, estructura, tecnologías, estilo de código, etc.). Se activa
automáticamente vía el hook SessionStart (ver ~/.claude/hooks/docmost-session-start.sh) — no
requiere que el usuario la invoque a mano, aunque también responde a "carga el contexto de
Docmost", "lee el proyecto en Docmost", "/docmost-context".
effort: medium
argument-hint: "[nombre del proyecto, si ya se conoce]"
---
# docmost-context — Cargar contexto relevante del proyecto desde Docmost
Lee solo las páginas más importantes de Docmost para arrancar la conversación con contexto
estructural, en lugar de leer todo ciegamente.
Se dispara automáticamente al inicio de la conversación vía el hook `SessionStart`
(`~/.claude/hooks/docmost-session-start.sh`), que inyecta una instrucción con el `PROJECT_NAME` ya
detectado. También puede invocarse a mano.
## Workflow
### 1. Determinar el nombre del proyecto
- Si vino como argumento (`args`), úsalo directamente.
- Si no, detéctalo en este orden de prioridad (igual que `agent-orchestrator`):
```bash
PROJECT_NAME=$(jq -r '.name // empty' package.json 2>/dev/null)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(grep -oP '^\s*name\s*=\s*"\K[^"]+' pyproject.toml 2>/dev/null | head -1)
[ -z "$PROJECT_NAME" ] && PROJECT_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
```
### 2. Listar spaces
Llama a `mcp__docmost__list_spaces`.
### 3. Buscar la coincidencia
Compara `PROJECT_NAME` contra el `name` y el `slug` de cada space, **normalizando** ambos lados antes de
comparar: minúsculas, sin espacios/guiones/guiones bajos, y **sin el scope de npm** si lo tiene (quita el
prefijo `@algo/` de nombres tipo `@aleleba/create-react-component-library``create-react-component-library`)
— los nombres de space no siempre calzan literal con el nombre del repo o del `package.json`
(ej. space "Create React SSR App" vs. repo `create-react-ssr-app`, o `@aleleba/create-react-component-library`
vs. space "Create React Component Library").
Si tras esa normalización no hay match exacto, prueba también un match por contención (el nombre
normalizado de uno está contenido en el del otro) antes de darte por vencido.
- **Una sola coincidencia** → úsala directamente, sin confirmar con el usuario.
- **Cero coincidencias** → usa `AskUserQuestion` mostrando la lista de spaces disponibles (nombre +
descripción) y deja elegir uno existente, o la opción de que ninguno corresponde. Si el usuario dice
que ninguno corresponde, no cargues nada y continúa la conversación normalmente — no insistas.
- **Varias coincidencias ambiguas** → pregunta igual, mostrando solo los candidatos ambiguos.
### 4. Dos pasos: puntuar y leer
Llama a `mcp__docmost__list_pages({spaceId})` para obtener el árbol completo de páginas.
No llames a `get_page` todavía. Primero **puntuar** todas las páginas, luego **leer** solo las seleccionadas.
#### Paso A: Puntuar cada página
Asigna un score a cada página con esta fórmula:
```
score = keyword_score + depth_score + name_quality + topic_bonus
```
**keyword_score** (2 puntos por cada keyword que coincida con el nombre o ruta de la página):
| Categoría | Keywords |
|---|---|
| Arquitectura y estructura | arquitectura, estructura, stack, tecnologías, componentes, layout, routing, routes, endpoints, models, schema, database, base de datos |
| Configuración y herramientas | config, configuration, tsconfig, vite.config, next.config, eslint, prettier, biome, tailwind, tailwind.config, dependencies, dependencias |
| Convenios y estilo | estilo de código, convenciones, code style, coding conventions, guidelines, guía, style guide |
| Onboarding y setup | onboarding, setup, getting started, entorno, environment, variables, entorno, configuración, install, instalación |
| Testing | testing, tests, jest, vitest, cypress, playwright, e2e, integration |
| Deploy y CI/CD | deploy, deployment, CI/CD, pipeline, github actions, vercel, netlify, docker, docker-compose |
| Migración y estado | migración, migrations, roadmap, estado actual, changelog, release |
| API y backend | API, endpoint, route, controller, service, middleware |
| Genéricos (páginas raíz) | overview, introduction, getting started, readme, documentación, inicio, home |
**depth_score** (basado en la profundidad en la jerarquía):
- 0 niveles (página raíz): **+3**
- 1 nivel de profundidad: **+2**
- 2 niveles de profundidad: **+1**
- 3+ niveles de profundidad: **+0**
**name_quality** (1 si el nombre es significativo, 0 si no):
- **+1** si el nombre no es "Untitled", "Sin título", vacío, o genérico sin contexto
- **+0** si el nombre es vacío, "Untitled", o similar
**topic_bonus** (2 puntos si la página coincide con el tema del usuario, 0 si no):
Si el mensaje del usuario o el hook mencionan un tema específico, aplica bonus:
| Tema del usuario | Keywords de bonus |
|---|---|
| Testing | test, jest, vitest, cypress, playwright, e2e |
| Deploy/CI/CD | deploy, CI/CD, pipeline, docker, vercel, github actions |
| Database | database, schema, migration, prisma, drizzle, kysely |
| UI/Components | component, design system, tailwind, theme, layout |
| API/Backend | API, endpoint, route, controller, service |
El topic_bonus es un **extra** sobre el keyword_score base. No reemplaza las keywords de
arquitectura/estructura — se suma a ellas.
#### Paso B: Reglas de selección
Clasifica las páginas en tres tiers según el score:
| Tier | Score | Qué hacer |
|---|---|---|
| **Crítico** | >= 8 | Leer completo con `get_page` |
| **Importante** | 4-7 | Leer título + snippet (primeros 300 caracteres) |
| **Nice-to-know** | < 4 | No leer contenido, solo anotar título |
**Límite de páginas completas:** máximo **5 páginas** se leen en detalle.
**Regla de selección según el total de páginas del space:**
| Total páginas | Páginas completas | Snippets | Ignorar |
|---|---|---|---|
| 0-3 | Todas | 0 | 0 |
| 4-10 | Top 3-5 | Resto | 0 |
| 11+ | Top 5 | Siguientes 5 | Resto |
**Lectura parcial con contenido-gated expansion (para tier "Importante"):**
1. Leer título + primeros 300 caracteres del contenido.
2. Evaluar: ¿el snippet contiene información relevante para el tema que el usuario está trabajando?
3. Si SÍ → leer la página completa con `get_page`.
4. Si NO → descartar, no leer más.
**Páginas ignoradas:** solo anotar el título en el reporte final. No llamar a `get_page`.
### 5. Leer solo lo seleccionado
Para cada página seleccionada:
- **Tier Crítico** (hasta 5 páginas): llamar a `mcp__docmost__get_page({pageId})` y leer el contenido completo.
- **Tier Importante** (lectura parcial): llamar a `get_page`, leer solo título + primeros 300 caracteres.
Si el snippet es relevante para el tema del usuario, leer el resto.
- **Tier Nice-to-know**: no llamar a `get_page`. Solo usar el título de la lista.
### 6. Reportar, no transcribir
Una vez cargado, responde con un mensaje breve (1-2 líneas): qué space se usó y cuántas páginas
relevantes se cargaron. No repitas el contenido de las páginas en el chat — ya quedó en tu contexto
para usarlo cuando haga falta.
Ejemplo de reporte:
```
Contexto cargado desde Docmost:
- Space: <nombre>
- Fase 1 (baseline): <n> páginas (arquitectura, estructura, tecnologías)
- Fase 2 (contexto específico): <n> páginas (<temas>)
Total: <n> páginas relevantes cargadas.
```
### 7. Una sola vez por conversación
Ejecuta esto una sola vez, al primer turno disparado por el hook. No lo repitas en turnos posteriores
de la misma conversación salvo que el usuario lo pida explícitamente (ej. "recarga el contexto de
Docmost").
@@ -0,0 +1,70 @@
---
name: spark-ssh
description: >-
Conecta y ejecuta comandos en el servidor remoto "spark" vía SSH usando la variable de entorno
SPARK_PASSWORD para la autenticación (spark no acepta key, solo password). Traduce rutas entre el
volumen NFS /mnt/docker-nas/projects de spark y la carpeta ~/projects de este entorno de desarrollo
(son el mismo storage compartido). Triggers: "conéctate a spark", "ejecuta esto en spark", "corre este
comando en spark", "revisa spark", "spark ssh".
effort: low
argument-hint: "[comando o tarea a ejecutar en spark]"
---
# spark-ssh — Ejecutar comandos remotos en spark vía SSH
## Contexto fijo
- El host `spark` ya está configurado en `~/.ssh/config` (`HostName <<SPARK_IP_1>>`, `User aleleba`) —
nunca hace falta especificar user/host, basta `ssh spark`.
- spark **no acepta autenticación por key**, solo por password. La password vive en la variable de
entorno `$SPARK_PASSWORD` de este entorno de desarrollo.
- `ssh` no tiene ninguna forma nativa de pasar una password por flag (`-p` es el puerto, no la
password) — por eso se usa `sshpass` para automatizar el login.
- spark tiene montado por NFS el volumen `/mnt/docker-nas`. Dentro de él,
`/mnt/docker-nas/projects/<nombre>` **es exactamente el mismo storage** que `~/projects/<nombre>` en
este entorno de desarrollo (compartido vía NAS). Un cambio hecho en un lado es instantáneamente visible
en el otro.
- En spark, los modelos (LLMs, checkpoints, etc.) se guardan en `/home/aleleba/models` — esta ruta es
local a spark, no está compartida con este entorno de desarrollo.
## 1. Preflight — sshpass (una sola vez, idempotente)
```bash
which sshpass >/dev/null 2>&1 || sudo apt-get install -y sshpass
```
Si ya está instalado (lo estará después de la primera vez que se use esta skill), este paso no hace
nada — no volver a instalar ni preguntar por ello.
## 2. Ejecutar un comando remoto
```bash
SSHPASS="$SPARK_PASSWORD" sshpass -e ssh spark '<comando remoto>'
```
- Usar siempre `sshpass -e` (lee la password de la variable de entorno `SSHPASS`), **nunca** `sshpass -p
"$SPARK_PASSWORD"` — ese flag deja la password visible en la lista de procesos (`ps`).
- Para comandos multilínea o con comillas complejas, usar un heredoc remoto en vez de escapar comillas:
```bash
SSHPASS="$SPARK_PASSWORD" sshpass -e ssh spark bash -s <<'EOF'
comando1
comando2
EOF
```
## 3. Rutas — proyectos compartidos
Si el comando remoto opera sobre un proyecto que también existe localmente en `~/projects/<nombre>`, la
ruta equivalente en spark es `/mnt/docker-nas/projects/<nombre>`.
- Para **editar código** de esos proyectos, preferir las tools locales (Read/Edit) ya que es el mismo
filesystem — no hace falta editar por SSH.
- Usar SSH solo para **ejecutar** cosas que realmente necesitan correr en spark (builds, procesos,
comandos específicos del entorno/hardware de spark, por ejemplo GPU/Spark jobs).
## Seguridad
- Nunca imprimir, loggear ni commitear el valor de `$SPARK_PASSWORD`.
- Aplican las reglas generales de acciones riesgosas: confirmar con el usuario antes de ejecutar en spark
comandos destructivos o de alto impacto (`rm -rf`, `docker rm`/`docker system prune`, `kill -9`,
reinicios de servicios, etc.).
@@ -0,0 +1,179 @@
---
name: web-ui-test
description: >-
Prueba interfaces de aplicaciones web (navegación, interacción, capturas, errores de consola) usando
un navegador headless vía Playwright CLI — pensado para correr desde un tunnel remoto de VS Code sin
GUI. Instala Playwright y su navegador automáticamente si no están presentes. Triggers: "prueba esta
interfaz", "prueba la web app", "test this UI", "revisa la interfaz", "haz un smoke test", "prueba el
flujo de login/registro/checkout", "captura screenshots de la app".
effort: medium
argument-hint: "[URL a probar, o descripción del flujo a validar]"
---
# web-ui-test — Probar interfaces web con Playwright headless
Prueba interfaces de aplicaciones web navegando, interactuando y capturando evidencia con un navegador
**headless** vía `@playwright/cli`. Diseñada para correr desde un tunnel remoto de VS Code (sin display
local) — nunca intenta abrir un navegador con GUI.
Skill global, autosuficiente: no depende de ningún plugin instalado (por ejemplo `c3po`), solo de
`npm`/`npx` disponibles en el PATH.
## 1. Preflight — verificar/instalar Playwright de forma global
Ejecuta primero, siempre:
```bash
npm ls -g --depth=0 2>/dev/null | grep -q "@playwright/cli" && echo CLI_OK=1 || echo CLI_OK=0
npm ls -g --depth=0 2>/dev/null | grep -qw "playwright" && echo PW_OK=1 || echo PW_OK=0
ls ~/.cache/ms-playwright 2>/dev/null | grep -qi chromium && echo BROWSER_OK=1 || echo BROWSER_OK=0
```
- Si `CLI_OK=0` o `PW_OK=0` → instalar de forma global:
```bash
npm install -g @playwright/cli playwright
```
- Si `BROWSER_OK=0` → instalar el navegador **por defecto sin dependencias de sistema**:
```bash
playwright install chromium
```
- Si todo ya está OK (`CLI_OK=1`, `PW_OK=1`, `BROWSER_OK=1`) → **saltar este paso por completo** y pasar
directo a la sección 3.
**Importante**: `@playwright/cli` usa por defecto el canal `chrome` (Google Chrome real, no instalado
aquí), no el Chromium que acabas de instalar con `playwright install chromium`. Por eso **todo comando
que abra un navegador debe incluir `--browser chromium` explícitamente** (ver sección 3) — si lo omites,
falla con `Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome` aunque la
instalación haya sido exitosa.
### Fallback automático (solo si falla, nunca por defecto)
Si al abrir una sesión (sección 3) el error indica librerías de sistema faltantes (mensajes tipo
"missing libraries", "host system is missing dependencies"), reintenta automáticamente, sin preguntar:
```bash
playwright install --with-deps chromium
# si falla por permisos:
sudo npx playwright install-deps chromium
```
Luego repite la operación que había fallado. Este camino solo se dispara ante ese error concreto — no lo
ejecutes de forma preventiva ni la primera vez que instalas el navegador.
## 2. Reglas headless (no negociable)
- **Nunca** pases `--headed` ni cambies `PWDEBUG`. Este entorno es un tunnel remoto sin display: todo
corre headless, siempre.
- Usa sesiones nombradas (`-s=<slug>`) en kebab-case para trazabilidad y para poder limpiar sesiones
huérfanas de corridas anteriores.
## 3. Ciclo de vida de sesión — abrir → interactuar → cerrar
Toda tarea sigue el patrón **abrir → interactuar → cerrar**. El cierre es obligatorio, incluso si algo
falló antes (equivalente a un `finally`).
```bash
# Abrir — SIEMPRE con --browser chromium (ver nota en sección 1)
npx @playwright/cli -s=$SESSION open "$URL" --browser chromium
# Interactuar (snapshot → actuar → re-snapshot)
npx @playwright/cli -s=$SESSION snapshot
npx @playwright/cli -s=$SESSION click e15
npx @playwright/cli -s=$SESSION snapshot
# Cerrar (SIEMPRE, incluso si algo anterior falló)
npx @playwright/cli -s=$SESSION close
```
### Comandos disponibles
- **Navegación**: `open <url> --browser chromium`, `goto <url>`, `go-back`, `go-forward`, `reload`
- **Inspección**: `snapshot` (árbol de accesibilidad con refs `e15`, `e21`...), `screenshot [--full-page] --filename ...`, `pdf --filename ...`
- **Interacción**: `click <ref>`, `fill <ref> "texto"`, `type "texto"`, `select <ref> "valor"`, `check/uncheck <ref>`, `upload /ruta/archivo`
- **Teclado/mouse**: `press <tecla>`, `hover <ref>`, `drag <ref-origen> <ref-destino>`
- **Pestañas**: `tab-list`, `tab-new <url> --browser chromium`, `tab-select <n>`, `tab-close <n>`
- **Estado de auth**: `state-save <archivo.json>`, `state-load <archivo.json>`
- **Diagnóstico**: `console error`, `network`, `eval "() => document.readyState"`
- **Gestión de sesiones**: `list`, `close`, `close-all`, `kill-all`
## 4. Verificación de carga de página
Antes de interactuar con el contenido, confirma que la página cargó:
```bash
npx @playwright/cli -s=$SESSION eval "() => document.readyState"
```
- `"complete"` → continuar
- `"loading"` / `"interactive"` → esperar ~2s y reintentar (máx. 3 chequeos, sin `sleep` arbitrario más
allá de esa espera corta)
## 5. Secuencia estándar de captura
Cuando la tarea es "revisar/probar esta página":
```bash
mkdir -p .local/screenshots
npx @playwright/cli -s=$SESSION open "$URL" --browser chromium
npx @playwright/cli -s=$SESSION eval "() => document.readyState"
npx @playwright/cli -s=$SESSION screenshot --filename .local/screenshots/$SESSION-viewport.png
npx @playwright/cli -s=$SESSION screenshot --full-page --filename .local/screenshots/$SESSION-full.png
npx @playwright/cli -s=$SESSION snapshot --filename .local/screenshots/$SESSION-snapshot.md
npx @playwright/cli -s=$SESSION console error
npx @playwright/cli -s=$SESSION close
```
**Artefactos siempre a disco** en `.local/screenshots/` (relativo al directorio del proyecto actual).
Nunca vuelques el snapshot completo, HTML crudo, o el contenido de las capturas en el chat — solo reporta
las rutas y un resumen.
## 6. Flujos interactivos (probar un flujo, no solo una página)
Cuando el usuario pide validar un flujo (login, checkout, registro, etc.):
1. `snapshot` para obtener refs frescos.
2. Actuar (`click`/`fill`/`select`/`check`) sobre esos refs.
3. **Re-snapshot después de cualquier acción que cambie el DOM** — los refs quedan obsoletos tras
mutaciones.
4. Si un elemento esperado no aparece tras 2 reintentos con snapshot fresco, repórtalo como fallo del
paso en vez de seguir a ciegas.
5. Captura screenshot en cada momento relevante del flujo (no solo al final).
## 7. App local vs URL remota
- Si el usuario pide probar "la app" sin dar URL y el proyecto tiene un servidor de desarrollo definible
(`package.json` con script `dev`/`start`, etc.), levanta ese servidor primero y espera a que responda
antes de abrir la sesión de Playwright.
- Si el usuario da una URL explícita, úsala directamente sin intentar levantar nada.
## 8. Formato de reporte final
Resumen breve, no el contenido crudo:
```
URL probada: ...
Sesión: ...
Acciones realizadas: ...
Artefactos: .local/screenshots/...-viewport.png, .../...-full.png, .../...-snapshot.md
Errores de consola: {cantidad, o "ninguno"}
Hallazgos: {bugs de UX/funcionalidad si los hay}
Estado de la sesión: cerrada / falló el cierre (requiere kill-all)
```
## 9. Limpieza y recuperación
- Cierra la sesión siempre, incluso si algo falló antes.
- Si el cierre mismo falla, ejecuta `npx @playwright/cli kill-all` como recuperación.
- Si hay sospecha de sesiones huérfanas de una corrida anterior, revisa con `npx @playwright/cli list`
antes de abrir una nueva con el mismo nombre.
## Qué NO hacer
- No pases `--headed` ni intentes abrir una GUI: este entorno no tiene display.
- No vuelques snapshots de DOM, HTML o contenido de screenshots en el texto de respuesta — guarda a
disco y reporta la ruta.
- No dejes una sesión abierta al terminar, ni siquiera cuando algo falló.
- No interactúes con refs sin un snapshot fresco después de cambios en el DOM.
- No instales dependencias de sistema (`--with-deps` / `sudo apt`) de forma preventiva — solo como
fallback automático ante un error concreto de librerías faltantes (ver sección 1).
+190
View File
@@ -0,0 +1,190 @@
"""Fase 2: sanitiza secretos reales en las fuentes de contenido para autoria de seeds
(5 SKILL.md, 7 agentes de ~/.claude/agents/, 66 planes de ~/.claude/plans/) antes de
que ningun subagente developer los lea para escribir ejemplos de entrenamiento.
Corre localmente (no requiere GPU ni el modelo). Usa detect-secrets (entropia alta,
formatos conocidos AWS/JWT/etc.) mas una lista de regex explicitas para los patrones
ya conocidos de este proyecto (SPARK_PASSWORD, GMAIL_APP_PASSWORD, el bearer token
reusado, emails reales, IPs/hostnames internos de spark). La sustitucion es estable:
el mismo secreto real siempre produce el mismo placeholder, via un diccionario hash
persistido localmente en data/raw/.secrets_map.json (gitignoreado, solo para debug).
Salida: data/raw/sanitized/{skills,agents,plans}/... (misma estructura relativa).
Gate: termina con exit code != 0 si detect-secrets o las regex explicitas encuentran
algo en la salida YA sanitizada -- falla cerrado, no adivina reemplazos para
patrones no reconocidos.
"""
import hashlib
import json
import re
import sys
from pathlib import Path
from detect_secrets.core import scan
REPO_ROOT = Path(__file__).resolve().parent.parent
HOME = Path.home()
SOURCES = [
(HOME / ".claude" / "skills", "*/SKILL.md", "skills"),
(HOME / ".claude" / "agents", "*.md", "agents"),
(HOME / ".claude" / "plans", "*.md", "plans"),
]
OUTPUT_ROOT = REPO_ROOT / "data" / "raw" / "sanitized"
SECRETS_MAP_PATH = REPO_ROOT / "data" / "raw" / ".secrets_map.json"
# Patrones explicitos conocidos de este proyecto. Cada tupla es
# (nombre_semantico, regex compilado). El grupo de captura 0 completo es lo que
# se reemplaza; si el patron tiene grupos, se reemplaza el match completo igual
# (el placeholder no intenta preservar texto circundante).
EXPLICIT_PATTERNS = [
("SPARK_PASSWORD", re.compile(r"\b01140102Alb\?")),
("BEARER_TOKEN", re.compile(r"\b7c1f76a62391a47941d7aab8369eb8f20334daf136ba88080815ff3070773a1f\b")),
("DB_PASSWORD", re.compile(r"\bsarh21234\b")),
("SPARK_IP", re.compile(r"\b10\.212\.133\.200\b")),
("INTERNAL_IP", re.compile(r"\b10\.212\.133\.\d{1,3}\b")),
("INTERNAL_SUBNET", re.compile(r"\b10\.212\.133\.0/24\b")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("GMAIL_APP_PASSWORD_LITERAL", re.compile(r"\bGMAIL_APP_PASSWORD=[^\s\"']+")),
]
# Nombres de variables de entorno que NUNCA deben aparecer con un valor real
# asignado literalmente (uso de la variable en si, p.ej. "$SPARK_PASSWORD", esta bien).
ENV_VAR_NAMES = ["SPARK_PASSWORD", "GMAIL_APP_PASSWORD"]
PLACEHOLDER_COUNTS = {}
SECRETS_MAP = {}
def load_secrets_map():
global SECRETS_MAP
if SECRETS_MAP_PATH.exists():
SECRETS_MAP = json.loads(SECRETS_MAP_PATH.read_text())
def save_secrets_map():
SECRETS_MAP_PATH.parent.mkdir(parents=True, exist_ok=True)
SECRETS_MAP_PATH.write_text(json.dumps(SECRETS_MAP, indent=2, ensure_ascii=False))
def stable_placeholder(semantic_name, raw_value):
"""Mismo secreto real -> mismo placeholder, vía un hash estable persistido."""
digest = hashlib.sha256(raw_value.encode("utf-8")).hexdigest()[:8]
if digest not in SECRETS_MAP:
PLACEHOLDER_COUNTS[semantic_name] = PLACEHOLDER_COUNTS.get(semantic_name, 0) + 1
placeholder = f"<<{semantic_name}_{PLACEHOLDER_COUNTS[semantic_name]}>>"
SECRETS_MAP[digest] = {"placeholder": placeholder, "semantic_name": semantic_name}
return SECRETS_MAP[digest]["placeholder"]
def apply_explicit_patterns(text):
for semantic_name, pattern in EXPLICIT_PATTERNS:
def _sub(match, semantic_name=semantic_name):
return stable_placeholder(semantic_name, match.group(0))
text = pattern.sub(_sub, text)
return text
def detect_secrets_scan(text):
"""Corre los plugins default de detect-secrets sobre el texto linea por linea.
Devuelve la lista de (line_number, secret_value) encontrados."""
findings = []
plugins = list(scan.get_plugins())
for lineno, line in enumerate(text.splitlines(), start=1):
for plugin in plugins:
try:
results = plugin.analyze_line(filename="<sanitize>", line=line, line_number=lineno)
except Exception:
continue
if results:
for secret in results:
raw = getattr(secret, "secret_value", None)
if raw:
findings.append((lineno, raw))
return findings
def sanitize_text(text):
text = apply_explicit_patterns(text)
# Segunda pasada: detect-secrets sobre el resultado de las regex explicitas,
# para capturar cualquier secreto de alta entropia no cubierto arriba.
for lineno, raw in detect_secrets_scan(text):
placeholder = stable_placeholder("GENERIC_SECRET", raw)
text = text.replace(raw, placeholder)
return text
def gate_check(text, relpath):
"""Falla cerrado: si sobrevive un patron conocido o detect-secrets encuentra
algo en la salida YA sanitizada, es un error del gate."""
problems = []
for semantic_name, pattern in EXPLICIT_PATTERNS:
if pattern.search(text):
problems.append(f"{relpath}: patron explicito '{semantic_name}' sobrevivio la sanitizacion")
for env_var in ENV_VAR_NAMES:
if re.search(rf"\b{env_var}\s*=\s*[^\s$\"'][^\s]*", text):
problems.append(f"{relpath}: posible asignacion literal de {env_var} sobrevivio")
residual = detect_secrets_scan(text)
if residual:
for lineno, raw in residual:
problems.append(f"{relpath}:{lineno}: detect-secrets encontro un secreto residual")
return problems
def collect_files():
files = []
for base_dir, glob_pattern, bucket in SOURCES:
if not base_dir.exists():
print(f"[WARN] fuente no encontrada: {base_dir}")
continue
for path in sorted(base_dir.glob(glob_pattern)):
if path.is_file():
files.append((path, bucket))
return files
def main():
load_secrets_map()
files = collect_files()
print(f"[INFO] {len(files)} archivos fuente encontrados")
all_problems = []
written = 0
for path, bucket in files:
raw_text = path.read_text(encoding="utf-8")
sanitized_text = sanitize_text(raw_text)
if bucket == "skills":
relpath = Path(bucket) / path.parent.name / path.name
else:
relpath = Path(bucket) / path.name
problems = gate_check(sanitized_text, relpath)
all_problems.extend(problems)
out_path = OUTPUT_ROOT / relpath
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(sanitized_text, encoding="utf-8")
written += 1
save_secrets_map()
print(f"[INFO] {written} archivos sanitizados escritos en {OUTPUT_ROOT}")
total_subs = sum(PLACEHOLDER_COUNTS.values())
print(f"[INFO] {total_subs} secretos unicos sustituidos: {PLACEHOLDER_COUNTS}")
if all_problems:
print(f"[GATE FAIL] {len(all_problems)} problemas encontrados en la salida sanitizada:")
for problem in all_problems:
print(f" - {problem}")
sys.exit(1)
print("[GATE OK] 0 secretos sobrevivientes detectados en la salida sanitizada")
sys.exit(0)
if __name__ == "__main__":
main()