SLOPSHOPPER

mascota

Una mascota kaomoji por repo en la línea de estado: crece con tus commits y tests en verde, se enferma con builds rotos

newpanecommandtoaststatusprocess
★ 1v0.1.0NOASSERTIONupdated 2026-10-05Emaleo0522/claude-vibecoding/mods/mascota
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mascota
│ ┃ mascota ✕ › fix the failing auth test and add an audit log call │ ┃ Todavía no hay mascota. │ ┃ [ cerrar ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mascota │ ⎿ mascota: Todavía no hay mascota: nace en el primer repo donde co │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mascota
Todavía no hay mascota. [ cerrar ]
README

Claude Vibecoding

Hay personas con ideas brillantes que nunca llegan a hacerse — no por falta de talento, sino porque la distancia entre imaginar algo y tenerlo online es demasiado grande.

Si alguna vez dejaste morir una idea porque no sabías cómo empezar, este sistema es para vos. El conocimiento técnico ya estaba en el mundo — solo faltaba un puente para llegar a él.


Un sistema multiagente para construir software de principio a fin con Claude. Desde una landing simple hasta una app con auth + base de datos. Te acompaña en cada decisión clave, sin pedirte permiso para cada coma.

License: PolyForm Noncommercial 1.0.0 Status Platforms

Read this in English: README.en.md


⚠️ Uso no comercial. Este software está bajo PolyForm Noncommercial License 1.0.0. Uso personal, educativo, investigación y organizaciones sin fines de lucro permitidos sin restricción. Cualquier uso comercial requiere licencia separada — contactar a emaleo0522@gmail.com.


¿Esto es para mí?

Hay dos perfiles típicos que sacan provecho del sistema. Si te identificás con alguno, seguí leyendo.

Si nunca programaste y tenés una idea que querés ver hecha (la landing de tu emprendimiento, una app para tu equipo, un juego para regalar a tu sobrina), este sistema te lleva de la idea al deploy. Vos describís qué querés en español natural; el sistema te pregunta lo que necesita saber con opciones múltiples, y al final tenés un proyecto online. No tenés que aprender a programar para empezar a usarlo.

Si sos developer y estás cansado de hacer las mismas tareas repetitivas (setup, scaffolding, QA visual, headers de seguridad, SEO, deploy), este sistema te las quita de encima. Vos te quedás con las decisiones que importan; el resto lo hacen 25 agentes especializados que se coordinan entre sí.

En ambos casos, el sistema te pregunta cuando hay decisiones interpretables (visual, multi-opción, irreversible) y decide solo cuando la respuesta es única. No te quema con un "¿estás seguro?" por cada commit, pero tampoco te deja por fuera de lo importante.


Qué podés construir

Tipo de proyectoEjemplo concretoStack que el sistema usa
Landing / sitio públicoPágina de un restaurante, portfolio, lanzamiento de productoAstro (content-heavy, 0 JS por default) o Vite + React + Tailwind
Web app con authDashboard, CRM, SaaS MVP, panel de adminNext.js + Better Auth + Drizzle + PostgreSQL
App móvil iOS + AndroidDelivery, fitness tracker, app de tu negocioReact Native + Expo SDK 52+
Juego de navegadorPlataformero 2D, puzzle, arcadePhaser.js o PixiJS
API / backendEndpoints REST/tRPC, webhooks, jobsHono + Drizzle + PostgreSQL
Full-stack completoProducto entero con frontend + backend + auth + DBCombinación según necesidad

El stack no está fijo: el orquestador decide en la primera fase según lo que pidas. Para una landing simple no monta una arquitectura de microservicios; para una app multi-tenant no te entrega una página HTML.


Instalación

Requisitos previos

PlataformaLo que necesitás antesDónde bajarlo
Linux + Claude CodeClaude Code CLI, git, Node.jsClaude Code
Windows + Claude DesktopClaude Desktop, Git for Windows (trae Git Bash), Node.jsClaude Desktop · Git · Node.js

Importante: este sistema extiende a Claude — no lo reemplaza. Si no tenés Claude Code (Linux) o Claude Desktop (Windows) instalado, los agentes y hooks no se ejecutan en ningún lado.

Linux (Claude Code) — 30 segundos

Abrí una terminal y corré:

git clone https://github.com/Emaleo0522/claude-vibecoding.git
cd claude-vibecoding
bash install/linux.sh

El script instala los 25 agentes + 24 referencias técnicas (incluida external-skills-reference para el ecosistema npx skills add) + 1 índice central (AGENTS.md), los 13 hooks + 6 utilities manuales, el CLAUDE.md global, y configura git/GitHub/Vercel. Te va preguntando los datos que necesita (tu nombre, email, usuario de GitHub). Reiniciá Claude Code cuando termine y ya estás listo.

Windows (Claude Desktop) — 20-30 minutos guiados

Abrí Git Bash (se instala con Git for Windows) y corré:

git clone https://github.com/Emaleo0522/claude-vibecoding.git
cd claude-vibecoding

Después seguí la guía paso a paso en install/windows.md. Te lleva desde cero hasta tener todo funcionando, incluyendo descargar el binario de Engram (la memoria persistente del sistema) que en Windows requiere un paso extra.

Otros entornos (Cursor, Aider, Codex CLI, Claude API directa, otros LLMs)

El sistema está formalmente soportado en Claude Code (Linux) y Claude Desktop (Windows). Si querés usarlo con otro runtime o IDE (Cursor, Aider, Codex CLI, llamadas directas a la Claude API, otros modelos), tenés que adaptarlo:

  • Los hooks (interceptores de tool calls) son específicos del runtime de Claude Code/Desktop. En otros entornos vas a necesitar otro mecanismo equivalente (extensión de IDE, wrapper de CLI, etc.) o desactivar la parte reactiva.
  • Engram (la memoria persistente) corre como MCP, lo que requiere que tu runtime soporte MCPs. Si no, podés sustituirlo por archivos JSON en disco con menor robustez.
  • Los agentes son archivos .md con frontmatter YAML. La sintaxis de subagentes es específica de Claude Code. Para usarlos como prompts en otro runtime, vas a necesitar adaptar el formato.

Si te animás a portarlo, abrí un issue o PR contando qué runtime estás usando — la idea es ir armando una guía colaborativa de portabilidad. No prometemos soporte oficial fuera de Claude Code/Desktop, pero la arquitectura es lo bastante modular como para que sea viable.

Verificación post-instalación

# Agentes (debería ser 52 o más: 25 agentes + 24 referencias técnicas + agent-protocol.md + AGENTS.md + PIPELINE-AGENTS.md)
ls ~/.claude/agents/*.md | wc -l

# Hooks (debería ser 20: 13 reactivos + 7 utilidades/scripts .js/.sh)
ls ~/.claude/hooks/ | wc -l

# CLAUDE.md presente en ~
head -3 ~/CLAUDE.md

# Health check unificado (recomendado): audit + drift + MCP registry + Engram en un comando
node ~/.claude/hooks/healthcheck.js

# Chequeos individuales:
node ~/.claude/hooks/audit-system.js          # catálogo de agentes, hooks, settings, protocolo
node ~/.claude/hooks/drift-check.js            # repo ↔ ~/.claude en sync (por hash)
node ~/.claude/hooks/mcp-registry.js           # inventario de MCPs por estado

Tu primer proyecto en 5 minutos

Abrí Claude Code y escribí:

modo orquestador — quiero crear una landing para mi cafetería de especialidad

Lo que pasa a continuación:

  1. El sistema te hace 6 preguntas con opciones múltiples (tipo de proyecto, industria, estilo visual, referencia opcional, originalidad, audiencia). Tarda 1-2 minutos. No podés saltarlas con "decidí vos" — la pregunta 3 (estilo visual) y 5 (originalidad) son obligatorias. Esto evita que el output salga genérico.
  2. Genera el plan: lista de tareas con criterios de aceptación, en español.
  3. Diseña la arquitectura (CSS tokens, paleta, tipografía, layout) y te muestra un checkpoint visual con 8 decisiones interpretables (hero, navegación, mood, animaciones, efectos). Vos elegís o aceptás las recomendaciones.
  4. Genera assets visuales (paleta de marca, logo SVG, imagen del hero, video opcional de fondo) — con tu aprobación antes de gastar créditos en APIs de IA.
  5. Implementa cada tarea con un loop dev → QA visual (Playwright a 3 viewports) → reintento si falla. Hasta 3 intentos por tarea.
  6. Certifica SEO, performance (Core Web Vitals), accesibilidad, security headers, antes de declarar "listo".
  7. Te muestra el resultado, y si das OK, hace commit + push + deploy a Vercel.

Al final tenés un repo en GitHub, una URL pública en Vercel, y un proyecto que ya pasó por 4 capas de QA. El proceso completo dura entre 20 minutos (landing simple) y 4-6 horas (web app full-stack con auth).

Otros prompts útiles

retomar mi-cafeteria — agrega un blog con markdown
retomar mi-cafeteria — cambia la paleta a tonos más oscuros
modo orquestador — app mobile de delivery con React Native
modo orquestador — juego 2D tipo plataformas en el navegador

Cómo funciona (resumen)

El sistema tiene un orquestador central que coordina 24 subagentes especializados, cada uno con una responsabilidad acotada. El orquestador nunca hace trabajo real — solo delega y junta resultados.

Pipeline de 5 fases

Fase 1  Planificación  →  Intent Clarifier (6 preguntas) + project-manager-senior
Fase 2  Arquitectura   →  ux-architect (CSS tokens) + ui-designer + security-engineer
                          ↳ Visual Direction Checkpoint (decidís estilo)
Fase 2B Assets visuales →  brand-agent + logo-agent + image-agent + video-agent
Fase 3  Dev ↔ QA       →  frontend-developer, backend-architect, etc. ↔ evidence-collector
Fase 4  Certificación  →  seo-discovery + api-tester + performance-benchmarker + reality-checker
                          ↳ Paso 4.5 No-JS Render Audit: valida que el HTML inicial sin JS sirva a Bing, scrapers de LLMs y previews sociales
Fase 5  Publicación    →  git (con tu confirmación) + deployer (con tu confirmación)

Para modificar un proyecto que ya está hecho, el sistema entra en modo modificación: corre un Paso 0 de auditoría sobre el código heredado (detecta defaults problemáticos antes de tocar nada), después solo ejecuta los agentes afectados por el cambio.

Lo que te protege en el camino

  • 13 hooks bloquean cosas peligrosas en tiempo real: git --no-verify, git push --force, rm -rf, DROP TABLE, chmod 777, edición de archivos secretos (.env, claves privadas), uso de --no-gpg-sign. Otros avisan: debugger o console.log en código de producción, @ts-ignore, animaciones excesivas, container CSS con cap "SaaS feel", fuentes declaradas sin cargar, navegación móvil sin hamburger. Otros corren en background: cost tracking, session logging, sync de Engram local→GitHub y local→cloud al cerrar sesión, snapshot pre-compact. Más 6 utilities manuales que ejecutás con node cuando los necesitás: healthcheck.js (estado del sistema en un comando: agrega audit + drift + MCP registry + Engram con veredicto READY/NOT READY), audit-system.js (health check del catálogo), drift-check.js (detecta si tu copia viva ~/.claude/ se desincronizó del repo, por hash), mcp-registry.js (inventario de MCPs por estado, leído de mcp.registry.json), cost-report.js y learning-index.js.
  • AUTO_AUDIT pre-return: antes de devolver código, el frontend-developer corre 5 reglas grep ejecutables (no paleta teal por default, no Inter como heading en moods bold, hero con media coherente, motion según dial, shadow según mood). Si falla → regenera. Si pasa → marca cambio como VISUAL_IMPACT: high|medium|low.
  • Checkpoint humano automático: cuando el cambio tiene VISUAL_IMPACT: high, el orquestador te muestra el resultado antes de marcar la tarea como completa. La doctrina: el agente decide solo cuando hay UNA respuesta correcta; en todo lo demás (visual, multi-opción, irreversible, iterado 2+ veces) te pregunta con su recomendación incluida.
  • 12 capas de defensa anti-falso-positivo en QA: visual fidelity LLM-as-judge (5 dimensiones contra referencia), network inspection (Mixed Content, status 0, leaks de localhost), E2E flows obligatorios en auth/CRUD, reality-checker re-corre 2-3 PASS al azar, TDD evidence trail opt-in (RED→GREEN→TRIANGULATE→REFACTOR cuando hay test_commands), cache hash de archivos en reintentos (skip QA si todos los archivos tocados tienen hash idéntico al último PASS, ahorra ~80% de tokens en reintentos sin cambio real), No-JS Render Audit en Fase 4 (Playwright con JS apagado mide qué contenido sobrevive — bloquea landings/blogs/ecommerce que serían invisibles a Bing/LLM scrapers/previews sociales).
  • Delegation Stop Rules cuantificados: umbrales explícitos para escalar (5+ archivos leídos consecutivos → delegar a Explore, 20+ tool calls sin spawn → pausar, 2+ archivos no-triviales en una tarea → fresh review). Adaptado de gentle-ai.
  • Simplicity First en outputs (2026-05-26): toda respuesta arranca con TL;DR de 1-3 oraciones que resuelve la pregunta directa; si la respuesta natural se acaba ahí, termina ahí. Sube a estructura (tablas, secciones) solo si pediste análisis/comparación/plan o hay ≥3 ítems comparables. Baja a prosa simple si pediste "resumen", "corto", "rápido", "en palabras sencillas". Anti-patterns: headers ## para parecer estructurado en respuestas chicas, tablas con 2 filas, iniciar con análisis antes de la conclusión directa. Detalle: agents/simplicity-first-reference.md.
  • Architecture Decision Records (ADR) en Engram (2026-05-24): cada vez que se analiza un patrón/paradigma externo (paper, librería, sistema ajeno) vs el sistema vibecoding, la decisión queda persistida en Engram con prefix claude-vibecoding/architecture-review/{YYYY-MM-DD}-{slug}. Estados: ADOPTADO / DIFERIDO / RECHAZADO / MITIGACIÓN PARCIAL. El self-auditor T9 lee estas observations y reporta drift (decisión adoptada cuyo cambio desapareció), overdue (re-evaluación vencida) o reconsidered (patrón rechazado que reaparece). Evita re-analizar lo mismo + detecta cuando algo adoptado se deshizo silenciosamente.

Configuración

Engram MCP (memoria persistente) — obligatorio

Engram es lo que hace que el sistema recuerde entre sesiones. Sin él, cada conversación arranca de cero. Se instala automáticamente con el script de Linux y con la guía de Windows.

Engram Cloud (memoria cross-machine) — recomendado

Si trabajás desde varias computadoras y querés que las memorias se crucen entre PCs en tiempo real (no al final de sesión), Engram tiene un modo cloud self-hosted. La instancia oficial corre en un VPS Oracle Cloud propio del autor con allowlist por proyecto.

El hook engram-cloud-sync-on-stop (incluido) empuja al cloud al cerrar sesión, y el cliente Engram local pull-ea automáticamente al iniciar la siguiente sesión. El servidor mantiene la fuente de verdad.

Para auto-hospedar tu propio cloud (recomendado para uso real):

  1. Aprovisioná un VPS (Oracle Always Free Tier alcanza).
  2. Cloná Gentleman-Programming/engram y levantá docker compose up -d cloud.
  3. En /opt/engram-cloud/.env configurá ENGRAM_CLOUD_ALLOWED_PROJECTS=mi-proyecto,personal,… (allowlist explícita para evitar bucket explosion).
  4. Apuntá el client local a tu URL: engram cloud configure --url https://TU-VPS:PUERTO.
  5. Para cada proyecto que quieras sincronizar: engram cloud enroll <project-name>.

Reglas clave (validadas en producción 2026-05-15):

  • Todos los mem_save cross-PC deben usar scope="personal" + project= explícito. El auto-detect del MCP routea a buckets distintos según el cwd del cliente y rompe el cruce. Ver el protocolo "guarda en engram" completo en CLAUDE.md.
  • Para agregar un bucket nuevo al cloud: SSH al server, editar .env, docker compose up -d cloud. Sin allowlist explícita el server retorna 403.

Cross-Claude Mailbox Protocol — opt-in para uso multi-PC

Si trabajás en 2+ PCs con instancias separadas de Claude (típicamente Linux + Windows), podés activar un canal asíncrono entre ellas vía un bucket dedicado de Engram cloud (cross-claude-mailbox). Una instancia deja un mensaje (mailbox/from-{origen}/to-{destino}/{ts}-{slug}), la otra lo lee la próxima vez que la despertás.

Es opt-in: no se chequea por default en cada turn (ahorra ~3-5k tokens/día en sesiones que no coordinan cross-PC). Para activarlo en una sesión, decile a Claude "chequeá el mailbox" o "¿hay mensajes de pc004?". Diseño completo (schemas query/reply, flujo checks vs edits con confirmación, anti-patrones): ver sección Cross-Claude Mailbox Protocol en CLAUDE.md.

Reglas clave:

  • Lecturas/greps/doctor → el Claude destinatario auto-procesa y responde.
  • Edits/Bash mutating/SSH → NO auto-aplicar, escalar al usuario primero.
  • SSH al server productivo requiere autorización LITERAL EXPLÍCITA del usuario ("sí hacé el SSH"), no un "OK dale" genérico.

Engram Sync (legacy, git) — opcional

Antes de Engram Cloud, el sync cross-PC se hacía empujando ~/.engram/ a un repo privado de GitHub. Sigue funcionando si preferís un setup más simple sin VPS, pero es eventually consistent (solo cruza al cerrar sesión) y requiere resolver conflictos a mano si dos PCs escriben en paralelo.

# 1. Creá un repo privado en GitHub (ej: mi-engram-sync)
# 2. Inicializalo en ~/.engram/:
cd ~/.engram && git init && git remote add origin https://github.com/TU_USUARIO/mi-engram-sync.git
# Desde 2026-07-22 no hay hook que empuje este repo: hacé commit y push a mano.
# Recomendado: Engram Cloud (sección anterior), que el hook engram-cloud-sync-on-stop sincroniza solo.

Variables de entorno para assets generativos — política free-first

🆕 Actualizado 2026-05-18 — política free-first verificada con curl real contra fuentes primarias, NO con blogs de marketing que reciclan fechas. Con solo HF_TOKEN ya podés generar imágenes y logos sin tarjeta de crédito.

El sistema prioriza paths FREE top-tier que no requieren tarjeta. Las opciones pagas son opt-in.

Hay un archivo .env.example en la raíz del repo con todos los campos comentados y links de signup.

Stack free real (ningún provider requiere tarjeta)
VariableServicioQuota freeCómo obtenerla
HF_TOKEN ⭐ primarioHuggingFace Inference$0.10/mes (~150 imgs FLUX-schnell), reset mensualhuggingface.co/settings/tokens → token role Read
CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_AI_TOKEN secundarioCloudflare Workers AI10,000 neurons/día sin tarjeta (cientos de imgs/día)Setup en 3 pasos abajo ⬇️
sin variablePollinations.aiFLUX unlimited free (FAQ oficial)No requiere key — fallback automático

Con solo HF_TOKEN el sistema funciona. Si agregás Cloudflare, multiplicás la quota gratis. Pollinations es el safety net automático cuando todo lo demás se agota.

Setup Cloudflare Workers AI (3 minutos, sin tarjeta)
  1. Signup: dash.cloudflare.com/sign-up — el plan Free Workers NO pide tarjeta (fuente oficial)
  2. Account ID: en el dashboard, scrolleá el sidebar derecho hasta la sección "API" — copialo (32 chars hex)
  3. API Token: dash.cloudflare.com/profile/api-tokens → "Create Custom Token" → permiso Account → Workers AI → Read → "Continue to summary" → "Create Token" (copialo, se muestra una sola vez)
Opt-in paga (solo si tenés billing habilitado)
VariableServicioCostoCuándo usarlo
GEMINI_API_KEYGoogle AI Studio$0.02-0.04/img + billingMejor comprensión LLM-nativa de prompts. Requiere billing habilitado en Google Cloud
REPLICATE_API_TOKENReplicate$0.03-0.10/videoSolo para video real (LTX-Video 2.3). Sin esta variable, video-agent retorna CSS fallback animado como output válido (replicate.com/account/api-tokens)
RECRAFT_API_KEYRecraft V4 Vector$0.08/img + $5 free/mes via Vercel AI GatewayLogos SVG nativos (sin pérdida raster→vector). Solo si querés logos vectoriales premium
Dónde poner las variables
  • Linux/macOS: agregalas a ~/.bashrc o ~/.zshrc con export VAR=valor, o crealas en ~/.claude/.env (una por línea: VAR=valor)
  • Windows: setx VAR "valor" en PowerShell (persiste user-level), o panel de control → Variables de entorno del sistema. Cerrá y reabrí Claude Desktop para que las tome.

Pixel Bridge — opcional, decorativo

Una oficina pixel art donde los agentes caminan a sus escritorios cuando se les asigna una tarea, reportan al orquestador, y descansan cuando no hay trabajo. Puramente visual, no afecta el pipeline. Te lo ofrece el instalador.


Los 4 modos de trabajo

ModoCuándo usarloCómo activarlo
Claude normalPreguntas, fixes puntuales, revisar código, chat técnicoDefault — solo hablar
OrquestadorProyecto completo de principio a finDecí: "modo orquestador — [tu idea]" o "activa el pipeline"
ModificaciónCambios sobre un proyecto ya completado por el pipelineDetectado automáticamente cuando decís "retomar [proyecto] — [cambio]"
DiagnósticoAuditar código existente sin tocarlo (due diligence, audits de proyectos ajenos)Decí: "modo diagnóstico", "audita este código", "evalúa sin tocar"

En modo normal, Claude responde como siempre pero no trabaja pelado: además de los hooks, AUTO_AUDIT y memoria, alcanza por reflejo los mismos subagentes y referencias que el pipeline cuando la tarea lo amerita —un Explore para entender código sin llenar el contexto, un agente especializado para auth/SEO/deploy, una referencia técnica antes de trabajo pesado—, sin pedirte permiso para usarlos. En modo orquestador, adopta el rol de coordinador y delega a los 24 subagentes. En modo modificación, corre un mini-pipeline (Paso 0 de auditoría → planificación ligera → dev+QA solo de los agentes afectados). En modo diagnóstico es read-only por doctrina: solo lee y devuelve un reporte estructurado con hallazgos por severidad — útil para auditar proyectos que no fueron generados por este sistema (due diligence, code review de repos ajenos).


Documentación técnica completa

Para developers que quieran ir más allá:

ArchivoPara qué
agents/PIPELINE-AGENTS.mdTabla de los 25 agentes organizada por fase del pipeline con descripción 1-línea de cada uno + link al .md completo. Bilingüe (es+en). Referencia para humanos, no se carga al boot
agents/AGENTS.mdÍndice central de las 24 referencias técnicas con triggers de carga y skip conditions. El orquestador lo consulta en Fase 1 Paso 0b para decidir qué refs aplicar por proyecto (evita carga indiscriminada)
agents/orquestador.mdComportamiento completo del orquestador: detección de modos, pipeline detallado, DAG State, fallbacks
agents/agent-protocol.mdProtocolo compartido entre subagentes: Engram (2 pasos), Return Envelope, VISUAL_IMPACT, Delegation Stop Rules, reglas universales
agents/pipeline-reference.mdDetalles de cada fase, tools por agente, stack adaptable, Design Intelligence Engine
agents/external-skills-reference.mdSkills externas via npx skills add — whitelist curada, opt-in en Fase 3, no contamina boot
CLAUDE.mdEl CLAUDE.md que se instala — toda la doctrina del sistema (Checkpoint humano, Engram, hooks, mod mode)

| agents/ux-architect.md | Tokens de diseño, container strateg

Source 3 files
hooks/register.tsx 325 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { applyEvent, commandDir, newCommits, normalizePet, paneLines, pickNotice, statusLine, validName, verdict, type Notice, type Pet } from './pet'
5
6const PANE = 'mascota'
7const GIT_TIMEOUT_MS = 3_000
8const CHECK_EVERY_MS = 30_000
9const STARTUP_DELAY_MS = 2_000
10const REFRESH_MS = 10 * 60_000
11const BLINK_MS = 700
12const LOG_LIMIT = '50'
13
14type Repo = { key: string; label: string; top: string }
15
16// Todo lo de la sesión vive en atoms: nada se comparte entre sesiones (ni entre pruebas).
17const shown = atom({ plugin: 'mascota', key: 'shown' } as const, null as string | null)
18const pending = atom({ plugin: 'mascota', key: 'pending' } as const, null as Notice | null)
19const touched = atom({ plugin: 'mascota', key: 'touched' } as const, [] as string[])
20const repos = atom({ plugin: 'mascota', key: 'repos' } as const, {} as Record<string, Repo>) // por clave
21const dirs = atom({ plugin: 'mascota', key: 'dirs' } as const, {} as Record<string, string | null>) // carpeta → clave
22const checked = atom({ plugin: 'mascota', key: 'checked' } as const, {} as Record<string, number>)
23const paneOpen = atom({ plugin: 'mascota', key: 'paneOpen' } as const, false)
24const frame = atom({ plugin: 'mascota', key: 'frame' } as const, 0)
25
26// Cola: los cambios al store se aplican de a uno (subagentes en paralelo no se pisan).
27const mod = {
28  queue: Promise.resolve() as Promise<void>,
29  /** Cancela el timer de parpadeo del panel (solo existe con el panel abierto). */
30  stopBlink: null as (() => void) | null,
31}
32
33const norm = (p: string) => {
34  const f = p.trim().replace(/\\/g, '/').replace(/\/+$/, '')
35  return /^[A-Za-z]:/.test(f) ? f.toLowerCase() : f
36}
37function labelOf(common: string): string {
38  const f = common.trim().replace(/\\/g, '/').replace(/\/+$/, '')
39  const parts = f.split('/')
40  const lastPart = parts[parts.length - 1] ?? ''
41  if (lastPart === '.git') return parts[parts.length - 2] || 'repo'
42  return lastPart.replace(/\.git$/, '') || 'repo'
43}
44const storeKey = (key: string) => `repo:${key}`
45
46async function git($: EngineInterface, argv: string[]): Promise<string | null> {
47  const r = await $.process.run(['git', ...argv], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => null)
48  return r && r.exitCode === 0 ? r.stdout : null
49}
50
51const safe = (p: Promise<string | undefined>) => p.catch(() => undefined)
52
53/** Sin variables de entorno no se rompe nada: solo no se expande `~`. */
54async function homeOf($: EngineInterface): Promise<{ home: string; isWindows: boolean }> {
55  const isWindows = (await safe($.env.get('OS'))) === 'Windows_NT'
56  // En Windows HOME puede venir en formato POSIX (/c/Users/...): primero USERPROFILE.
57  const home = isWindows
58    ? ((await safe($.env.get('USERPROFILE'))) ?? (await safe($.env.get('HOME'))) ?? '')
59    : ((await safe($.env.get('HOME'))) ?? (await safe($.env.get('USERPROFILE'))) ?? '')
60  return { home: home.replace(/\\/g, '/'), isWindows }
61}
62
63/** La carpeta → su repo (clave = git-common-dir, así un worktree comparte mascota). */
64async function resolveRepo($: EngineInterface, cwd: string): Promise<Repo | null> {
65  const dir = norm(cwd)
66  const known = (await read($, dirs))[dir]
67  if (known === null) return null
68  if (known !== undefined) return (await read($, repos))[known] ?? null
69  const out = await $.process.run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
70  if (out === undefined) return null // error del motor: no se cachea
71  if (out.exitCode !== 0) {
72    await update($, dirs, d => ({ ...d, [dir]: null }))
73    return null
74  }
75  const [common, top] = out.stdout.split(/\r?\n/).map(s => s.trim())
76  if (!common || !top) return null
77  // La carpeta principal del repo (no la de un worktree, que puede ser temporal de un subagente).
78  const main = common.replace(/\\/g, '/').replace(/\/+$/, '')
79  const repo: Repo = { key: norm(common), label: labelOf(common), top: main.endsWith('/.git') ? main.slice(0, -5) : top }
80  await update($, repos, r => ({ ...r, [repo.key]: repo }))
81  await update($, dirs, d => ({ ...d, [dir]: repo.key }))
82  return repo
83}
84
85/** Repo por clave: de la sesión, o del store (la mascota guarda su nombre de repo y ruta). */
86async function repoForKey($: EngineInterface, key: string): Promise<Repo | null> {
87  const r = (await read($, repos))[key]
88  if (r) return r
89  const raw = (await $.store.get(storeKey(key))) as { repo?: unknown; ruta?: unknown } | undefined
90  if (!raw) return null
91  return { key, label: typeof raw.repo === 'string' ? raw.repo : labelOf(key), top: typeof raw.ruta === 'string' ? raw.ruta : '' }
92}
93
94async function loadPet($: EngineInterface, repo: Repo, now: number): Promise<Pet> {
95  return normalizePet(await $.store.get(storeKey(repo.key)), repo.label, repo.top, now)
96}
97
98type Change = (p: Pet, now: number) => { pet: Pet; notices: Notice[]; activity?: boolean }
99
100/** Lee, aplica y guarda, de a un cambio por vez. `activity`: pasa a ser la mascota mostrada. */
101async function mutate($: EngineInterface, repo: Repo, change: Change, activity: boolean) {
102  const prev = mod.queue
103  let release = () => {}
104  mod.queue = new Promise<void>(r => { release = r })
105  await prev
106  try {
107    const now = await $.clock.now()
108    const { pet, notices, activity: found } = change(await loadPet($, repo, now), now)
109    await $.store.set(storeKey(repo.key), pet)
110    if (activity || found) {
111      await $.store.set('ultimo', repo.key)
112      await update($, shown, () => repo.key)
113    }
114    if (notices.length) await update($, pending, cur => pickNotice(cur ? [cur, ...notices] : notices))
115    if ((await read($, shown)) === repo.key) $.ui.status(statusLine(pet, now))
116  } finally {
117    release()
118  }
119}
120
121function responseText(resp: unknown): string {
122  if (typeof resp === 'string') return resp
123  if (!resp || typeof resp !== 'object') return ''
124  const r = resp as Record<string, unknown>
125  return ['stdout', 'stderr', 'output'].map(k => r[k]).filter((v): v is string => typeof v === 'string').join('\n')
126}
127
128type ToolEvent = { tool_name?: string; tool_input?: unknown; tool_response?: unknown; error?: unknown; is_interrupt?: boolean; cwd?: string }
129
130/** Un resultado de Bash/PowerShell: ¿fue un test o un build, y cómo salió? */
131async function onToolResult($: EngineInterface, e: ToolEvent, failed: boolean) {
132  if (e.tool_name !== 'Bash' && e.tool_name !== 'PowerShell') return
133  const input = (e.tool_input ?? {}) as { command?: unknown; run_in_background?: unknown }
134  if (typeof input.command !== 'string' || input.run_in_background === true) return
135  if (failed && e.is_interrupt) return
136  const resp = (e.tool_response ?? {}) as { interrupted?: unknown; backgroundTaskId?: unknown; backgroundedByUser?: unknown; timedOutAfterMs?: unknown }
137  // Interrumpido, o pasó a segundo plano (timeout, Ctrl+B, un mensaje): la salida es parcial.
138  if (!failed && (resp.interrupted === true || resp.backgroundTaskId || resp.backgroundedByUser === true || resp.timedOutAfterMs)) return
139  const v = verdict(input.command, !failed, failed ? String(e.error ?? '') : responseText(e.tool_response))
140  const counts = v.test !== undefined || v.build !== undefined
141  if (!counts && !/\bgit\b/.test(input.command)) return
142  if (!e.cwd) return
143  const repo = await resolveRepo($, commandDir(input.command, { cwd: e.cwd, ...(await homeOf($)) }))
144  if (!repo) return
145  await update($, touched, t => (t.includes(repo.key) ? t : [...t, repo.key]))
146  if (!counts) return
147  await mutate($, repo, (p, now) => {
148    let pet = p
149    const notices: Notice[] = []
150    for (const kind of ['build', 'test'] as const) {
151      const ok = v[kind]
152      if (ok === undefined) continue
153      const r = applyEvent(pet, { type: kind, ok }, now)
154      pet = r.pet
155      notices.push(...r.notices)
156    }
157    return { pet, notices }
158  }, true)
159}
160
161/** Commits propios nuevos de un repo (marca de agua por fecha de autor). */
162async function checkCommits($: EngineInterface, repo: Repo) {
163  if (!repo.top) return
164  const now = await $.clock.now()
165  const lastAt = (await read($, checked))[repo.key]
166  if (lastAt !== undefined && now - lastAt < CHECK_EVERY_MS) return
167  await update($, checked, c => ({ ...c, [repo.key]: now }))
168  const email = (await git($, ['-C', repo.top, 'config', 'user.email']))?.trim()
169  if (!email) return // sin email no se cuenta: un --author vacío coincidiría con todo
170  // <email> completo: --author busca una subcadena en "Nombre <email>" (a@b.com coincidiría con ana@b.com).
171  const out = await git($, ['-C', repo.top, 'log', '-n', LOG_LIMIT, `--author=<${email}>`, '--fixed-strings', '--format=%at%x09%s'])
172  if (out === null) return
173  const commits = out.split(/\r?\n/).map(l => /^(\d+)\t(.*)$/.exec(l)).filter((m): m is RegExpExecArray => !!m).map(m => ({ at: Number(m[1]), s: m[2] }))
174  await mutate($, repo, (p, t) => {
175    const { n, marca } = newCommits(commits.map(c => c.at), p.marcaAutor)
176    const base = { ...p, marcaAutor: marca }
177    if (n === 0) return { pet: base, notices: [] }
178    const newest = commits.filter(c => c.at > (p.marcaAutor ?? 0)).sort((a, b) => b.at - a.at)[0]
179    return { ...applyEvent(base, { type: 'commits', n, subject: newest?.s }, t), activity: true }
180  }, false)
181}
182
183async function endOfTurn($: EngineInterface) {
184  const keys = new Set(await read($, touched))
185  // Los tocados se chequean una vez; después solo la mostrada (si no, cada turno corre git en todos).
186  await update($, touched, () => [])
187  const s = await read($, shown)
188  if (s) keys.add(s)
189  for (const key of keys) {
190    const repo = await repoForKey($, key)
191    if (repo) await checkCommits($, repo).catch(() => {})
192  }
193  const n = await read($, pending)
194  if (n) {
195    $.ui.toast(n.texto)
196    await update($, pending, () => null)
197  }
198}
199
200async function refreshStatus($: EngineInterface) {
201  const key = await read($, shown)
202  if (!key) return
203  const repo = await repoForKey($, key)
204  if (!repo) return
205  const now = await $.clock.now()
206  $.ui.status(statusLine(await loadPet($, repo, now), now))
207}
208
209/** Al arrancar (sin demorar la sesión): la mascota del último repo con actividad. */
210async function startup($: EngineInterface) {
211  if (await read($, shown)) return
212  const ultimo = await $.store.get('ultimo')
213  if (typeof ultimo !== 'string') return
214  const repo = await repoForKey($, ultimo)
215  if (!repo) return
216  await update($, repos, r => ({ ...r, [repo.key]: repo }))
217  await update($, shown, () => repo.key)
218  await refreshStatus($)
219  await checkCommits($, repo).catch(() => {})
220}
221
222async function currentRepo($: EngineInterface): Promise<Repo | null> {
223  const key = (await read($, shown)) ?? (await $.store.get('ultimo'))
224  return typeof key === 'string' ? repoForKey($, key) : null
225}
226
227async function closePane($: EngineInterface) {
228  paneClosed()
229  await update($, paneOpen, () => false)
230  await $.ui.close({ id: PANE })
231}
232
233/** El parpadeo corre solo con el panel abierto. */
234function paneClosed() {
235  const stop = mod.stopBlink
236  mod.stopBlink = null
237  if (typeof stop === 'function') {
238    try {
239      stop()
240    } catch {
241      // un timer ya cancelado (p. ej. por un hot reload) no importa
242    }
243  }
244}
245
246async function runCommand($: EngineInterface, args: string) {
247  const repo = await currentRepo($)
248  if (!repo) return { text: 'Todavía no hay mascota: nace en el primer repo donde corras tests o hagas un commit.' }
249  const m = /^nombre(?:\s+([\s\S]*))?$/.exec(args.trim())
250  if (m) {
251    const name = validName(m[1] ?? '')
252    if (!name) return { text: 'El nombre tiene que tener entre 1 y 20 caracteres, en una sola línea.' }
253    await mutate($, repo, p => ({ pet: { ...p, nombre: name }, notices: [] }), true)
254    return { text: `Ahora se llama ${name}.` }
255  }
256  if (await read($, paneOpen)) {
257    await closePane($)
258  } else {
259    await update($, paneOpen, () => true)
260    paneClosed()
261    mod.stopBlink = $.clock.every(BLINK_MS, () => void blinkTick($).catch(() => {}))
262    await $.ui.open({ id: PANE, title: 'Mascota' })
263  }
264  return {}
265}
266
267async function blinkTick($: EngineInterface) {
268  if (await read($, paneOpen)) await update($, frame, f => (f + 1) % 4)
269}
270
271export const register: Register = on => {
272  on('session.start', async ($, e, next) => {
273    const result = await next(e)
274    await $.command.register({ name: 'mascota', description: 'Tu mascota del repo: /mascota abre el panel, /mascota nombre <nuevo> la renombra' })
275    $.clock.after(STARTUP_DELAY_MS, () => void startup($).catch(() => {}))
276    $.clock.every(REFRESH_MS, () => void refreshStatus($).catch(() => {}))
277    return result
278  })
279
280  // Ema cerró el panel (Esc, la cruz) o se descargó el mod: el próximo /mascota lo abre.
281  on('ui.close', async ($, e, next) => {
282    const r = await next(e)
283    if ((e as { id?: string }).id === PANE) {
284      paneClosed()
285      await update($, paneOpen, () => false).catch(() => {})
286    }
287    return r
288  })
289
290  // Solo observan: el resultado de la herramienta pasa intacto, aunque algo falle acá.
291  on('classic.PostToolUse', async ($, e, next) => {
292    const r = await next(e)
293    await onToolResult($, e as ToolEvent, false).catch(() => {})
294    return r
295  })
296  on('classic.PostToolUseFailure', async ($, e, next) => {
297    const r = await next(e)
298    await onToolResult($, e as ToolEvent, true).catch(() => {})
299    return r
300  })
301
302  on('turn.complete', async ($, e, next) => {
303    const r = await next(e)
304    // Solo el turno principal: los de subagentes no chequean commits ni avisan.
305    // En diferido: git puede tardar y el turno ya terminó.
306    if (!(e as { agentId?: string }).agentId) $.clock.after(0, () => void endOfTurn($).catch(() => {}))
307    return r
308  })
309
310  on('command.run', { command: 'mascota' }, async ($, e) => runCommand($, e.args))
311
312  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
313    const { Box, Text, Button } = $.ui.resolve(e)
314    const repo = await currentRepo($)
315    const now = await $.clock.now()
316    const lines = repo ? paneLines(await loadPet($, repo, now), now, (await read($, frame)) === 3) : ['Todavía no hay mascota.']
317    return (
318      <Box flexDirection="column">
319        {lines.map((l, i) => <Text key={`l${i}`}>{l}</Text>)}
320        <Button key="close" label="cerrar" role="dismiss" onPress={() => closePane($)} />
321      </Box>
322    )
323  })
324}
325
hooks/pet.ts 321 lines
1// Lógica pura de la mascota: etapas, XP, salud, caras y el veredicto de un
2// comando de test o build. Sin `$`: todo se prueba con datos.
3
4const DAY_MS = 86_400_000
5const SLEEP_AFTER_MS = 3 * DAY_MS
6const XP_COMMIT = 10
7const XP_TEST = 2
8const XP_CURE_BUILD = 5
9const MAX_XP_TESTS_PER_DAY = 10
10const MAX_COMMITS_PER_CHECK = 20
11const MAX_EVENTS = 10
12const SUBJECT_CHARS = 60
13
14type Stage = { name: string; from: number; faces: { bien: string; resfriada: string; enferma: string; dormida: string } }
15
16const STAGES: Stage[] = [
17  { name: 'huevo', from: 0, faces: { bien: '◯', resfriada: '◯', enferma: '◯', dormida: '◯' } },
18  { name: 'cría', from: 10, faces: { bien: '(•ᴗ•)', resfriada: '(•﹏•)', enferma: '(×_×)', dormida: '(-ᴗ-) zZ' } },
19  { name: 'joven', from: 100, faces: { bien: 'ʕ•ᴥ•ʔ', resfriada: 'ʕ•﹏•ʔ', enferma: 'ʕ×ᴥ×ʔ', dormida: 'ʕ-ᴥ-ʔ zZ' } },
20  { name: 'adulta', from: 400, faces: { bien: 'ʕ•̀ω•́ʔ✧', resfriada: 'ʕ•̀﹏•́ʔ', enferma: 'ʕ×ω×ʔ', dormida: 'ʕ-ω-ʔ zZ' } },
21  { name: 'sabia', from: 1200, faces: { bien: 'ʕ◕ω◕ʔ☆', resfriada: 'ʕ◕﹏◕ʔ', enferma: 'ʕ×ω×ʔ', dormida: 'ʕ-ω-ʔ zZ' } },
22]
23
24const NAMES = ['Bit', 'Byte', 'Nodo', 'Pixel', 'Grep', 'Tilde', 'Lambda', 'Coma', 'Bucle', 'Commit']
25
26export type Salud = 'bien' | 'resfriada' | 'enferma'
27
28export type Pet = {
29  nombre: string
30  repo: string
31  ruta: string
32  xp: number
33  commits: number
34  testsVerdes: number
35  xpTestsHoy: { dia: string; n: number }
36  buildRoto: boolean
37  testsRojos: boolean
38  salud: Salud
39  marcaAutor: number | null
40  ultimaActividad: number
41  nacio: number
42  eventos: Array<{ cuando: number; texto: string }>
43}
44
45export type PetEvent = { type: 'commits'; n: number; subject?: string } | { type: 'test'; ok: boolean } | { type: 'build'; ok: boolean }
46export type NoticeKind = 'nace' | 'etapa' | 'enferma' | 'cura'
47export type Notice = { kind: NoticeKind; texto: string }
48
49/** Nombre por defecto, estable para cada repo. */
50export function defaultName(repo: string): string {
51  let h = 0
52  for (const ch of repo) h = (h * 31 + ch.charCodeAt(0)) >>> 0
53  return NAMES[h % NAMES.length]
54}
55
56export function defaultPet(repo: string, ruta: string, now: number): Pet {
57  return {
58    nombre: defaultName(repo), repo, ruta, xp: 0, commits: 0, testsVerdes: 0,
59    xpTestsHoy: { dia: '', n: 0 }, buildRoto: false, testsRojos: false, salud: 'bien',
60    marcaAutor: null, ultimaActividad: now, nacio: now, eventos: [],
61  }
62}
63
64const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
65const deriveSalud = (p: Pick<Pet, 'buildRoto' | 'testsRojos'>): Salud => (p.buildRoto ? 'enferma' : p.testsRojos ? 'resfriada' : 'bien')
66
67/** Lo que haya en el store (vacío, viejo o roto) → una mascota válida. */
68export function normalizePet(raw: unknown, repo: string, ruta: string, now: number): Pet {
69  const d = defaultPet(repo, ruta, now)
70  if (!raw || typeof raw !== 'object') return d
71  const r = raw as Record<string, unknown>
72  const hoy = (r.xpTestsHoy ?? {}) as Record<string, unknown>
73  const p: Pet = {
74    ...d,
75    nombre: typeof r.nombre === 'string' && validName(r.nombre) ? r.nombre : d.nombre,
76    xp: num(r.xp, 0),
77    commits: num(r.commits, 0),
78    testsVerdes: num(r.testsVerdes, 0),
79    xpTestsHoy: { dia: typeof hoy.dia === 'string' ? hoy.dia : '', n: num(hoy.n, 0) },
80    buildRoto: r.buildRoto === true,
81    testsRojos: r.testsRojos === true,
82    marcaAutor: typeof r.marcaAutor === 'number' ? r.marcaAutor : null,
83    ultimaActividad: num(r.ultimaActividad, now),
84    nacio: num(r.nacio, now),
85    eventos: Array.isArray(r.eventos)
86      ? r.eventos.filter((e): e is { cuando: number; texto: string } => !!e && typeof (e as { texto?: unknown }).texto === 'string').slice(0, MAX_EVENTS)
87      : [],
88  }
89  return { ...p, salud: deriveSalud(p) }
90}
91
92export function validName(s: string): string | null {
93  const t = s.trim()
94  return t.length >= 1 && t.length <= 20 && !/[\r\n]/.test(t) ? t : null
95}
96
97function stageIndex(p: Pet): number {
98  if (p.commits === 0) return 0
99  let i = 1
100  for (let k = 1; k < STAGES.length; k++) if (p.xp >= STAGES[k].from) i = k
101  return i
102}
103
104const asleep = (p: Pet, now: number) => now - p.ultimaActividad > SLEEP_AFTER_MS
105
106export function face(p: Pet, now: number, blink = false): string {
107  const s = STAGES[stageIndex(p)]
108  const f = asleep(p, now) ? s.faces.dormida : s.faces[p.salud]
109  return blink ? f.replace(/•[̀́]?/g, '-').replace(/◕/g, '-') : f
110}
111
112const level = (xp: number) => Math.floor(xp / 50) + 1
113
114function bar(p: Pet): string {
115  const i = stageIndex(p)
116  const next = STAGES[i + 1]
117  const filled = next ? Math.floor(((p.xp - STAGES[i].from) / (next.from - STAGES[i].from)) * 5) : 5
118  const f = Math.max(0, Math.min(5, filled))
119  return '▰'.repeat(f) + '▱'.repeat(5 - f)
120}
121
122export function statusLine(p: Pet, now: number): string {
123  const who = `${p.nombre}@${p.repo}`
124  if (stageIndex(p) === 0) return `◯ ${who} · nace con el primer commit`
125  if (asleep(p, now)) return `${face(p, now)} ${who}`
126  if (p.salud === 'enferma') return `${face(p, now)} ${who} · build roto`
127  if (p.salud === 'resfriada') return `${face(p, now)} ${who} · tests en rojo`
128  return `${face(p, now)} ${who} · nv ${level(p.xp)} ${bar(p)}`
129}
130
131const HEARTS: Record<Salud, string> = { bien: '♥♥♥♥', resfriada: '♥♥♡♡', enferma: '♥♡♡♡' }
132
133/** Las líneas del panel /mascota. */
134export function paneLines(p: Pet, now: number, blink: boolean): string[] {
135  const f = face(p, now, blink)
136  const inner = 20
137  const pad = Math.max(0, inner - [...f].length)
138  const left = Math.floor(pad / 2)
139  const st = STAGES[stageIndex(p)].name
140  return [
141    `╭${'─'.repeat(inner)}╮`,
142    `│${' '.repeat(inner)}│`,
143    `│${' '.repeat(left)}${f}${' '.repeat(pad - left)}│`,
144    `│${' '.repeat(inner)}│`,
145    `╰${'─'.repeat(inner)}╯`,
146    `${p.nombre} · ${st} · nv ${level(p.xp)} · ${p.repo}`,
147    `${HEARTS[p.salud]}  ${p.commits} commits · ${p.testsVerdes} tests ✓${p.salud !== 'bien' ? ` · ${p.salud === 'enferma' ? 'build roto' : 'tests en rojo'}` : ''}`,
148    ...p.eventos.slice(0, 3).map(e => `· ${e.texto}`),
149  ]
150}
151
152/** Marca de agua por fecha de autor (segundos): primera vez no cuenta la historia; más de 20 de golpe, tampoco. */
153export function newCommits(ats: number[], marca: number | null): { n: number; marca: number } {
154  const max = ats.reduce((m, a) => Math.max(m, a), 0)
155  if (marca === null) return { n: 0, marca: max }
156  const newer = ats.filter(a => a > marca)
157  const m = Math.max(marca, max)
158  return { n: newer.length > MAX_COMMITS_PER_CHECK ? 0 : newer.length, marca: m }
159}
160
161const RANK: Record<Salud, number> = { bien: 0, resfriada: 1, enferma: 2 }
162const PRIORITY: Record<NoticeKind, number> = { nace: 4, etapa: 3, enferma: 2, cura: 1 }
163
164export function pickNotice(notices: Notice[]): Notice | null {
165  return notices.reduce<Notice | null>((best, n) => (!best || PRIORITY[n.kind] > PRIORITY[best.kind] ? n : best), null)
166}
167
168const pushEvent = (p: Pet, now: number, texto: string): Pet['eventos'] => [{ cuando: now, texto }, ...p.eventos].slice(0, MAX_EVENTS)
169const dayOf = (now: number) => new Date(now).toISOString().slice(0, 10)
170
171/** Aplica un evento; devuelve la mascota nueva y los avisos que corresponden. */
172export function applyEvent(prev: Pet, ev: PetEvent, now: number): { pet: Pet; notices: Notice[] } {
173  const egg = stageIndex(prev) === 0
174  let p: Pet = { ...prev, ultimaActividad: now }
175  if (ev.type === 'commits') {
176    if (ev.n <= 0) return { pet: prev, notices: [] }
177    const subject = (ev.subject ?? '').replace(/\s+/g, ' ').trim().slice(0, SUBJECT_CHARS)
178    const texto = ev.n === 1 ? `se comió el commit «${subject}»` : `se comió ${ev.n} commits (último: «${subject}»)`
179    p = { ...p, commits: p.commits + ev.n, xp: p.xp + XP_COMMIT * ev.n, eventos: pushEvent(p, now, texto) }
180  } else if (egg) {
181    // el huevo no se enferma ni gana XP: solo cuenta
182    if (ev.type === 'test' && ev.ok) p = { ...p, testsVerdes: p.testsVerdes + 1 }
183    return { pet: p, notices: [] }
184  } else if (ev.type === 'test') {
185    if (ev.ok) {
186      const dia = dayOf(now)
187      const hoy = p.xpTestsHoy.dia === dia ? p.xpTestsHoy : { dia, n: 0 }
188      const gana = hoy.n < MAX_XP_TESTS_PER_DAY
189      p = { ...p, testsVerdes: p.testsVerdes + 1, testsRojos: false, xp: p.xp + (gana ? XP_TEST : 0), xpTestsHoy: { dia, n: hoy.n + (gana ? 1 : 0) } }
190    } else {
191      p = { ...p, testsRojos: true }
192    }
193  } else if (ev.ok) {
194    p = { ...p, buildRoto: false, xp: p.xp + (p.buildRoto ? XP_CURE_BUILD : 0) }
195  } else {
196    p = { ...p, buildRoto: true }
197  }
198  p = { ...p, salud: deriveSalud(p) }
199
200  const notices: Notice[] = []
201  const before = stageIndex(prev)
202  const after = stageIndex(p)
203  if (before === 0 && after > 0) notices.push({ kind: 'nace', texto: `◯ → ${face(p, now)} nació ${p.nombre} en ${p.repo}` })
204  else if (after > before) notices.push({ kind: 'etapa', texto: `${p.nombre} creció: ahora es ${STAGES[after].name} ${face(p, now)}` })
205  if (RANK[p.salud] > RANK[prev.salud]) {
206    const texto = p.salud === 'enferma' ? `${p.nombre} se enfermó: build roto ${face(p, now)}` : `${p.nombre} se resfrió: tests en rojo ${face(p, now)}`
207    notices.push({ kind: 'enferma', texto })
208    p = { ...p, eventos: pushEvent(p, now, p.salud === 'enferma' ? 'se enfermó: build roto' : 'se resfrió: tests en rojo') }
209  } else if (p.salud === 'bien' && prev.salud !== 'bien') {
210    notices.push({ kind: 'cura', texto: `${p.nombre} se curó ${face(p, now)}` })
211    p = { ...p, eventos: pushEvent(p, now, 'se curó') }
212  }
213  return { pet: p, notices }
214}
215
216// ── Veredicto de un comando ─────────────────────────────────────────────
217
218const TEST_RE = /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?::[\w-]+)?(?:\s|$)|vitest(?:\s|$)|jest(?:\s|$)|playwright\s+test(?:\s|$)|pytest(?:\s|$)|python3?\s+-m\s+pytest(?:\s|$)|go\s+test(?:\s|$)|cargo\s+test(?:\s|$)|node\s+--test(?:\s|$)|claude\s+plugin\s+test(?:\s|$)|dotnet\s+test(?:\s|$))/
219const BUILD_RE = /^(?:(?:npm|pnpm|yarn|bun)\s+run\s+build(?::[\w-]+)?(?:\s|$)|(?:pnpm|yarn)\s+build(?:\s|$)|next\s+build(?:\s|$)|vite\s+build(?:\s|$)|tsc(?:\s|$)|cargo\s+build(?:\s|$)|go\s+build(?:\s|$)|dotnet\s+build(?:\s|$))/
220const WATCH_RE = /(?:^|\s)(?:--watch|-w)(?:\s|$)/
221// Invocaciones que no corren nada: no son un test ni un build.
222const NO_RUN_RE = /(?:^|\s)(?:--version|--help|--init|--showConfig|--listTests|--list|--collect-only|--co|--no-run)(?:\s|=|$)/
223const TSC_INFO_RE = /^tsc\s+(?:.*\s)?-[vh](?:\s|$)/
224const PREFIX_RE = /^(?:(?:\w+=\S*\s+)*)(?:timeout\s+\d+[smhd]?\s+|time\s+)?(?:(?:npx(?:\s+--?[\w-]+)*|pnpm\s+(?:exec|dlx)|bunx|yarn\s+dlx)\s+)?/
225
226const MARKS = {
227  test: {
228    fail: [/# fail [1-9]/, /\b[1-9]\d* (?:failed|failing)\b/i, /\b[1-9]\d* fail\b/, /Tests?:?\s+[1-9]\d* failed/i, /test result: FAILED/, /^FAIL\b/m,
229      /\b[1-9]\d* errors? in [\d.]+m?s\b/, /Errors\s+[1-9]\d* errors?\b/], // pytest "1 error in 0.3s", vitest "Errors  1 error"
230    pass: [/\b[1-9]\d* pass(?:ed|ing)?\b/i, /test result: ok/, /^ok\s/m],
231  },
232  build: {
233    fail: [/error TS\d+/, /Failed to compile/i, /error during build/i, /Build failed/i],
234    pass: [/Compiled successfully/i, /built in [\d.]+m?s/i, /✓ Built/],
235  },
236}
237
238/** Separa el comando en segmentos (sin el contenido de las comillas) y el separador que sigue a cada uno. */
239function segments(cmd: string): { segs: string[]; seps: string[] } {
240  const masked = cmd.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
241  const parts = masked.split(/(&&|\|\||;|\||\r?\n|\(|\)|\{|\})/)
242  const segs: string[] = []
243  const seps: string[] = []
244  for (let i = 0; i < parts.length; i += 2) {
245    segs.push(parts[i].trim())
246    seps.push(parts[i + 1] ?? '')
247  }
248  return { segs, seps }
249}
250
251type Kind = { present: boolean; compound: boolean; andOnly: boolean }
252
253/**
254 * Por tipo: si aparece; si hay algo después (entonces el código de salida no es
255 * solo del test/build); y si lo de después va solo con `&&` (entonces una salida 0
256 * quiere decir que el test/build también pasó).
257 */
258function classify(cmd: string): Record<'test' | 'build', Kind> {
259  const { segs, seps } = segments(cmd)
260  const lastNonEmpty = segs.reduce((m, s, i) => (s ? i : m), -1)
261  const none = { present: false, compound: false, andOnly: false }
262  const out = { test: { ...none }, build: { ...none } }
263  segs.forEach((s, i) => {
264    const bare = s.replace(PREFIX_RE, '')
265    if (NO_RUN_RE.test(bare) || TSC_INFO_RE.test(bare)) return
266    const kind = TEST_RE.test(bare) ? 'test' : BUILD_RE.test(bare) && !(/^tsc\b/.test(bare) && WATCH_RE.test(bare)) ? 'build' : null
267    if (!kind) return
268    const after = seps.slice(i, lastNonEmpty)
269    out[kind] = { present: true, compound: i < lastNonEmpty, andOnly: after.length > 0 && after.every(x => x === '&&') }
270  })
271  return out
272}
273
274/**
275 * true = pasó, false = falló, ausente = no cuenta. Un comando simple se cree por
276 * su código de salida (un fallo solo con marca del runner); uno compuesto, solo
277 * por las marcas del texto.
278 */
279export function verdict(cmd: string, exitOk: boolean, text: string): { test?: boolean; build?: boolean } {
280  const c = classify(cmd)
281  const out: { test?: boolean; build?: boolean } = {}
282  for (const kind of ['test', 'build'] as const) {
283    if (!c[kind].present) continue
284    const fail = MARKS[kind].fail.some(re => re.test(text))
285    const pass = !fail && MARKS[kind].pass.some(re => re.test(text))
286    if (!c[kind].compound) {
287      if (exitOk) out[kind] = true
288      else if (fail) out[kind] = false
289    } else if (c[kind].andOnly) {
290      // `test && git commit -m "fix 2 failing"`: con salida 0 corrió todo bien; no mirar el texto de lo de después
291      if (exitOk) out[kind] = true
292      else if (fail) out[kind] = false
293    } else if (fail) out[kind] = false
294    else if (pass) out[kind] = true
295  }
296  return out
297}
298
299// ── Carpeta donde corre un comando ─────────────────────────────────────
300
301const LEADING_CD_RE = /^\s*(?:cd|Set-Location|sl|pushd)\s+(?:-(?:LiteralPath|Path)\s+)?(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))\s*(?:&&|;|\|\||$)/i
302
303/**
304 * La carpeta real de un comando: si empieza con `cd <ruta>` (o Set-Location), esa;
305 * si no, la del evento. Hace falta porque el shell vuelve a la carpeta de la sesión
306 * después de cada comando, y el `cwd` del evento es esa.
307 */
308export function commandDir(cmd: string, env: { cwd: string; home: string; isWindows: boolean }): string {
309  const m = LEADING_CD_RE.exec(cmd)
310  if (!m) return env.cwd
311  let p = (m[1] ?? m[2] ?? m[3] ?? '').trim()
312  if (!p) return env.cwd
313  if (p === '~' || /^~[\\/]/.test(p)) p = `${env.home}${p.slice(1)}`
314  if (env.isWindows) {
315    const drive = /^\/([a-zA-Z])(\/|$)/.exec(p)
316    if (drive) p = `${drive[1].toUpperCase()}:/${p.slice(3)}`
317  }
318  if (/^(?:[A-Za-z]:[\\/]|[\\/])/.test(p)) return p.replace(/[\\/]+$/, '') || p
319  return `${env.cwd.replace(/[\\/]+$/, '')}/${p.replace(/^\.[\\/]/, '')}`
320}
321
types/index.d.ts 25 lines
1/** Repo de una mascota: clave = git-common-dir normalizado (un worktree comparte mascota). */
2export type MascotaRepo = { key: string; label: string; top: string }
3/** Aviso pendiente para el final del turno (uno solo, el de mayor prioridad). */
4export type MascotaNotice = { kind: 'nace' | 'etapa' | 'enferma' | 'cura'; texto: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    mascota: {
9      /** Clave del repo cuya mascota se ve en la línea de estado. */
10      shown: string | null
11      pending: MascotaNotice | null
12      /** Repos con actividad en esta sesión (se chequean sus commits al final del turno). */
13      touched: string[]
14      repos: Record<string, MascotaRepo>
15      /** Carpeta normalizada → clave de repo, o null si no es un repo. */
16      dirs: Record<string, string | null>
17      /** Último chequeo de commits por repo (ms). */
18      checked: Record<string, number>
19      paneOpen: boolean
20      /** Cuadro del parpadeo del panel (0-3). */
21      frame: number
22    }
23  }
24}
25