{"proyecto":"pulso","version":"v0.4.0","entorno":"production","fases":[{"fase":{"n":0,"nombre":"Fundaciones","pregunta":"¿Hay dónde construir y cómo mirarlo?","entrega":["Worker desplegado en una URL viva con health público","D1 pulso-db / pulso-obra, KV pulso-cache, R2 pulso-media (uat y producción separados)","Este panel de obra, con los veredictos persistidos en D1 pulso-obra","Dependencias fijadas exactas, con un test que falla si alguien las desfija","Entrada por magic link: sin contraseña, sesión firmada con HMAC. Se adelantó aquí porque la auth era el bloqueo del proyecto y había que probarla el día 1","Esquema completo de D1 aplicado (la fase 1 solo tendrá que sembrar y consultar)"],"criterios":[{"id":"f0.health","tipo":"auto","enunciado":"GET /v1/public/health responde 200 con status ok, versión y entorno","fallaSi":"No responde 200, o el JSON no trae version y environment"},{"id":"f0.pins","tipo":"auto","enunciado":"Ninguna dependencia usa rango (^, ~, *, x): todas fijadas exactas","fallaSi":"Cualquier versión en package.json no es exacta"},{"id":"f0.obra-persiste","tipo":"auto","enunciado":"Un veredicto enviado al panel se guarda en D1 y reaparece al recargar","fallaSi":"El veredicto no vuelve en GET /obra/api/estado"},{"id":"f0.plan-coherente","tipo":"auto","enunciado":"Toda fase tiene pregunta, al menos un criterio auto y al menos uno humano","fallaSi":"Una fase sin pregunta o sin gate humano"},{"id":"f0.sesion-firmada","tipo":"auto","enunciado":"Una cookie de sesión manipulada, caducada o mal formada se rechaza","fallaSi":"Cualquiera de los tres casos entra"},{"id":"f0.magic-un-uso","tipo":"auto","enunciado":"Un magic link usado dos veces no entra la segunda, y caduca a los 15 minutos","fallaSi":"El mismo enlace sirve dos veces"},{"id":"f0.magic-solo-permitidos","tipo":"auto","enunciado":"A un correo que no está en la lista no se le envía nada, y la respuesta es idéntica: no se filtra quién tiene acceso","fallaSi":"La respuesta permite distinguir un correo con acceso de uno sin acceso"},{"id":"f0.url-viva","tipo":"humano","enunciado":"Abro la URL desde el móvil y el panel se lee sin hacer zoom ni scroll lateral","fallaSi":"Tengo que hacer zoom, o algo se sale de la pantalla"},{"id":"f0.entro-por-correo","tipo":"humano","enunciado":"Pido el enlace en /entrar, me llega al correo, lo toco y entro. Ya puedo marcar veredictos","fallaSi":"No llega, llega a spam, o al tocarlo no entro"},{"id":"f0.plan-es-el-mio","tipo":"humano","enunciado":"El plan de 7 fases que veo es el que quiero para Pulso, en ese orden","fallaSi":"Falta algo importante, o el orden no tiene sentido para mí"},{"id":"f0.veredicto-facil","tipo":"humano","enunciado":"Puedo marcar ✓/✗ y dejar un comentario en tres toques, sin teclear nada raro","fallaSi":"Me cuesta encontrar dónde opinar, o se pierde lo que escribo"}],"guion":[{"n":1,"haz":"Abre https://uat-api.pulso.travel/entrar en el móvil, con datos, no en wifi.","debePasar":"Sale un campo de correo. El teclado del móvil aparece con la arroba a mano."},{"n":2,"haz":"Escribe lc@cooweb.co y toca «Enviarme el enlace».","debePasar":"Sale un mensaje verde diciendo que si ese correo tiene acceso, le llega un enlace."},{"n":3,"haz":"Abre el correo. Mira también en spam si no lo ves en un minuto.","debePasar":"Ha llegado un correo de Pulso con un botón «Entrar»."},{"n":4,"haz":"Toca «Entrar» en el correo.","debePasar":"Vas directo al panel de obra, ya dentro, y arriba aparece tu correo."},{"n":5,"haz":"Sin girar el móvil, desliza de arriba abajo toda la página del panel.","debePasar":"Nada se sale por el lado. No hay barra de scroll horizontal ni hace falta zoom."},{"n":6,"haz":"Despliega la Fase 0 y lee los criterios del gate.","debePasar":"Entiendes cada criterio sin preguntarme qué significa."},{"n":7,"haz":"Marca ✓ o ✗ en cada criterio humano de la Fase 0. En al menos uno, escribe un comentario.","debePasar":"El botón se queda marcado. Aparece «guardado» con la hora."},{"n":8,"haz":"Vuelve a tocar el enlace del correo, el mismo de antes.","debePasar":"Dice que ese enlace ya no vale. Un magic link se usa una vez y solo una."},{"n":9,"haz":"Cierra el navegador del todo y vuelve a abrir https://uat-api.pulso.travel/obra","debePasar":"Sigues dentro y tus ✓/✗ y tu comentario siguen ahí. Nada se ha perdido."},{"n":10,"haz":"Lee la sección «Pendiente de atender» arriba del panel.","debePasar":"Tu comentario y cualquier ✗ aparecen ahí, esperando respuesta."}]},"estado":"cerrada","entrega":{"id":"e0v002","fase":0,"version":"v0.0.2","url":"https://uat-api.pulso.travel/entrar","changelog":"Fase 0 micro 2 · Entrada por magic link sin contraseña, sesión firmada con HMAC, esquema completo de D1 aplicado, y el panel de obra protegido por tu sesión en vez de por una clave en la URL. 84 tests en verde.","commit_sha":null,"entregada_en":"2026-08-17 13:59:24"},"veredictos":{"f0.url-viva":{"id":"WkvRO3g1p4Zr","fase":0,"criterio_id":"f0.url-viva","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.0.1","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 13:00:43"},"f0.entro-por-correo":{"id":"OphZMxRhEgei","fase":0,"criterio_id":"f0.entro-por-correo","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:08"},"f0.plan-es-el-mio":{"id":"9ldJjjzKbOE8","fase":0,"criterio_id":"f0.plan-es-el-mio","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:12"},"f0.veredicto-facil":{"id":"eBNLHLDhPmc4","fase":0,"criterio_id":"f0.veredicto-facil","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:15"}},"humanosTotal":4,"humanosOk":4},{"fase":{"n":1,"nombre":"El feed, con datos sembrados","pregunta":"¿Esto se entiende y apetece?","entrega":["18 eventos reales de Castellón y provincia, extraídos de vivecastellon.com, cada uno con su URL de origen para poder comprobarlo","11 sitios con coordenadas reales geocodificadas con Nominatim (OpenStreetMap)","GET /v1/events, /events/cities, /events/:id, /events/nearby con bounding box + Haversine","Feed en /feed a 360 px, con filtros por ciudad y categoría, y ficha de detalle"],"criterios":[{"id":"f1.eventos-listan","tipo":"auto","enunciado":"GET /v1/events?city=Castellón devuelve los eventos sembrados paginados"},{"id":"f1.nearby-correcto","tipo":"auto","enunciado":"GET /v1/events/nearby filtra por radio real (Haversine), verificado con distancias conocidas"},{"id":"f1.geo-test","tipo":"auto","enunciado":"Haversine y bounding box tienen test con coordenadas de Castellón y distancias conocidas"},{"id":"f1.apetece","tipo":"humano","enunciado":"Miro el feed y me apetece tocar algo. No parece una tabla de base de datos","fallaSi":"Lo veo y no me dice nada, o no sé qué hacer con él"},{"id":"f1.entiendo-evento","tipo":"humano","enunciado":"De un evento entiendo qué es, cuándo y dónde sin abrirlo","fallaSi":"Tengo que abrir el detalle para saber si me interesa"},{"id":"f1.datos-crebles","tipo":"humano","enunciado":"Cojo tres eventos al azar, abro su enlace a la fuente y compruebo que existen de verdad","fallaSi":"Alguno no existe, o su fecha o su sitio no coinciden con la fuente"}],"guion":[{"n":1,"haz":"Abre https://uat-api.pulso.travel/feed en el móvil.","debePasar":"Sale una lista de planes. Nada se sale por el lado de la pantalla."},{"n":2,"haz":"Sin abrir ninguno, lee las tres primeras tarjetas.","debePasar":"De cada una sabes qué es, cuándo y dónde. Si tienes que abrirla para saberlo, es un ✗."},{"n":3,"haz":"Toca los filtros de arriba: prueba «Onda» y luego «Música».","debePasar":"La lista cambia. Los filtros se deslizan a lo ancho sin mover la página entera."},{"n":4,"haz":"Quita los filtros y toca «Ver por cercanía al centro», al final de la página.","debePasar":"Los planes salen ordenados por distancia, y cada uno dice a cuántos km está."},{"n":5,"haz":"Abre un evento cualquiera.","debePasar":"Ves cuándo, dónde, la distancia, la descripción y un botón a la fuente."},{"n":6,"haz":"En la ficha, toca «Ver en la fuente» y compara: título, fechas y sitio.","debePasar":"El evento existe de verdad en vivecastellon.com y los datos coinciden."},{"n":7,"haz":"Repite el paso 6 con otros dos eventos distintos, elegidos al azar.","debePasar":"Los tres existen. Si alguno es inventado o no cuadra, es un ✗."},{"n":8,"haz":"Toca «Ver en el mapa» en una ficha que tenga sitio concreto.","debePasar":"El mapa cae en el sitio correcto de Castellón, no en medio del campo ni en el mar."},{"n":9,"haz":"Vuelve al feed y prueba a combinar «Onda» con «Familia».","debePasar":"Dice que no hay nada con esos filtros, en vez de quedarse en blanco."},{"n":10,"haz":"Ahora la pregunta de verdad: ¿te apetece tocar algo de lo que ves?","debePasar":"Si el feed te parece una tabla de base de datos y no te dice nada, marca ✗ y dime por qué."}]},"estado":"cerrada","entrega":{"id":"e1v010","fase":1,"version":"v0.1.0","url":"https://uat-api.pulso.travel/feed","changelog":"Fase 1 · El feed con datos reales. 19 eventos de dos fuentes: 18 de vivecastellon.com y el Rototom Sunsplash de su web oficial, que la agregadora no listaba. 12 sitios geocodificados con OpenStreetMap, API de eventos con proximidad real (bounding box + Haversine) y feed a 360 px con filtros y fotos cuando la fuente las tiene. 162 tests en verde.","commit_sha":null,"entregada_en":"2026-08-17 14:29:48"},"veredictos":{"f1.apetece":{"id":"YLLshwJNbDLm","fase":1,"criterio_id":"f1.apetece","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.0","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 16:11:14"},"f1.entiendo-evento":{"id":"Gms0KY-b0Mc8","fase":1,"criterio_id":"f1.entiendo-evento","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.0","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 16:11:18"},"f1.datos-crebles":{"id":"Jtn3VNcCta4d","fase":1,"criterio_id":"f1.datos-crebles","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.0","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 16:11:22"}},"humanosTotal":3,"humanosOk":3},{"fase":{"n":2,"nombre":"MCP y scraping real","pregunta":"¿La pieza cara funciona y trae datos de verdad?","entrega":["Servidor MCP en uat-mcp.pulso.travel con 11 tools y 4 resources, transporte Streamable HTTP sin sesión (no necesita Durable Objects)","add_event: Luis encuentra algo que falta y el agente lo mete, con geocodificación","research_event: el agente va a la web oficial y trae descripción e imagen reales","Scraper de vivecastellon.com con HTMLRewriter y dedup por source_hash","Las tools que aún no funcionan se niegan diciéndolo, para que el agente no invente"],"criterios":[{"id":"f2.mcp-lista-tools","tipo":"auto","enunciado":"Un cliente MCP se conecta y lista las 11 tools y los 4 resources"},{"id":"f2.tools-no-inventan","tipo":"auto","enunciado":"Las tools sin construir devuelven error diciendo en qué fase se hacen, y las que no encuentran nada dicen explícitamente que no se invente el resultado","fallaSi":"Alguna devuelve datos vacíos o falsos como si fueran válidos"},{"id":"f2.alta-desde-agente","tipo":"auto","enunciado":"add_event da de alta un evento con fechas en prosa, no duplica al repetirlo, y se niega si no entiende la fecha en lugar de inventarse una"},{"id":"f2.geocoding-avisa","tipo":"auto","enunciado":"Si el municipio geocodificado no es el pedido, se avisa en la respuesta al agente","fallaSi":"Un evento se coloca en otro pueblo sin decir nada"},{"id":"f2.scrape-real","tipo":"auto","enunciado":"El scraper extrae ≥10 eventos reales de vivecastellon.com con título y fecha","fallaSi":"Extrae menos de 10, o alguno sin fecha parseable"},{"id":"f2.sin-duplicar","tipo":"auto","enunciado":"Scrapear dos veces la misma fuente no duplica eventos (source_hash)"},{"id":"f2.desde-claude","tipo":"humano","enunciado":"Conecto el MCP en Claude o Codex y le pregunto «¿qué hay este finde en Castellón?»","fallaSi":"No conecta, o me contesta con eventos que no existen"},{"id":"f2.agrego-un-evento","tipo":"humano","enunciado":"Le digo a Claude que falta un evento que yo conozco y lo añade. Luego lo veo en el feed","fallaSi":"No lo añade, o lo añade con la fecha o el sitio mal"},{"id":"f2.profundiza-un-evento","tipo":"humano","enunciado":"Le pido que investigue más sobre un evento y me trae información de la web oficial, no inventada","fallaSi":"Se inventa datos, o no distingue lo que encontró de lo que no"}],"guion":[{"n":1,"haz":"Añade el MCP a Claude Desktop o Codex con la URL y el token que te doy aparte.","debePasar":"Aparece «pulso» conectado, con 11 herramientas."},{"n":2,"haz":"Pregúntale: «¿qué hay este finde en Castellón?»","debePasar":"Contesta con eventos reales, con hora y sitio. Cada uno con su enlace de origen."},{"n":3,"haz":"Elige uno y pídele: «cuéntame más de ese».","debePasar":"Usa research_event y te resume lo que encontró, distinguiendo lo que NO encontró."},{"n":4,"haz":"Dile: «falta un evento, el [algo que tú sepas], es el [fecha] en [sitio]».","debePasar":"Lo añade con add_event y te da el enlace del feed para verlo."},{"n":5,"haz":"Abre ese enlace en el móvil.","debePasar":"El evento está ahí, con la fecha y el sitio que le dijiste."},{"n":6,"haz":"Pídele un evento inventado: «busca el Festival de Jazz de Villarreal de 2026».","debePasar":"Dice que no lo encuentra. Si se lo inventa, es un ✗ y el más grave de todos."},{"n":7,"haz":"Pídele: «avísame de ese evento».","debePasar":"Registra la suscripción Y te advierte de que el aviso todavía no llega (fase 5)."},{"n":8,"haz":"Pídele: «hazme una ruta con tres de estos eventos».","debePasar":"Los ordena por cercanía y te avisa de que son distancias en línea recta."}]},"estado":"cerrada","entrega":{"id":"e2v020","fase":2,"version":"v0.2.0","url":"https://uat-mcp.pulso.travel/info","changelog":"Fase 2 · Servidor MCP con 11 tools y 4 resources sobre Streamable HTTP, sin necesidad de Durable Objects. Incluye add_event y research_event, que son la vía para que Luis añada e investigue eventos desde Claude o Codex. Las tools sin construir se niegan diciendo en qué fase se hacen, para que el agente no invente. Scraper de vivecastellon con dedup por source_hash. 238 tests en verde.","commit_sha":null,"entregada_en":"2026-08-17 15:30:33"},"veredictos":{"f2.desde-claude":{"id":"eLmCiPfStey4","fase":2,"criterio_id":"f2.desde-claude","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.3","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 20:56:05"},"f2.agrego-un-evento":{"id":"UBeI6T3i0j13","fase":2,"criterio_id":"f2.agrego-un-evento","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.3","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 20:56:07"},"f2.profundiza-un-evento":{"id":"oOVM6EUZeUbZ","fase":2,"criterio_id":"f2.profundiza-un-evento","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.3.3","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 20:56:08"}},"humanosTotal":3,"humanosOk":3},{"fase":{"n":3,"nombre":"Con memoria: quién eres y qué te gusta","pregunta":"¿Recuerda lo que debe?","entrega":["Preferencias, ubicación y modo acompañante persistidos","GET /v1/events/for-you cruzando preferencias × ubicación × compañía","Zero Trust conectado de verdad: Access Application sobre pulso-travel, IdP y service token. Solo falta el API token con Access:Edit"],"criterios":[{"id":"f3.access-jwt-listo","tipo":"auto","enunciado":"Con AUTH_MODE=access-jwt, un JWT sin firmar, caducado o con audience ajeno recibe 401, validado contra el JWKS real de pulso-travel","fallaSi":"Alguno de los tres casos entra"},{"id":"f3.upsert-idempotente","tipo":"auto","enunciado":"El mismo usuario entrando dos veces no crea dos filas"},{"id":"f3.for-you-ordena","tipo":"auto","enunciado":"for-you ordena por relevance × peso de preferencia, verificado con pesos fijados"},{"id":"f3.entro-con-google","tipo":"humano","enunciado":"Entro con mi Google por Zero Trust y la app sabe quién soy sin que le diga nada","fallaSi":"Me pide algo más, o no me reconoce al volver"},{"id":"f3.me-conoce","tipo":"humano","enunciado":"Marco mis gustos, vuelvo mañana y el feed sigue ajustado a ellos","fallaSi":"Se olvidó de lo que le dije"}],"guion":[]},"estado":"esperando","entrega":{"id":"e3v030","fase":3,"version":"v0.3.0","url":"https://uat-api.pulso.travel/feed","changelog":"OAuth 2.1 en el MCP con magic link, enlaces con pase que abren la ficha ya autenticada, checks de comprobado e interesa, ruta dibujada en SVG y enrich_event para guardar lo investigado con lo no confirmado por delante. 306 tests en verde.","commit_sha":null,"entregada_en":"2026-08-17 16:07:04"},"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":4,"nombre":"Con tiempo: crons y digest","pregunta":"¿Aguanta el paso de los días?","entrega":["Cron de scraping diario 03:00 UTC vía Queue","Digest matutino pre-computado en KV a las 06:00","Cron de recordatorios cada 5 min y limpieza semanal","Días simulados: 7 ejecuciones seguidas sin duplicar ni romper"],"criterios":[{"id":"f4.siete-dias","tipo":"auto","enunciado":"Simular 7 días de crons no duplica eventos ni deja tareas colgadas","fallaSi":"Crecen los duplicados, o alguna tarea queda en running para siempre"},{"id":"f4.digest-precomputado","tipo":"auto","enunciado":"GET /v1/users/me/digest se sirve de KV, no recalcula"},{"id":"f4.cleanup-archiva","tipo":"auto","enunciado":"La limpieza archiva eventos de hace más de 7 días y no toca los vivos"},{"id":"f4.digest-util","tipo":"humano","enunciado":"Leo el digest de la mañana y me sirve. No es un listado, es un plan","fallaSi":"Es un volcado de eventos sin criterio"},{"id":"f4.sigue-vivo","tipo":"humano","enunciado":"Vuelvo tres días después sin tocar nada y hay eventos nuevos","fallaSi":"La base está igual que la dejé"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":5,"nombre":"El circuito entero: tiempo real y avisos","pregunta":"¿Funciona de punta a punta?","entrega":["Durable Object EventChannel con WebSocket y ring buffer de 50","Web Push real con VAPID (esto sí es gratis, va de verdad)","Durable Object SocialListener contra stub determinista (no hay claves de X aún)","Media a R2, servida por media.pulso.travel"],"criterios":[{"id":"f5.ws-broadcast","tipo":"auto","enunciado":"Dos clientes WS conectados al mismo evento reciben el mismo broadcast"},{"id":"f5.ring-buffer","tipo":"auto","enunciado":"Al reconectar, el cliente recibe los mensajes que se perdió (hasta 50)"},{"id":"f5.push-cifrado","tipo":"auto","enunciado":"El payload de Web Push se cifra correctamente (ECDH + VAPID) y el push service lo acepta"},{"id":"f5.stub-honesto","tipo":"auto","enunciado":"El social listening en modo stub lo dice en su respuesta: nunca finge ser real","fallaSi":"Devuelve datos inventados sin marcarlos como stub"},{"id":"f5.push-llega","tipo":"humano","enunciado":"Me suscribo a un evento desde el móvil y me llega la notificación push de verdad","fallaSi":"No llega, llega tarde, o llega sin decir de qué evento es"},{"id":"f5.tiempo-real-se-nota","tipo":"humano","enunciado":"Con la app abierta pasa algo en un evento y lo veo aparecer sin recargar","fallaSi":"Tengo que recargar para verlo"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":6,"nombre":"El canal real","pregunta":"¿Alguien lo quiere de verdad?","entrega":["Dominios finales api / mcp / media .pulso.travel en producción","Docs con Scalar en /docs, público","Rutas entre eventos (nearest neighbour + Nominatim, sin Google)","Hot scoring y rate limiting"],"criterios":[{"id":"f6.dominios-vivos","tipo":"auto","enunciado":"api., mcp. y media.pulso.travel responden en producción con TLS válido"},{"id":"f6.docs-publicas","tipo":"auto","enunciado":"/docs y /v1/openapi.json responden sin auth y el spec valida"},{"id":"f6.ruta-tiene-sentido","tipo":"auto","enunciado":"La ruta entre 3 eventos no cruza la ciudad dos veces (orden mejor que el de entrada)"},{"id":"f6.lo-usaria","tipo":"humano","enunciado":"Un fin de semana lo uso yo de verdad para decidir qué hacer, sin forzarme","fallaSi":"Abro otra cosa para decidir"},{"id":"f6.se-lo-daria-a-alguien","tipo":"humano","enunciado":"Le paso la URL a alguien de Castellón sin avisar de que es un PoC","fallaSi":"Me da vergüenza enseñarlo"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0}],"pendientes":[],"hallazgos":[{"id":"h0zerotrust","fase":0,"asunto":"Zero Trust está disponible pero sin configurar","resultado":"Zero Trust SÍ está disponible en la cuenta: access/identity_providers, access/apps y access/service_tokens responden success:true. Pero están vacíos: 0 IdPs, 0 aplicaciones, 0 service tokens. Y cooweb.cloudflareaccess.com da 404, así que el team domain no es el que asume el PRD. Falta: nombre del team domain + API token con Access:Edit. Ojo: pulso.cloudflareaccess.com ya está cogido por otra cuenta.","evidencia":"GET /accounts/45e3f.../access/identity_providers → success:true, 0 resultados · access/apps → 0 · access/service_tokens → 0 · curl cooweb.cloudflareaccess.com/cdn-cgi/access/certs → 404","bloquea":1,"created_at":"2026-08-17 12:47:16"},{"id":"h0dominios","fase":0,"asunto":"Los dominios no estaban configurados","resultado":"El GOAL daba por hechos api/mcp/media.pulso.travel, pero ninguno resolvía. La zona pulso.travel sí está en Cloudflare. uat-api.pulso.travel se creó como Custom Domain del Worker: el scope workers_routes bastó, no hizo falta zone:write.","evidencia":"dig +short api.pulso.travel → vacío · NS de pulso.travel: chance/olga.ns.cloudflare.com","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0castello","fase":0,"asunto":"La agenda del Ayuntamiento no es scrapeable con HTMLRewriter","resultado":"castello.es/es/agenda-cultural devuelve 200 y 172 KB, pero es un shell de Liferay: los eventos entran por JS. Los h3 del HTML son cromo del portal, no eventos. Plan B: Browser Rendering, que la cuenta sí tiene habilitado.","evidencia":"GET castello.es/es/agenda-cultural → 200, 0 eventos extraíbles del HTML","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0dipcas","fase":0,"asunto":"La Diputació tampoco sirve eventos en el HTML","resultado":"dipcas.es/es/eventos/public responde 200 pero el div listado-eventos llega vacío. Mismo plan B que castello.es.","evidencia":"GET dipcas.es/es/eventos/public → 200, div listado-eventos sin hijos","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0vivecastellon","fase":0,"asunto":"vivecastellon.com sí es scrapeable: es la fuente piloto","resultado":"Server-rendered, 12 eventos reales extraídos en la primera llamada, con título y enlace. Se usará como fuente piloto de la fase 2 en lugar de la web municipal.","evidencia":"GET vivecastellon.com/eventos-castellon-agenda/ → 200; ej: «27º Festival Bagatge de teatro de calle y circo en Betxì», Teatro Principal, MBACAS, EACC","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0terceros","fase":0,"asunto":"No hay claves de terceros: lo que dependa de ellas será stub declarado","resultado":"Sin X API, Meta Graph, Google Maps ni WhatsApp. Social listening y rutas se construyen contra stubs que se identifican como tales en su respuesta. Geocoding con Nominatim (OSM) y rutas con nearest-neighbour. Web Push sí es gratis con VAPID: eso va de verdad.","evidencia":"Decisión de Luis, 17/08/2026","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0versiones","fase":0,"asunto":"Versiones consultadas en npm hoy y fijadas exactas","resultado":"hono 4.13.2, drizzle-orm 0.45.2, zod 4.4.3, nanoid 6.0.1, jose 6.2.9, @modelcontextprotocol/sdk 1.30.0, wrangler 4.123.0, typescript 6.0.3. El SDK de MCP acepta zod ^3.25 || ^4.0. Scalar pide hono ^4.12.5. TypeScript 7.0.2 existe ya, pero se fija 6.0.3 para no estrenar el compilador nuevo en un PoC.","evidencia":"npm view <paquete> version · test/pins.test.ts falla si alguien desfija algo","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0vitest","fase":0,"asunto":"vitest-pool-workers 0.21 cambió de API","resultado":"Ya no existe defineWorkersConfig ni @cloudflare/vitest-pool-workers/config. Ahora es un plugin de Vite: cloudflareTest(). Tampoco existe la opción singleWorker. Se descubrió leyendo los tipos del paquete, no de memoria.","evidencia":"node_modules/@cloudflare/vitest-pool-workers/dist/pool/index.d.mts → exporta cloudflareTest, cloudflarePool","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0ztequipo","fase":0,"asunto":"El equipo Zero Trust es pulso-travel, no cooweb","resultado":"pulso-travel.cloudflareaccess.com existe y sirve 2 claves RS256. El PRD apuntaba a cooweb, que da 404. El validador de JWT ya está escrito contra este issuer y hay un test que comprueba que el JWKS real responde. Sigue faltando la Access Application y ACCESS_AUD, que necesitan un API token con Access:Edit: mi token OAuth de wrangler puede LEER Access pero no escribir (auth.forbidden), y no existe ningún scope de Access en wrangler login --scopes-list. Aviso: pulso.cloudflareaccess.com ya está cogido por otra cuenta.","evidencia":"GET pulso-travel.cloudflareaccess.com/cdn-cgi/access/certs → 200, 2 claves RS256 · POST access/identity_providers → 1010 auth.forbidden","bloquea":0,"created_at":"2026-08-17 13:59:24"},{"id":"h0email","fase":0,"asunto":"El magic link funciona: el correo llega a la bandeja de entrada","resultado":"pulso.travel está dado de alta para envío (SPF v=spf1 include:_spf.mx.cloudflare.net y DKIM en cf2024-1._domainkey, comprobados por DNS). El binding send_email envía de verdad desde hola@pulso.travel y el correo aterriza en INBOX, no en spam. Límite honesto: solo se puede enviar a direcciones verificadas como destino en Email Routing, hoy solo lc@cooweb.co. Para usuarios reales haría falta un proveedor transaccional. Falta DMARC en el DNS, que mejoraría la entrega.","evidencia":"POST /v1/auth/magic-link → 202 · correo recibido 17/08/2026 de hola@pulso.travel, labelIds UNREAD+INBOX","bloquea":0,"created_at":"2026-08-17 13:59:24"},{"id":"h0authpropia","fase":0,"asunto":"La auth del PoC ya no depende de Zero Trust","resultado":"Sesión propia firmada con HMAC-SHA256 sobre Web Crypto, sin dependencias y sin negociación de algoritmo (así no existe el ataque alg:none de JWT). Los magic links viven en KV como hash, no en claro, se consumen al usarlos y caducan a los 15 minutos. El destino de vuelta se valida para que el enlace del correo no sea un redirector abierto. Zero Trust queda como segundo backend, activable cambiando AUTH_MODE.","evidencia":"84 tests en verde, entre ellos: firma manipulada para subirse a revisor → rechazada; enlace usado dos veces → 401; destino externo → forzado a /obra","bloquea":0,"created_at":"2026-08-17 13:59:24"},{"id":"h1cuantos","fase":1,"asunto":"Son 18 eventos reales, no los 20 que decía el plan","resultado":"La agenda de vivecastellon.com tenía 18 eventos únicos el 17/08/2026, contando la portada, la página 2 y las de categoría. Se siembran los 18 reales en lugar de rellenar hasta 20 con dos inventados. Cada uno guarda su source_url para que se pueda abrir y comparar.","evidencia":"3 páginas recorridas, 18 títulos únicos; las URLs de categoría musica/fiestas/deportes/infantil dan 404","bloquea":0,"created_at":"2026-08-17 14:29:48"},{"id":"h1sinfotos","fase":1,"asunto":"La fuente no tiene imágenes de evento aprovechables","resultado":"Las fichas de vivecastellon.com no traen og:image y en el cuerpo solo hay cromo del sitio y banners de publicidad. Generar imágenes o tomarlas prestadas y presentarlas como fotos del evento sería falsear los datos, así que el feed se diseñó para funcionar sin fotos: fecha grande, color por categoría y tipografía.","evidencia":"Revisadas las 18 fichas: 0 con og:image. Los <img> del cuerpo son logos y /media/banners/*.jpg","bloquea":0,"created_at":"2026-08-17 14:29:48"},{"id":"h1geocoding","fase":1,"asunto":"Nominatim geocodificó 14 de 14 sitios, pero no todos con la misma precisión","resultado":"Cuatro salas se localizaron exactas (Museu de Belles Arts, EACC, Menador, Teatre Mónaco). En seis casos solo se pudo obtener el centroide del municipio: el Palau de Congressos de Peníscola y el recinto multiusos de Onda no existen como punto en OSM. Esos sitios se marcan type=city y su nombre lo advierte, y el feed no enseña distancias al metro para ellos. Prometer una dirección exacta que no tenemos sería falsear la precisión.","evidencia":"Nominatim: 12/14 a la primera, 2 tras reformular. Haversine validado con ancla conocida Madrid-Barcelona = 505,1 km","bloquea":0,"created_at":"2026-08-17 14:29:48"},{"id":"h1categorias","fase":1,"asunto":"Las categorías de la fuente no se pueden mapear a ciegas","resultado":"La fuente mete «Visitas guiadas gratuitas a la exposición de Benchamma» en el cajón «Excursiones, rutas, visitas guiadas», que mapeaba a naturaleza. Es una visita a una exposición del EACC: es cultura. El mapeo se corrige leyendo el título, no solo el cajón de origen. Hay un test que lo fija.","evidencia":"Detectado mirando el feed renderizado: chip NATURALEZA verde en un evento de museo","bloquea":0,"created_at":"2026-08-17 14:29:48"},{"id":"h1verified","fase":1,"asunto":"verified queda a 0 en los 18: nadie los ha validado a mano todavía","resultado":"La columna verified del PRD significa «un humano lo validó». Ponerla a 1 porque la fuente es real sería mentir sobre quién lo comprobó. Se queda a 0 hasta que alguien abra el enlace y compare, que es justo el paso 6 del guión de esta fase.","evidencia":"Test: SELECT count(*) FROM events WHERE verified = 1 → 0","bloquea":0,"created_at":"2026-08-17 14:29:48"},{"id":"h1rototom","fase":1,"asunto":"La fuente agregadora se perdió el evento más grande de la provincia","resultado":"Luis avisó de que faltaba el Rototom Sunsplash. Está ocurriendo AHORA (31ª edición, 16–22 de agosto de 2026, Benicàssim) y no aparecía: la agenda de vivecastellon.com no lo lista. Es el hallazgo más importante de la fase: una sola fuente agregadora no basta, ni para los eventos de cabecera. Añadido a mano desde su web oficial, con fechas, recinto geocodificado y foto reales. La fase 2 tiene que scrapear varias fuentes, incluidas las webs propias de los festivales.","evidencia":"GET vivecastellon.com/eventos-castellon-agenda/ (3 páginas) → 0 menciones al Rototom · web oficial: «La 31ª edición, del 16 al 22 de agosto»","bloquea":0,"created_at":"2026-08-17 14:56:14"},{"id":"h1multifuente","fase":1,"asunto":"El feed ya nombra todas sus fuentes, no solo la mayoritaria","resultado":"El subtítulo decía «datos reales de vivecastellon.com» aunque el Rototom viene de su web oficial. Atribuir un evento a la fuente equivocada es tan falso como inventarlo, aunque sea 1 de 19. Ahora se listan todas con su recuento.","evidencia":"GET /feed → «datos reales de vivecastellon.com (18) y rototomsunsplash.com (1)»","bloquea":0,"created_at":"2026-08-17 14:56:14"},{"id":"h1wpapi","fase":1,"asunto":"La web del Rototom es WordPress con API: mejor que scrapear HTML","resultado":"rototomsunsplash.com expone wp-json. El tipo de contenido `area` devuelve las 18 zonas del festival en JSON estructurado (Main Stage, Reggae Jamroom, House of Rastafari, Rototom Circus, Reggae University…). Los tipos `evento` e `invitado` existen pero vienen vacíos por API. Para la fase 2: probar wp-json ANTES de escribir un scraper de HTML, porque muchas webs de festivales son WordPress.","evidencia":"GET rototomsunsplash.com/wp-json/wp/v2/types → incluye evento, invitado, area · /wp-json/wp/v2/area → 18 items","bloquea":0,"created_at":"2026-08-17 14:56:14"},{"id":"h1fotosbis","fase":1,"asunto":"Corregido: hay fuentes que SÍ tienen fotos reales","resultado":"El hallazgo anterior decía que no había imágenes aprovechables, y era cierto de vivecastellon.com pero no en general. La web del Rototom tiene og:image oficial en su CDN (2244x1493, comprobado que carga). El feed ahora enseña foto solo si el evento trae una de su fuente oficial; cuando no hay, la tarjeta funciona igual con tipografía. Nunca se genera ni se toma prestada una imagen para hacerla pasar por foto del evento. La fase 5 copiará las fotos a R2 en lugar de enlazar al CDN ajeno.","evidencia":"og:image de rototomsunsplash.com carga a 2244x1493 en el navegador","bloquea":0,"created_at":"2026-08-17 14:56:14"},{"id":"h1fkbug","fase":1,"asunto":"Las migraciones de seed no eran re-aplicables: FOREIGN KEY constraint failed","resultado":"El seed de vivecastellon borraba las ubicaciones con LIKE 'loc_%', y eso mataba el recinto del Rototom mientras su evento seguía apuntando ahí. Lo cazó un test al volver a sembrar. Ahora cada seed borra solo SUS eventos (por source_name) y hace upsert de las ubicaciones en lugar de borrarlas. Hay un test que aplica los seeds dos veces seguidas.","evidencia":"D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT_FOREIGNKEY, en el beforeAll de eventos.test.ts","bloquea":0,"created_at":"2026-08-17 14:56:14"},{"id":"h2transporte","fase":2,"asunto":"El SDK de MCP trae transporte nativo para Workers","resultado":"No hace falta SSE ni Durable Objects: @modelcontextprotocol/sdk 1.30 exporta WebStandardStreamableHTTPServerTransport, que funciona sobre Request/Response estándar, y su documentación pone el ejemplo de Cloudflare Workers explícitamente. Se usa en modo SIN sesión y con respuesta JSON, así que cada petición se resuelve sola. Los Durable Objects siguen siendo de la fase 5, como estaba planeado.","evidencia":"node_modules/@modelcontextprotocol/sdk/dist/esm/server/webStandardStreamableHttp.d.ts documenta el uso en Workers y en Hono","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2geomorella","fase":2,"asunto":"Un nombre de sitio ambiguo colocaba un evento a 80 km","resultado":"add_event con «Plaza Mayor, Castellón» geocodificó a la Plaça Major de MORELLA, un pueblo a 80 km, porque «Castellón» es también el nombre de la provincia. Un evento colocado a 80 km del sitio real destruye la confianza en todo el producto. Arreglado en tres capas: consulta estructurada (amenity+city) antes de la libre, countrycodes=es, y sobre todo un aviso explícito cuando el municipio devuelto no es el pedido, que llega hasta la respuesta del agente. «Plaza Mayor» seguirá siendo ambiguo (cada pueblo tiene una): la respuesta correcta no es acertar siempre, es no colocarlo en silencio.","evidencia":"Antes: loc_407265_00821 = Plaça Major, Morella. Ahora: «⚠ Se pidió Castellón pero OpenStreetMap devolvió Vila-real. Compruébalo.»","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2cache429","fase":2,"asunto":"Un 429 de Nominatim se cacheaba como «este sitio no existe» durante 90 días","resultado":"Nominatim devuelve lista vacía también cuando limita por tasa, y ese vacío se guardaba 90 días. Un fallo de un segundo marcaba un sitio real como inexistente durante tres meses. Ahora se distingue «no hay resultados» de «el servicio falló»: lo transitorio no se cachea, y un vacío legítimo se recuerda solo una hora.","evidencia":"Los tests recibieron HTTP 429 al disparar 10 consultas en segundos; el límite de Nominatim es 1 por segundo","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2testsredes","fase":2,"asunto":"Los tests ya no golpean un servicio ajeno","resultado":"La suite llamaba a Nominatim de verdad y acabó recibiendo 429: diez peticiones en segundos es abusar de un servicio gratuito. La lógica que importa (comparar municipios, clasificar precisión, detectar discrepancias, decidir qué se cachea) es determinista, así que ahora se testea con un buscador inyectado. Que Nominatim siga respondiendo se comprueba una sola vez y contra el despliegue.","evidencia":"19 tests deterministas sustituyen 7 que dependían de la red","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2ciudades","fase":2,"asunto":"Comparar municipios en dos idiomas: dos bugs seguidos","resultado":"Quitar acentos no basta: «Castelló de la Plana» y «Castellón» quedan como castello y castellon, distintas por la n final. Al arreglarlo con substring apareció el bug contrario: «Ondara» contiene «Onda» y los daba por el mismo pueblo. La solución compara el token principal con regla de prefijo y un mínimo de 5 letras. De paso: Peníscola y Peñíscola SÍ son el mismo sitio, y mi expectativa inicial en el test estaba equivocada.","evidencia":"Tests: Castelló/Castellón=sí, Peníscola/Peñíscola=sí, Onda/Ondara=NO, Morella/Castellón=NO","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2hostmcp","fase":2,"asunto":"El host del MCP no se reconocía en uat","resultado":"La comprobación era host.startsWith('mcp.'), y el host de uat es uat-mcp.pulso.travel: todo el servidor MCP devolvía 404 en vivo aunque los tests pasaran, porque los tests usaban mcp.pulso.travel. Ahora se mira la primera etiqueta del dominio y hay test para las dos formas.","evidencia":"GET uat-mcp.pulso.travel/info → 404 antes, 200 después","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h2scraper","fase":2,"asunto":"Decisión de Luis: la calidad del scraping se resuelve más adelante","resultado":"El scraper propio con HTMLRewriter se queda como está: funciona, extrae los eventos de vivecastellon.com y no duplica (dedup por source_hash sobre la source_url). Mejorarlo no es el camino: se enchufarán MCPs de terceros tipo Firecrawl en una fase futura. Lo que sí importaba de esta fase es la vía manual: que Luis pueda añadir e investigar eventos desde Claude.","evidencia":"Decisión de Luis, 17/08/2026","bloquea":0,"created_at":"2026-08-17 15:30:33"},{"id":"h3oauth","fase":3,"asunto":"El Bearer estático tenía dos agujeros de diseño","resultado":"Cualquiera con ese token ACTUABA COMO Luis, y era imposible que un segundo usuario tuviera su propio perfil y sus propios gustos. La especificación de MCP exige que un servidor remoto sea resource server de OAuth 2.1. Ahora lo es: metadata RFC 9728 y RFC 8414, PKCE S256 obligatorio, registro dinámico de cliente, validación de audiencia RFC 8707, `iss` en la respuesta RFC 9207, rotación de refresh y revocación. El paso de «login» del flujo es el magic link que ya existía, así que no hace falta ningún proveedor externo. El token estático se conserva aparte, documentado como service token para crons y agentes sin navegador.","evidencia":"Flujo completo probado en vivo: registro → magic link → consentimiento → código → token → tools/list con 11 tools","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3sdkexpress","fase":3,"asunto":"Los handlers OAuth del SDK no sirven en Workers, pero sus esquemas sí","resultado":"El SDK trae servidor OAuth completo (provider, authorize, token, register, metadata), pero todos los handlers importan express. En Workers no valen. Lo que SÍ se reutiliza son los esquemas Zod de @modelcontextprotocol/sdk/shared/auth: OAuthClientMetadataSchema valida el registro dinámico contra el estándar en lugar de contra mi criterio.","evidencia":"grep de express en dist/esm/server/auth/**: los 7 ficheros de handlers lo importan","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3wellknown404","fase":3,"asunto":"El descubrimiento de OAuth daba 404 en el host del MCP","resultado":"El middleware del host mcp.* interceptaba TODO y solo dejaba pasar /, /mcp e /info, así que /.well-known/* y /oauth/* devolvían 404. El cliente recibía el 401 con el reto y no encontraba el metadata al que apuntaba. Esas rutas tienen que vivir en el MISMO origen que el recurso protegido.","evidencia":"GET uat-mcp.pulso.travel/.well-known/oauth-protected-resource → 404 antes, 200 después","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3mismoorigen","fase":3,"asunto":"El magic link tiene que servirse desde el host del MCP, no solo desde el de la API","resultado":"authorize redirigía a /entrar en el host del MCP, donde no existía. Y hay una razón más de fondo: la cookie de sesión se pone en el host que sirve el enlace, así que si /entrar viviera solo en api.*, la cookie caería en otro origen y authorize no la vería. Ahora /entrar y /v1/auth/* se sirven en los dos hosts y el flujo entero es del mismo origen.","evidencia":"El correo de prueba trajo el enlace apuntando a uat-mcp.pulso.travel/v1/auth/callback y la sesión funcionó","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3callejon","fase":3,"asunto":"Un magic link pedido desde el host del MCP acababa en una página muerta","resultado":"Detectado abriendo el flujo en un navegador de verdad: el callback redirigía a /obra, que en el host del MCP daba 404. Ahora cualquier ruta de la aplicación en mcp.* redirige a su equivalente en api.* en lugar de morir en un 404.","evidencia":"GET uat-mcp.pulso.travel/obra → 404 antes; ahora 302 a uat-api.pulso.travel/obra","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3testslentos","fase":3,"asunto":"Los tests tardaban 104 segundos por remontar las migraciones en cada bloque","resultado":"El fichero de OAuth llegó a 104 segundos con dos tests cayéndose por timeout, y no era lentitud de los tests: el más lento son 137 ms. Cada `beforeAll` reaplicaba las cuatro migraciones, unas cien sentencias por bloque. Memorizando el montaje una vez por worker, la suite entera bajó a 9,4 segundos.","evidencia":"Duración de la suite: 104 s → 9,4 s con 276 tests","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3perfilmcp","fase":3,"asunto":"El perfil se configura por MCP, y ahora es por usuario","resultado":"set_user_context guarda ubicación, compañía y gustos con pesos de 0 a 2, y pulso://user/context los lee. Eso ya funcionaba, pero con el token compartido todos los clientes escribían sobre el mismo perfil. Con OAuth cada usuario tiene su identidad, así que el perfil es suyo. Además las tools de escritura exigen el permiso pulso:write: un token de solo lectura no puede dar de alta eventos ni cambiar los gustos de nadie.","evidencia":"Test: con scope pulso:read, add_event devuelve error pidiendo pulso:write y no escribe nada en la base","bloquea":0,"created_at":"2026-08-17 16:07:04"},{"id":"h3destino","fase":3,"asunto":"El flujo OAuth no se podía completar desde el navegador","resultado":"Luis mandó una captura de /entrar con su correo bien escrito y el error «Hace falta un email válido». El email no tenía nada de malo: el campo que fallaba era `destino`, limitado a 200 caracteres, y la URL de vuelta a /oauth/authorize mide unos 300 (client_id, redirect_uri, PKCE, scope, state y resource). Así que tras autorizar, pedir el enlace se rechazaba y el flujo OAuth era imposible de terminar. Había un segundo recorte del mismo tipo en destinoSeguro(), que truncaba a 200 en SILENCIO: un destino recortado es peor que uno rechazado, porque parece funcionar. Y el mensaje de error culpaba al email pasara lo que pasara, lo que hacía el bug indiagnosticable desde la pantalla.","evidencia":"POST /v1/auth/magic-link con destino de 298 caracteres → 400 antes, 202 después. Comprobado en navegador: «Enlace enviado» en verde con la misma URL que fallaba.","bloquea":0,"created_at":"2026-08-17 16:15:13"},{"id":"h3atajo","fase":3,"asunto":"Mi atajo al probar en vivo escondió el bug","resultado":"La verificación del flujo OAuth se hizo metiendo el token del magic link directamente en KV con wrangler, para no depender del correo. Eso saltó /entrar, que es exactamente donde estaba el fallo, y por eso los 276 tests y la prueba en vivo pasaron con un flujo roto. Lección: cuando se rodea un paso para ir más rápido, ese paso queda sin probar y es justo donde se esconde el bug. Ahora hay un test que recorre el flujo pasando por /entrar y usa el destino tal como lo construye authorize, sin acortarlo a mano.","evidencia":"El test nuevo comprueba que el destino real pasa de 250 caracteres, que el enlace se acepta, y que llega intacto al callback","bloquea":0,"created_at":"2026-08-17 16:15:13"},{"id":"h3ubicacion","fase":3,"asunto":"Cambiar de ubicación no servía de nada","resultado":"set_user_context guardaba la ciudad pero dejaba lat y lng en NULL, así que decir «estoy en Onda» no habilitaba la búsqueda por cercanía. Y peor: search_events ignoraba la ubicación guardada, devolvía los eventos de toda la provincia y dependía de que el agente se acordase de leer el recurso y pasar la ciudad. Ahora la ciudad se geocodifica al guardarla, y search_events sin ciudad usa el perfil Y DICE cuál está usando, para que el agente lo traslade.","evidencia":"Antes: current_city=Onda, current_lat=NULL, y search_events sin ciudad devolvía 24 eventos de la provincia","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3pase","fase":3,"asunto":"Los enlaces del chat ahora dejan dentro al tocarlos","resultado":"Las tools devuelven un enlace por evento con un pase en la URL: al tocarlo desde el chat se abre la ficha ya autenticada y se puede marcar. El compromiso está acotado: lo emite el servidor solo para el usuario ya autenticado, no eleva permisos, vive 15 minutos, admite 10 usos, en KV se guarda su hash, y al canjearlo se redirige para que el pase no quede en la barra, el historial ni el Referer.","evidencia":"GET /feed/evt_x?p=... → 303 a /feed/evt_x con cookie de sesión","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3checks","fase":3,"asunto":"El check que cierra el círculo de la fase 1","resultado":"La columna verified («un humano lo validó») llevaba a 0 en los 19 eventos desde la fase 1, esperando precisamente esto. Ahora la ficha tiene dos botones: marcar comprobado y me interesa. Los dos son interruptores, exigen sesión y comprueban el origen de la petición para que un POST de otra web no pueda marcarlos.","evidencia":"POST desde otro origen → 403, y verified no cambia","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3ruta","fase":3,"asunto":"La ruta se dibuja, y dos correcciones al verla","resultado":"plan_route devuelve enlace a una página con la ruta en SVG, sin teselas de mapa: son dependencia externa con política de uso y peso en el móvil. Al mirarla aparecieron dos problemas reales. Uno: dos exposiciones del mismo museo son dos paradas a 0 m y una quedaba EXACTAMENTE debajo de la otra, así que se veían 2 nodos con 3 paradas; ahora se agrupan y el nodo dice «1-2». Dos: cuando un tramo largo domina la escala, las etiquetas de los sitios del centro se pisaban hasta ser ilegibles; ahora se colocan evitando colisiones y, si no hay hueco, no se dibuja la etiqueta. Un nombre ilegible es peor que ninguno.","evidencia":"Comprobado en navegador a 360 px con una ruta real de 4 paradas","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3cssvar","fase":3,"asunto":"Un botón invisible: una variable CSS sin definir falla en silencio","resultado":"El botón «✓ Lo comprobé» usaba background: var(--ok) y en la paleta del feed no existía --ok; venía copiada del panel de obra. Resultado: fondo transparente con texto blanco, un botón de 145x50 px completamente invisible sobre fondo claro. Ni el navegador ni TypeScript avisan. Se detectó inspeccionando el DOM en vez de suponer, y ahora hay un test que comprueba que TODA variable usada esté definida, en claro y en oscuro.","evidencia":"getComputedStyle del botón: background-color rgba(0,0,0,0), color rgb(255,255,255)","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3ascii","fase":3,"asunto":"La cabecera del 401 habría roto un cliente MCP de navegador","resultado":"WWW-Authenticate llevaba «Autoriza la aplicación por OAuth», con tilde. Una cabecera HTTP con caracteres no ASCII hace saltar un TypeError en el fetch de un navegador: un cliente MCP web se habría roto con nuestro propio 401. Lo avisó workerd en los logs de test. Ahora el motivo se transcribe a ASCII sin perder el mensaje.","evidencia":"Aviso de workerd: «A header value for WWW-Authenticate contains non-ASCII characters»","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3enrich","fase":3,"asunto":"enrich_event: profundizar de verdad, con lo no confirmado por delante","resultado":"research_event solo va a la web oficial y saca metadatos. enrich_event recibe lo que el AGENTE ha investigado con sus propias herramientas, ya estructurado: destacados, programa acto por acto, datos prácticos, fuentes con qué confirma cada una, y un campo sin_confirmar. Ese campo es el importante: los programas de fiestas que circulan por internet suelen ser del año anterior, y alguien puede plantarse en un sitio a una hora que nadie ha confirmado. En la ficha va ARRIBA del programa y con color de aviso, no en letra pequeña al final. La tool además protesta si guardas un programa entero sin marcar nada como dudoso, o sin ninguna fuente.","evidencia":"Probado con las Fiestas de San Bartolomé: 4 destacados, 2 actos marcados sin confirmar, 2 avisos y 1 fuente","bloquea":0,"created_at":"2026-08-17 18:15:49"},{"id":"h3categorias","fase":3,"asunto":"Las categorías estaban repetidas a mano en cinco sitios","resultado":"El enum de siete categorías vivía copiado en el esquema de search_events, el de add_event, el router de la API, los filtros del feed y el mapeo del scraper. Añadir una obligaba a tocar los cinco y desplegar. Ahora viven en D1 con su color en claro y en oscuro, se crean con la tool add_category y aparecen solas en todas partes. Los esquemas MCP usan z.string() y validan contra la base, no un enum: un cliente cachea tools/list al conectar y un enum fijado ahí dejaría fuera las categorías creadas después. La tool avisa cuando ya existe una parecida y su descripción desaconseja crear categorías con dos eventos, que hacen el feed inservible.","evidencia":"add_category «Mercadillos» → creada con color #0f766e/#2dd4bf y visible en el feed sin desplegar. add_event con «reggaeton» → error listando las que hay.","bloquea":0,"created_at":"2026-08-17 19:45:32"},{"id":"h3google","fase":3,"asunto":"Los mapas pasan a Google Maps, citando a OpenStreetMap","resultado":"La ficha y la ruta enlazaban a OpenStreetMap. Ahora usan Google Maps con las URL api=1, que son públicas, no necesitan clave, no cuestan nada y en el móvil abren la app en vez de la web. La ficha ofrece «Abrir en Google Maps» y «Cómo llegar»; la ruta manda la primera parada como origen, la última como destino y el resto como waypoints, hasta nueve. Las COORDENADAS se siguen geocodificando con OpenStreetMap, así que se cita en las dos páginas: usar sus datos y no decirlo estaría feo.","evidencia":"GET /feed/evt_rototom2026 → google.com/maps/search/?api=1&query=Recinto+de+Festivales… y /maps/dir/?api=1&destination=…&travelmode=walking","bloquea":0,"created_at":"2026-08-17 19:45:32"}],"faseActual":3}