Luna Chat — plan de ejecución
Qué es: el orden en que se construye lo que define01-interfaz-luna.md, fase por fase, con lo que se puede demostrar al final de cada una, los archivos reales que se tocan, y la puerta que hay que pasar para seguir.
Base verificada: staging @ f5acdf06. Dos hechos cambian el plan respecto a lo que dicen los docs viejos: (a) las tablas de Work Session existen y están registradas en TypeORM, pero nada las lee: no hay reductor, no hay agentic-contracts.ts, no hay 28 eventos escritos; Luna es greenfield sobre un esquema vacío. (b) FeatureKey solo admite assistant | broker.
Supuesto de equipo: una persona de ingeniería a tiempo completo más Claude como par. Con dos personas las fases F2–F4 corren en paralelo y el total baja de ~7 a ~5 semanas.
Por dónde se empieza y por qué
Se empieza por el hilo (F1): hablar con Luna y verla buscar, sin dinero y sin escribir nada. Porque ahí viven las dos incógnitas que pueden tirar todo el diseño y que hoy nadie ha probado en este repo:- ¿El modelo primario barato aguanta un bucle con ~10 herramientas y salida
json_objectsin desbarrancarse a la ruta cara? Si no, el costo por conversación cambia y hay que saberlo la semana uno, no la seis. - ¿La sesión, los eventos y la pantalla dividida se sienten vivos? Es el esqueleto sobre el que van las tarjetas de decisión.
Mapa
F0 · Cimientos (2–3 días)
Decisiones que se cierran y quedan enDECISIONS.md de este folder:
- Nombre. “Luna” = el modelo gestionado. El id
gpt-5.6-lunase queda como está en el gateway (es un id de proveedor) pero ninguna etiqueta de producto vuelve a decir “Luna” para referirse al fallback. - Entitlement. Reusar
assistantenapps/api/src/entitlements/plans.catalog.tsen lugar de ampliarFeatureKey; el control fino de rollout va en un kill switch propio. - Eventos v1. Como los 28 eventos de A1 nunca se escribieron, se congela la tabla de 10 eventos de
01-interfaz-luna.md §6como vocabulario v1. Cabe entype varchar(40)dework_session_events. - Free. Explorer: chat sin auto, sin contactos, sin secuencias, tope diario de turnos (sugerido: 15).
apps/api/src/luna/luna.contracts.ts:TurnInput,TurnIntent,DecisionDraft,Ref,LunaEvent(los 10),SessionStage,SessionPolicy(modo, tope, clases). Solo tipos y JSON Schemas.apps/api/src/luna/luna-rollout.tscopiando la doctrina deinvestigations/investigation-rollout.ts:{ disabled, allowedWorkspaces, autoModeEnabled, maxTurnsPerDayFree }. Encendido por defecto, lista solo para fijar durante beta.- Registrar la superficie
luna-chaten la sesión gestionada y añadir el runner de Luna aFACTORY_CONSUMERSenagent-runs/mastra-boundary.spec.ts(si no, CI falla por diseño). - Instalar
react-resizable-panelsen el dashboard.
F1 · El hilo (1 semana)
Objetivo demostrable: abrir/w/:slug/luna, escribir “quiero 40 fabricantes de empaque en Jalisco que ya le hayan vendido a gobierno”, y ver la narración en vivo, los pasos (“leí el criterio del equipo”, “412 candidatos”), y tarjetas argumentadas apareciendo a la derecha. Nada se escribe, nada cuesta.
API — módulo apps/api/src/luna/
luna.module.tscon importsAgentRunsModule,EntitlementsModule,BillingModule,TopicsModule,MarketDataModule,TypeOrmModule.forFeature([WorkSession, WorkSessionEvent, WorkSessionArtifact]). Registrar enapp.module.tsL286–332.luna-session.service.ts: crear/cerrarwork_sessions(una por principal),append(event)conseqmonotónico ysnapshotcada N eventos,readSince(seq).luna-turn.service.ts, el corazón:assertFeature(ws,'assistant')+CommercialUsageService.preflight(ws, actorHash, false, false)+ rollout.- Compilar
TurnInput(perfil comercial, libro de hechos, tablero del snapshot, mapa de cobertura, etapa). managedSessions.contextFor({ surface:'luna-chat', runId: sessionId+turn, capabilities:{ structuredOutputMode:'json_object' }, allowTechnicalFailover:true }). Sinjson_schemaaquí.mastra.for(access.modelContext).runStreaming(spec)contools = buildAgentToolExecutor(LUNA_TOOLS_FOR_STAGE, backing, { caller:'internal_agent', emit }),maxSteps: 8,stubToolResultsOverChars: 4500,onStep→ eventostool.started/finished,narration.delta.- Validar el
TurnIntenten modopromptedcon una reparación; si falla, segunda pasada concapabilities:{ structuredOutputMode:'json_schema' }yescalate:{ reason:'capability' }. modelUsage.recordSession(access.session, null)siempre, enfinally.
luna-tools.ts: las herramientas de F1 comoDriftlessTool[](sideEffect:'read',policyClass:'open_read'):context_retrieve,context_get,market_search,market_count,market_get,market_aggregate,market_history,market_compare,market_screen,board_read,usage_read. El backing llama aRetrieveService,MarketDataService,CommercialUsageServicecon proyección cliente-segura (sinnext_actionde operador, sin ids internos).luna-stage.ts:stageFor(snapshot)→sin_criterio | buscando | con_seleccion | con_lista | decision_pendienteytoolsFor(stage).luna-refs.ts: validador de[[kind:id]]contra los objetos del turno (v1:company,topic,evidence) + instrucción de reparación.luna.controller.tsenworkspaces/:slug/luna:POST /sessions→{session_id}POST /sessions/:id/turns(headerIdempotency-Key) → 202{turn_id}; el trabajo corre en el request y publica eventosGET /sessions/:id/events?since=→ SSE (text/event-stream), mismo patrón queevents/streamGET /sessions/:id→ snapshot proyectado
Dashboard — apps/dashboard/src/luna/
- Ruta: una línea en
App.tsxL265–311:<Route path="luna" element={<Screen render={(ws) => sus(<Luna workspace={ws} />)} />} />. Luna.tsx:PanelGrouphorizontal (react-resizable-panels), izquierdaLunaThread, derechaLunaBoard.lunaRuntime.ts: adaptador External Store de assistant-ui (useExternalStoreRuntime) alimentado porsubscribeLunaEvents(copiarsubscribeWorkspaceEventsdeapi.tsL195–247 consince=seqy reconexión). El servidor es la única autoridad: el store no guarda nada que no venga de un evento.LunaBoard.tsxv1: la selección como tarjetas argumentadas (nombre, porqué, evidencia), sin CRM todavía.- Composer con el selector de modo visible pero deshabilitado (“Preguntar”).
Tests
- Unit:
luna-stage.spec.ts,luna-refs.spec.ts, compilación deTurnInputcon fixtures. - Candado
luna-boundary.spec.ts(text-scan comomastra-boundary.spec.ts): ningún archivo deluna/importaunlockContacts,promote,EmailCampaignsService.confirm/activate,approve; ninguna herramienta de Luna tienesideEffect:'act'. - Integración: un turno con modelo scripted (fixture) produce la secuencia de eventos esperada.
F2 · La lista y el CRM al lado (1 semana)
Objetivo demostrable: “guárdamelos en Prospección” → tarjeta “Guardar 40 · 3 ya estaban” → un toque → la Colección real aparece en el panel derecho con sus etapas. “Pasa Cartonera Jalisco a Calificado” → la tarjeta se mueve a la vista, con deshacer.API
- Herramientas:
selection_update,crm_collections,crm_query,crm_get,crm_update(devuelveundo_tokenydropped_fields),crm_schedule_activity,crm_save_companies,crm_bulk_update. luna-decisions.service.ts: las propuestas viven en el snapshot (pending[]conproposal_id, tipo, vista previa, vencimiento).POST /sessions/:id/decisions/:pid/resolve { outcome }ejecuta:guardar_en_crm→POST market-data/promotecon las mismas filas del preview;cambios_masivos→POST /records/bulksindry_run.POST /sessions/:id/undo/:tokenrevierte un nivel 0.- Tablero como
STATE_DELTA: cada cambio de selección o de CRM emiteboard.patched(JSON Patch sobre la proyección). - Etapas
con_seleccionycon_listaactivan sus herramientas.
Dashboard
CollectionDetail.tsxaceptacollectionId/recordIdpor props con fallback auseParams()(cambio pequeño, no rompe la rutacollections/:collectionId/records?/:recordId?). Se monta enLunaBoardcuando hay lista guardada; antes, la selección.- Tarjetas (Tool UIs de assistant-ui):
GuardarEnCrmCard,CambiosMasivosCard, chipDeshacer. - SWR: al resolver una decisión, invalidar la clave
/workspaces/:slug/collections/:id/...para que el board se refresque solo.
Tests
- Integración: preview y promote producen la misma salida por fila; resolver dos veces es idempotente (
source_promotion_key). - Candado:
crm_updatenunca mandaentity_iddentro defields.
F3 · Contactos con dinero y modo automático (1 semana)
Objetivo demostrable: “consígueme el correo de los 10 mejores” → “Revelar 10 contactos · 20 créditos · te quedan 140 · vence en 10 min” → aceptar → los registros muestran contacto revelado. Luego, activar “Automático hasta 50 créditos” y repetir con otros 5: Luna narra “revelé 5 · 10 créditos · llevas 30 de 50” sin preguntar.API
- Herramientas:
people_search,people_quote,people_acquired. - Cotización:
people_quotellama al servicio de Radar y guarda en el snapshot{ quote_id, quote_token, idempotency_key, credits, expires_at, selection }. Soloquote_id, precio, saldo y vencimiento cruzan al modelo y al cliente. - Resolver
revelar_contactos:unlockContacts(ws, collectionId, recordIds, { confirmed:true, maxCredits: credits, idempotencyKey, quoteToken, actorId }). Vencida →expiredy se recotiza.RADAR_QUOTE_EXCEEDED→ tarjeta “cambió el precio, vuelvo a cotizar”, nada cobrado. SessionPolicy:PUT /sessions/:id/policy { mode:'ask'|'auto', budget_credits, max_contacts_per_turn, classes }. Gating:commercialTierForPlan(plan) === 'explorer'→ 403 paraauto. El resolutor automático corre dentro del turno cuando la política lo permite y la lista ya está guardada; escribe en la auditoríaresolved_by:'policy'+ quién fijó la política y cuándo. El auto expira con la sesión.- Contador: evento
decision.resolvedllevabudget_used/budget_total.
Dashboard
RevelarContactosCardcon cuenta regresiva;PolicySheet(modo, tope, clases) en el composer; contador de presupuesto en la barra del hilo.- Explorer ve el selector deshabilitado con “Disponible en Founder”.
Tests
one-debit-per-operation.spec.tsse extiende: un desbloqueo desde Luna produce una fila de ledger por operación.- Candado:
unlockContactssolo se invoca desdeluna-decisions.service.ts, nunca desdeluna-tools.ts. - Unit: la política rechaza más de
max_contacts_per_turn, rechaza sin lista guardada, expira con la sesión.
F4 · Secuencias (1 semana)
Objetivo demostrable: “ármame una secuencia de tres correos para los 10” → borrador (día 0, 3, 7) → vista previa por destinatario con avisos (“2 sin nombre de contacto”) → tarjetarevisar_secuencia en el chat con los pasos, la audiencia, la vista previa por destinatario y las brechas de capacidad → la persona la acepta (confirma campaña + autoriza cada paso + activa) → sale con el proveedor mock.
API
- Herramientas:
sequence_list,sequence_get,sequence_draft,sequence_update_draft,sequence_preview, envolviendoEMAIL_CAMPAIGN_TOOL_CONTRACT(email-campaign-tools.ts). Audiencia = registros de la Colección. email-campaigns.feature.ts: pasar de single-tenant a lista de workspaces + plan (Founder y Commercial). Sin esto no hay beta.sequence_previewincluyeGET /email-capabilitiesproyectado: lo que no existe (prueba, respuestas, aperturas) se dice tal cual.- Resolutor de
revisar_secuenciaenluna-decisions.service.ts(server-side, sesión humana): al aceptar la tarjeta, en un solo acto —EmailCampaignsService.confirmsobre la campaña, una autorización inmutable por paso con huella de remitente + audiencia + contenido (una llamada por paso, o una sola con las casillas marcadas — decidir contra la UI de F4), yactivate. Corre conassertHumanSession: el chat principal ya es una sesión humana de Clerk, así que se cumple igual que en el dashboard. Editar el borrador después de emitida la tarjeta invalida las autorizaciones y obliga a reemitirla — mismo mecanismo que ya invalida ensequence_update_draft. sequence_activateno es una tool del cinturón: es el paso final del resolutor, nunca algo que Luna invoque.
Dashboard
RevisarSecuenciaCard(pasos, audiencia, avisos, checkbox o botón por paso), montada en el chat — ya no hay handoff ni pantalla intermedia en/email-campaigns.
Tests
- Candado:
luna-tools.tsno importaconfirm,activate,send-authorizations(sololuna-decisions.service.tspuede);assertHumanSessionsigue enemail-campaigns.service.tsL694 y L1042 (text-scan). - Integración: editar un borrador ya autorizado invalida las autorizaciones y el chat lo reporta; aceptar
revisar_secuenciados veces es idempotente.
EMAIL_CAMPAIGN_PROVIDER=mock, autorizada enteramente desde el chat.
F5 · Cerebro, integraciones, investigación (3–4 días)
- Herramientas:
context_note,context_suggest_edit(tarjetas nivel 1 que al resolver llamanPOST /topicsconpropose:trueyproposals);integrations_list,integrations_read(broker read);research_start,research_status. - Decisiones:
accion_externa(resolver →broker invokeconidempotency_key; la tarjeta resuelta muestracorrelation_id),investigar(resolver →POST /research/runs?stream=ndjson; los frames se re-emiten comorun.progress),fusionar_conocimiento(solo aparece a owner/admin; resolver →assertCanApprove+ approve). - Intent
limite: tarjeta “Esto no lo puedo hacer todavía” con proveedor + operación, sin intento alternativo. - Broker sigue tras su kill switch; en beta solo lectura de HubSpot y Notion.
F6 · Puerta de beta (1 semana, en paralelo desde F2)
evals/luna/registry.mjs+run.mjscopiandoevals/intelligence/registry.mjs: los 8 evals bloqueantes de01 §9en tres niveles (L1 reductor puro, L2 modelo scripted con fixtures, L3 modelo vivo ennightly-evals.yml). Registrado sin implementar = falla.- Telemetría de costo: por sesión,
% turnos en primario,costo USD,escaladas por razón. Tablero interno simple. Alerta si el primario baja de 90 %. - Léxico y errores: gate de léxico prohibido sobre
narration.delta; catálogo de errores mapeandoRATE_LIMITED,CURSOR_*,PROTECTED_RESOURCE,VERSION_CONFLICT,RADAR_QUOTE_EXCEEDEDy losRefusalCodea frases de producto. - Rollout:
allowedWorkspaces→ 3 workspaces amigos → plan Founder completo. Explorer con su tope diario. - Docs:
update-docspara la sección de Luna en docs.driftless.icu.
Después de beta (no se planifica aún)
- Precio para el enrichment de empresa de Radar (
enrichCompanies): hoy no tiene operación de débito y por eso el código lo limita a una pasada por run; el enrichment de contacto ya está cobrado (operacióncontacts, 2 créditos por persona). - Conector de correo en el broker (Gmail u Outlook) para operar la bandeja propia de la persona (leer respuestas, enviar un correo suelto desde su cuenta). No es un bloqueante para secuencias: el envío ya funciona hoy vía Nylas (send-only).
- Paridad MCP: exponer el mismo registro
DriftlessToolhacia afuera (external:true) para que Claude/ChatGPT tengan lo que Luna tiene. - CopilotKit solo si assistant-ui se queda corto en las tarjetas de decisión.
Riesgos y qué se hace con ellos
El primer PR (esta semana)
F0 completo + el esqueleto de F1: móduloluna/, contratos, rollout, sesión con eventos SSE, un turno que solo llama context_retrieve y market_search, la ruta /w/:slug/luna con el hilo de assistant-ui y el panel dividido. Sin tarjetas, sin dinero. Con eso ya se puede escribir “busca fabricantes de empaque en Jalisco” y ver a Luna pensar en vivo.