SLOPSHOPPER

engram-status

Estado del sync de Engram en la línea de estado, con una franja de aviso solo cuando algo falla

newpanebandcommandtoaststatus
★ 1v0.1.0NOASSERTIONupdated 2026-10-03Emaleo0522/claude-vibecoding/mods/engram-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · engram-status
│ ┃ engram-status ✕ › fix the failing auth test and add an audit lo╭───────────────────╮ │ ┃ Cargando… │ engram-status │ │ ┃ ⏺ Read(src/auth.ts) │ Engram: sin datos │ │ ┃ [ cerrar ] ⎿ 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 │ │ › /engram-status │ ⎿ engram-status: **engram ? sin datos** │ ⎿ engram-status: │ ⎿ engram-status: - Último sync completo: no hay ninguno en el fina │ ⎿ engram-status: - Log: `/Users/dev/.claude/sessions/engram-cloud- │ ⎿ engram-status: │ ⎿ engram-status: **Diagnóstico** (`engram cloud upgrade doctor`) │ │ ENGRAM sin datos [ ver detalle ] [ ocultar ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ engram-status: ◉ engram ? sin datos

Draws

Band
ENGRAM sin datos [ ver detalle ] [ ocultar ]
Pane · engram-status
Cargando… [ 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 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EngramStatusSnapshot } from '../types'
5import { bandText, detailText, evaluate, hideKey, isWorse, showsBand, statusLine, type Level, type Status } from './parse'
6
7const FIRST_CHECK_MS = 2_000
8const POLL_MS = 60_000
9const TAIL_LINES = 200
10const RUN_TIMEOUT_MS = 10_000
11const PANE = 'engram-status'
12
13const current = atom({ plugin: 'engram-status', key: 'current' } as const, null)
14const hiddenKey = atom({ plugin: 'engram-status', key: 'hiddenKey' } as const, null)
15const detail = atom({ plugin: 'engram-status', key: 'detail' } as const, '')
16/** Por sesión, no en $.store: dos sesiones con estados distintos se pisaban y repetían el toast. */
17const lastLevel = atom({ plugin: 'engram-status', key: 'lastLevel' } as const, 'ok')
18
19type Options = { project: string; engramPath: string; logPath: string }
20
21// Estado del módulo: se reinicia con cada reload, y está bien
22// (session.start vuelve a disparar y lo reconstruye).
23const mod = {
24  opts: { project: 'personal', engramPath: 'engram', logPath: '' } as Options,
25  lines: [] as string[],
26  lastMtime: null as number | null,
27  lastTurnEnd: null as number | null,
28  lastStatus: { level: 'unknown', reason: 'no-data' } as Status,
29  running: false,
30  detailBusy: false,
31}
32
33async function platform($: EngineInterface) {
34  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
35  // En Windows HOME puede venir en formato POSIX (/c/Users/...): primero USERPROFILE.
36  const home = isWindows
37    ? ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '')
38    : ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '')
39  const logPath = mod.opts.logPath || `${home}/.claude/sessions/engram-cloud-sync.jsonl`
40  return { isWindows, logPath }
41}
42
43function tailArgv(isWindows: boolean, logPath: string): string[] {
44  if (isWindows) {
45    const quoted = `'${logPath.replace(/'/g, "''")}'`
46    return ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath ${quoted} -Tail ${TAIL_LINES} -Encoding UTF8`]
47  }
48  return ['tail', '-n', String(TAIL_LINES), logPath]
49}
50
51/** Relee la cola del log solo si cambió el mtime. */
52async function refreshLines($: EngineInterface) {
53  const { isWindows, logPath } = await platform($)
54  const stat = await $.fs.stat(logPath).catch(() => null)
55  if (!stat) {
56    mod.lines = []
57    mod.lastMtime = null
58    return
59  }
60  if (stat.mtimeMs === mod.lastMtime) return
61  const result = await $.process.run(tailArgv(isWindows, logPath), { timeoutMs: RUN_TIMEOUT_MS }).catch(() => null)
62  if (!result || result.exitCode !== 0) {
63    mod.lines = []
64    mod.lastMtime = null
65    return
66  }
67  mod.lines = result.stdout.split(/\r?\n/).filter(Boolean)
68  mod.lastMtime = stat.mtimeMs
69}
70
71async function notifyIfWorse($: EngineInterface, s: Status, now: number) {
72  // ⟳ es neutro: no avisa ni cambia el último nivel avisado (si no, ⚠ → ⟳ → ⚠ repetía el toast).
73  if (s.level === 'syncing') return
74  const prev: Level = await read($, lastLevel)
75  if (isWorse(s.level, prev) && showsBand(s.level)) {
76    $.ui.toast(`Engram: ${bandText(s, now)}`)
77  }
78  if (s.level !== prev) await update($, lastLevel, () => s.level)
79}
80
81/** Publica lo que leen la franja y el panel. "Ocultar" se olvida cuando el problema se va. */
82async function publish($: EngineInterface, s: Status, now: number) {
83  const snap: EngramStatusSnapshot = { level: s.level, band: bandText(s, now), hideKey: hideKey(s) }
84  await update($, current, prev => (prev && prev.band === snap.band && prev.hideKey === snap.hideKey && prev.level === snap.level ? prev : snap))
85  // Solo ✓ olvida "ocultar"; ⟳ es neutro (pasa en casi cada turno).
86  if (s.level === 'ok') await update($, hiddenKey, prev => (prev === null ? prev : null))
87}
88
89async function check($: EngineInterface) {
90  if (mod.running) return
91  mod.running = true
92  try {
93    await refreshLines($)
94    const now = await $.clock.now()
95    const s = evaluate(mod.lines, now, mod.lastTurnEnd)
96    mod.lastStatus = s
97    $.ui.status(statusLine(s, now))
98    await publish($, s, now)
99    await notifyIfWorse($, s, now)
100  } finally {
101    mod.running = false
102  }
103}
104
105/** Diagnóstico a pedido del usuario; nunca corre de fondo. */
106async function runDoctor($: EngineInterface): Promise<string> {
107  const argv = [mod.opts.engramPath, 'cloud', 'upgrade', 'doctor', '--project', mod.opts.project]
108  const result = await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS, env: { ENGRAM_NO_UPDATE_CHECK: '1' } }).catch((err: unknown) => String(err))
109  if (typeof result === 'string') return `no se pudo correr engram (${result.slice(0, 120)})`
110  return `${result.stdout}${result.stderr ? `\n${result.stderr}` : ''}`
111}
112
113async function buildDetail($: EngineInterface): Promise<string> {
114  mod.lastMtime = null // forzar relectura del log
115  await check($)
116  const { logPath } = await platform($)
117  const doctor = await runDoctor($)
118  const now = await $.clock.now()
119  return detailText(mod.lastStatus, now, logPath, doctor)
120}
121
122async function openDetail($: EngineInterface) {
123  if (mod.detailBusy) return // doble clic: un solo doctor
124  mod.detailBusy = true
125  try {
126    // Abrir ya con "Cargando…": el doctor puede tardar hasta 10 s con el server caído.
127    await update($, detail, () => '')
128    await $.ui.open({ id: PANE, title: 'Engram' })
129    const text = await buildDetail($)
130    await update($, detail, () => text)
131  } finally {
132    mod.detailBusy = false
133  }
134}
135
136export const register: Register = (on, options) => {
137  mod.opts = { ...mod.opts, ...(options as unknown as Partial<Options>) }
138
139  on('session.start', async ($, e, next) => {
140    const result = await next(e)
141    await $.command.register({ name: 'engram-status', description: 'Estado del sync de Engram: detalle y diagnóstico' })
142    $.clock.after(FIRST_CHECK_MS, () => void check($).catch(() => {}))
143    $.clock.every(POLL_MS, () => void check($).catch(() => {}))
144    return result
145  })
146
147  on('turn.complete', async ($, e, next) => {
148    // Solo el hilo principal: el hook Stop que sincroniza corre al final del turno de la sesión.
149    // Y solo turnos que terminaron bien: con Esc ('aborted') o error de API el hook Stop no corre.
150    if (!(e as { agentId?: string }).agentId && e.reason === 'answer') mod.lastTurnEnd = await $.clock.now()
151    return next(e)
152  })
153
154  on('command.run', { command: 'engram-status' }, async $ => ({ text: await buildDetail($) }))
155
156  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
157    if (e.props.hasSurvey) return next(e)
158    const snap = await read($, current)
159    if (!snap || !showsBand(snap.level)) return next(e)
160    if ((await read($, hiddenKey)) === snap.hideKey) return next(e)
161    const { Box, Text, Button } = $.ui.resolve(e)
162    const color = snap.level === 'error' ? 'error' : 'warning'
163    return (
164      <Box flexDirection="row" gap={1}>
165        <Text color={color} inverse bold> ENGRAM </Text>
166        <Text wrap="truncate-end">{snap.band}</Text>
167        <Button key="detail" label="ver detalle" onPress={() => openDetail($)} />
168        <Button key="hide" label="ocultar" onPress={() => update($, hiddenKey, () => snap.hideKey)} />
169      </Box>
170    )
171  })
172
173  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174    const { Box, Markdown, Button } = $.ui.resolve(e)
175    const text = await read($, detail)
176    return (
177      <Box flexDirection="column" gap={1}>
178        <Markdown key="detail-md" text={text || 'Cargando…'} />
179        <Button key="close" label="cerrar" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
180      </Box>
181    )
182  })
183}
184
hooks/parse.ts 177 lines
1// Lógica pura de engram-status: del log de sync a un estado y sus textos.
2// Sin `$`: se prueba sola (parse.test.ts).
3
4export type Level = 'ok' | 'syncing' | 'warn' | 'unknown' | 'error'
5export type Reason = 'ok' | 'syncing' | 'failures' | 'server-down' | 'stalled' | 'not-run' | 'no-data'
6
7export type Status = {
8  level: Level
9  reason: Reason
10  /** Desde cuándo vale el motivo (último done, inicio colgado, primer error de conexión). */
11  sinceMs?: number
12  lastDoneMs?: number
13  synced?: number
14  failed?: number
15  /** Hasta 3 errores del último sync, recortados, para el detalle. */
16  errors?: string[]
17}
18
19const MIN = 60_000
20/** Un sync tarda ~55 s; más de 3 min sin `done` es que quedó colgado o cortado. */
21export const STALLED_MS = 3 * MIN
22/** Tiempo de gracia después de un turno para que arranque el sync del hook Stop. */
23export const NOT_RUN_MS = 5 * MIN
24
25const RANK: Record<Level, number> = { ok: 0, syncing: 1, warn: 2, unknown: 3, error: 4 }
26
27export const isWorse = (a: Level, b: Level): boolean => RANK[a] > RANK[b]
28
29export const hideKey = (s: Status): string => `${s.level}:${s.reason}`
30
31type Event =
32  | { kind: 'start'; ts: number }
33  | { kind: 'done'; ts: number; synced: number; failed: number }
34  | { kind: 'error'; ts: number; msg: string }
35
36const DONE_RE = /^done synced=(\d+) failed=(\d+)/
37const CONN_RE = /dial tcp|connectex|connection refused|no such host|i\/o timeout|timed out|deadline exceeded/i
38
39function toEvent(raw: string): Event | null {
40  let row: unknown
41  try {
42    row = JSON.parse(raw)
43  } catch {
44    return null
45  }
46  if (!row || typeof row !== 'object') return null
47  const { ts, level, msg } = row as { ts?: unknown; level?: unknown; msg?: unknown }
48  if (typeof ts !== 'string' || typeof msg !== 'string') return null
49  const t = Date.parse(ts)
50  if (Number.isNaN(t)) return null
51  if (msg.startsWith('starting cloud sync')) return { kind: 'start', ts: t }
52  const d = DONE_RE.exec(msg)
53  if (d) return { kind: 'done', ts: t, synced: Number(d[1]), failed: Number(d[2]) }
54  if (level === 'error') return { kind: 'error', ts: t, msg }
55  return null
56}
57
58const latest = <T extends Event>(events: T[]): T | undefined =>
59  events.reduce<T | undefined>((best, e) => (!best || e.ts >= best.ts ? e : best), undefined)
60
61/** Evalúa las últimas líneas del log. `lastTurnEndMs`: cuándo terminó el último turno de esta sesión. */
62export function evaluate(lines: readonly string[], nowMs: number, lastTurnEndMs: number | null): Status {
63  const events = lines.map(toEvent).filter((e): e is Event => e !== null)
64  const starts = events.filter((e): e is Extract<Event, { kind: 'start' }> => e.kind === 'start')
65  const dones = events.filter((e): e is Extract<Event, { kind: 'done' }> => e.kind === 'done')
66  const lastStart = latest(starts)
67  const lastDone = latest(dones)
68
69  let base: Status
70  if (!lastDone) {
71    base = lastStart && nowMs - lastStart.ts < STALLED_MS
72      ? { level: 'syncing', reason: 'syncing', sinceMs: lastStart.ts }
73      : { level: 'unknown', reason: 'no-data' }
74  } else if (lastStart && lastStart.ts > lastDone.ts) {
75    base = nowMs - lastStart.ts < STALLED_MS
76      ? { level: 'syncing', reason: 'syncing', sinceMs: lastStart.ts, lastDoneMs: lastDone.ts }
77      : { level: 'warn', reason: 'stalled', sinceMs: lastStart.ts, lastDoneMs: lastDone.ts }
78  } else {
79    base = fromDone(lastDone, starts, events)
80  }
81
82  if (lastTurnEndMs !== null && nowMs - lastTurnEndMs > NOT_RUN_MS) {
83    const ranAfter = events.some(e => (e.kind === 'start' || e.kind === 'done') && e.ts >= lastTurnEndMs)
84    if (!ranAfter && isWorse('warn', base.level)) {
85      return { level: 'warn', reason: 'not-run', sinceMs: lastTurnEndMs, lastDoneMs: base.lastDoneMs }
86    }
87  }
88  return base
89}
90
91function fromDone(done: Extract<Event, { kind: 'done' }>, starts: Extract<Event, { kind: 'start' }>[], events: Event[]): Status {
92  const common = { lastDoneMs: done.ts, synced: done.synced, failed: done.failed }
93  if (done.failed === 0) return { level: 'ok', reason: 'ok', sinceMs: done.ts, ...common }
94
95  const runStart = latest(starts.filter(s => s.ts <= done.ts))
96  const from = runStart ? runStart.ts : done.ts - 10 * MIN
97  const errors = events.filter((e): e is Extract<Event, { kind: 'error' }> => e.kind === 'error' && e.ts >= from && e.ts <= done.ts)
98  const samples = errors.slice(0, 3).map(e => e.msg.slice(0, 160))
99
100  if (errors.length > 0 && errors.every(e => CONN_RE.test(e.msg))) {
101    const first = errors.reduce((a, b) => (b.ts < a.ts ? b : a))
102    return { level: 'error', reason: 'server-down', sinceMs: first.ts, errors: samples, ...common }
103  }
104  return { level: 'warn', reason: 'failures', sinceMs: done.ts, errors: samples, ...common }
105}
106
107export function ago(ms: number): string {
108  const m = Math.floor(ms / MIN)
109  if (m < 1) return '<1 min'
110  if (m < 60) return `${m} min`
111  const h = Math.floor(m / 60)
112  if (h < 48) return `${h} h`
113  return `${Math.floor(h / 24)} d`
114}
115
116export function hhmm(ms: number): string {
117  const d = new Date(ms)
118  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
119}
120
121function failuresText(n: number): string {
122  return n === 1 ? '1 proyecto no sincronizó' : `${n} proyectos no sincronizaron`
123}
124
125/** Mensaje corto del motivo, compartido por la línea y la franja. */
126function message(s: Status): string {
127  switch (s.reason) {
128    case 'failures': return failuresText(s.failed ?? 0)
129    case 'server-down': return 'el server no responde'
130    case 'stalled': return 'un sync quedó sin terminar'
131    case 'not-run': return 'el sync no corrió al terminar el turno'
132    case 'no-data': return 'sin datos'
133    case 'syncing': return 'sincronizando…'
134    case 'ok': return 'sync'
135  }
136}
137
138const MARK: Record<Level, string> = { ok: '', syncing: '', warn: '⚠', unknown: '?', error: '✗' }
139
140export function statusLine(s: Status, nowMs: number): string {
141  if (s.level === 'ok') return `◉ engram · sync ${ago(nowMs - (s.sinceMs ?? nowMs))}`
142  if (s.level === 'syncing') return '◉ engram · sincronizando…'
143  return `◉ engram ${MARK[s.level]} ${message(s)}`
144}
145
146/** ¿Se dibuja la franja? Solo con problemas: ⚠, ? y ✗. */
147export const showsBand = (level: Level): boolean => isWorse(level, 'syncing')
148
149/** Detalle en Markdown para el panel y para /engram-status. */
150export function detailText(s: Status, nowMs: number, logPath: string, doctor: string | null): string {
151  const out: string[] = [`**${statusLine(s, nowMs).replace(/^◉ /, '')}**`, '']
152  if (s.lastDoneMs !== undefined) {
153    const f = s.failed ?? 0
154    const counts = s.synced !== undefined ? ` (${s.synced} proyectos, ${f === 1 ? '1 falló' : `${f} fallaron`})` : ''
155    out.push(`- Último sync completo: hace ${ago(nowMs - s.lastDoneMs)} · ${hhmm(s.lastDoneMs)}${counts}`)
156  } else {
157    out.push('- Último sync completo: no hay ninguno en el final del log')
158  }
159  if (s.reason === 'stalled' && s.sinceMs !== undefined) out.push(`- Hay un sync que arrancó a las ${hhmm(s.sinceMs)} y no terminó`)
160  if (s.reason === 'not-run' && s.sinceMs !== undefined) out.push(`- El turno terminó a las ${hhmm(s.sinceMs)} y el hook de sync no arrancó`)
161  if (s.errors && s.errors.length > 0) {
162    out.push('- Errores del último sync (hasta 3):')
163    for (const e of s.errors) out.push(`  - \`${e.replace(/`/g, "'")}\``)
164  }
165  out.push(`- Log: \`${logPath}\``)
166  if (doctor !== null) out.push('', '**Diagnóstico** (`engram cloud upgrade doctor`)', '```text', doctor.trim() || '(sin salida)', '```')
167  return out.join('\n')
168}
169
170/** Texto de la franja (sin el chip). Solo tiene sentido en warn, error y unknown. */
171export function bandText(s: Status, nowMs: number): string {
172  const msg = message(s)
173  if (s.reason === 'server-down' && s.sinceMs !== undefined) return `${msg} · desde ${hhmm(s.sinceMs)}`
174  if ((s.reason === 'failures' || s.reason === 'stalled') && s.sinceMs !== undefined) return `${msg} · hace ${ago(nowMs - s.sinceMs)}`
175  return msg
176}
177
types/index.d.ts 23 lines
1/** Lo que la franja y el panel necesitan del último chequeo. */
2export type EngramStatusSnapshot = {
3  level: 'ok' | 'syncing' | 'warn' | 'unknown' | 'error'
4  /** Texto de la franja, sin el chip. */
5  band: string
6  /** Nivel + tipo de motivo: si no cambia, "ocultar" sigue vigente. */
7  hideKey: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'engram-status': {
13      current: EngramStatusSnapshot | null
14      /** hideKey que el usuario ocultó, o null. */
15      hiddenKey: string | null
16      /** Markdown del panel de detalle. */
17      detail: string
18      /** Último nivel avisado con toast en esta sesión (sobrevive al hot reload). */
19      lastLevel: 'ok' | 'syncing' | 'warn' | 'unknown' | 'error'
20    }
21  }
22}
23