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

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 184 lines1import { 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}
184hooks/parse.ts 177 lines1// 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}
177types/index.d.ts 23 lines1/** 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