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

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.
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.
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.
| Tipo de proyecto | Ejemplo concreto | Stack que el sistema usa |
|---|---|---|
| Landing / sitio público | Página de un restaurante, portfolio, lanzamiento de producto | Astro (content-heavy, 0 JS por default) o Vite + React + Tailwind |
| Web app con auth | Dashboard, CRM, SaaS MVP, panel de admin | Next.js + Better Auth + Drizzle + PostgreSQL |
| App móvil iOS + Android | Delivery, fitness tracker, app de tu negocio | React Native + Expo SDK 52+ |
| Juego de navegador | Plataformero 2D, puzzle, arcade | Phaser.js o PixiJS |
| API / backend | Endpoints REST/tRPC, webhooks, jobs | Hono + Drizzle + PostgreSQL |
| Full-stack completo | Producto entero con frontend + backend + auth + DB | Combinació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.
| Plataforma | Lo que necesitás antes | Dónde bajarlo |
|---|---|---|
| Linux + Claude Code | Claude Code CLI, git, Node.js | Claude Code |
| Windows + Claude Desktop | Claude Desktop, Git for Windows (trae Git Bash), Node.js | Claude 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.
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.
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.
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:
.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.
# 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
Abrí Claude Code y escribí:
modo orquestador — quiero crear una landing para mi cafetería de especialidad
Lo que pasa a continuación:
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).
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
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.
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.
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.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.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.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).Explore, 20+ tool calls sin spawn → pausar, 2+ archivos no-triviales en una tarea → fresh review). Adaptado de gentle-ai.## 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.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.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.
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):
docker compose up -d cloud./opt/engram-cloud/.env configurá ENGRAM_CLOUD_ALLOWED_PROJECTS=mi-proyecto,personal,… (allowlist explícita para evitar bucket explosion).engram cloud configure --url https://TU-VPS:PUERTO.engram cloud enroll <project-name>.Reglas clave (validadas en producción 2026-05-15):
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..env, docker compose up -d cloud. Sin allowlist explícita el server retorna 403.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:
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.
🆕 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_TOKENya 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.
| Variable | Servicio | Quota free | Cómo obtenerla |
|---|---|---|---|
HF_TOKEN ⭐ primario | HuggingFace Inference | $0.10/mes (~150 imgs FLUX-schnell), reset mensual | huggingface.co/settings/tokens → token role Read |
CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_AI_TOKEN secundario | Cloudflare Workers AI | 10,000 neurons/día sin tarjeta (cientos de imgs/día) | Setup en 3 pasos abajo ⬇️ |
| sin variable | Pollinations.ai | FLUX 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.
Account → Workers AI → Read → "Continue to summary" → "Create Token" (copialo, se muestra una sola vez)| Variable | Servicio | Costo | Cuándo usarlo |
|---|---|---|---|
GEMINI_API_KEY | Google AI Studio | $0.02-0.04/img + billing | Mejor comprensión LLM-nativa de prompts. Requiere billing habilitado en Google Cloud |
REPLICATE_API_TOKEN | Replicate | $0.03-0.10/video | Solo 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_KEY | Recraft V4 Vector | $0.08/img + $5 free/mes via Vercel AI Gateway | Logos SVG nativos (sin pérdida raster→vector). Solo si querés logos vectoriales premium |
~/.bashrc o ~/.zshrc con export VAR=valor, o crealas en ~/.claude/.env (una por línea: VAR=valor)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.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.
| Modo | Cuándo usarlo | Cómo activarlo |
|---|---|---|
| Claude normal | Preguntas, fixes puntuales, revisar código, chat técnico | Default — solo hablar |
| Orquestador | Proyecto completo de principio a fin | Decí: "modo orquestador — [tu idea]" o "activa el pipeline" |
| Modificación | Cambios sobre un proyecto ya completado por el pipeline | Detectado automáticamente cuando decís "retomar [proyecto] — [cambio]" |
| Diagnóstico | Auditar 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).
Para developers que quieran ir más allá:
| Archivo | Para qué |
|---|---|
agents/PIPELINE-AGENTS.md | Tabla 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.md | Comportamiento completo del orquestador: detección de modos, pipeline detallado, DAG State, fallbacks |
agents/agent-protocol.md | Protocolo compartido entre subagentes: Engram (2 pasos), Return Envelope, VISUAL_IMPACT, Delegation Stop Rules, reglas universales |
agents/pipeline-reference.md | Detalles de cada fase, tools por agente, stack adaptable, Design Intelligence Engine |
agents/external-skills-reference.md | Skills externas via npx skills add — whitelist curada, opt-in en Fase 3, no contamina boot |
CLAUDE.md | El 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
hooks/register.tsx 325 lines1import { 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}
325hooks/pet.ts 321 lines1// 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}
321types/index.d.ts 25 lines1/** 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