orquestra-mcp 1.9.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -189,7 +189,7 @@ create/update sigue siendo criterio del agente y del humano.
189
189
  38 tools: 9 de lectura (contexto/tasks/search + dashboard/workspace/infra/
190
190
  paridad/setup guide/stage playbook) + 6 `list_*` + 19 de creación/edición
191
191
  (tasks/ideas/changelog/notes/docs + módulos/flujo/categorías/servicios/
192
- entornos/distribuciones/proyecto) + 2 de QA (create_qa_run/get_qa_run) +
192
+ entornos/distribuciones/proyecto) + 2 de Agentes (create_agent_run/get_agent_run) +
193
193
  2 compuestas (init_project, apply_stage_playbook). No hay `delete_*`, ni
194
194
  `update_idea`/`update_note`/`update_doc`/`update_changelog_entry`
195
195
  todavía. Publicado en npm como `orquestra-mcp` (unscoped) -- `npx
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.9.0",
4
- "description": "Servidor MCP de Orquestra -- 71 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
3
+ "version": "1.14.0",
4
+ "description": "Servidor MCP de Orquestra -- 78 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
5
5
  "type": "module",
6
6
  "bin": {
7
- "orquestra-mcp": "./bin/orquestra-mcp.mjs"
7
+ "orquestra-mcp": "bin/orquestra-mcp.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/api.mjs CHANGED
@@ -102,3 +102,16 @@ export async function apiDelete(config, path, { projectId, id }) {
102
102
  })
103
103
  return parseResponse(res)
104
104
  }
105
+
106
+ // PUT crudo a una presigned URL de S3 (createAttachmentUploadUrl en
107
+ // orquestra-infra) -- a diferencia de apiGet/apiPost/etc, este request NO
108
+ // va contra config.apiBase ni lleva el Bearer del MCP: la URL firmada YA
109
+ // es la autorización completa.
110
+ export async function putToPresignedUrl(uploadUrl, buffer, contentType) {
111
+ const res = await fetch(uploadUrl, {
112
+ method: 'PUT',
113
+ headers: { 'Content-Type': contentType },
114
+ body: buffer,
115
+ })
116
+ if (!res.ok) throw new Error(`Falló la subida a S3: HTTP ${res.status}`)
117
+ }
@@ -0,0 +1,50 @@
1
+ // Checklist de "fundamentos olvidados" -- prácticas chicas que casi ningún
2
+ // proyecto instrumenta por su cuenta pero suman mucho cuando faltan (idea
3
+ // de José, 21 ago 2026, a raíz de auditar Orquestra mismo y encontrar
4
+ // analytics/crash reporting ausentes en las 4 apps). Constantes estáticas,
5
+ // sin acceso a red ni a la base de datos -- mismo criterio que stages.mjs.
6
+ // get_foundations_checklist las expone tal cual; el agente conectado hace
7
+ // la detección real (grep del repo local) y usa report_foundation_gap para
8
+ // cada una que confirme ausente -- ver SERVER_INSTRUCTIONS.
9
+ export const FOUNDATIONS = [
10
+ {
11
+ key: 'analytics',
12
+ label: 'Analytics de producto',
13
+ category: 'observabilidad',
14
+ why: 'Sin eventos básicos (pantallas, acciones clave) no hay forma de saber qué usa la gente ni de tomar decisiones con datos.',
15
+ detectionHint: 'Buscar SDKs de analytics en dependencias (Firebase Analytics, Segment, Amplitude, PostHog, Mixpanel) o gtag/GA4 en el HTML -- y confirmar que de verdad se llame (logEvent/track), no solo que esté listada la dependencia.',
16
+ scales: ['app', 'platform'],
17
+ },
18
+ {
19
+ key: 'crash_reporting',
20
+ label: 'Reporte de errores y crashes',
21
+ category: 'observabilidad',
22
+ why: 'Sin esto, los crashes de usuarios reales son invisibles -- solo se enteran si alguien reporta a mano.',
23
+ detectionHint: 'Buscar Crashlytics/Sentry/Bugsnag en dependencias, y confirmar inicialización real en el entrypoint de la app (no solo la dependencia listada y sin usar).',
24
+ scales: ['app', 'platform'],
25
+ },
26
+ {
27
+ key: 'error_alerting',
28
+ label: 'Alertas de error del backend',
29
+ category: 'observabilidad',
30
+ why: 'Un log que nadie lee no es monitoreo -- sin una alarma real, un backend caído puede tardar horas o días en notarse.',
31
+ detectionHint: 'Buscar alarmas de CloudWatch con AlarmActions/SNS, integraciones de Sentry/PagerDuty, o un webhook de errores a Slack/email en la infraestructura.',
32
+ scales: ['platform'],
33
+ },
34
+ {
35
+ key: 'dependency_scanning',
36
+ label: 'Escaneo de vulnerabilidades de dependencias',
37
+ category: 'seguridad',
38
+ why: 'Dependencias con vulnerabilidades conocidas quedan sin parchear indefinidamente si nada avisa cuando aparecen.',
39
+ detectionHint: 'Buscar .github/dependabot.yml, renovate.json, o un paso de audit (npm audit / pip-audit / similar) en el CI.',
40
+ scales: ['app', 'platform'],
41
+ },
42
+ {
43
+ key: 'ci_pipeline',
44
+ label: 'CI antes de mergear',
45
+ category: 'calidad',
46
+ why: 'Sin lint/tests/build automáticos en cada PR, cada regresión depende de que alguien la note a mano antes de mergear.',
47
+ detectionHint: 'Buscar workflows de CI (.github/workflows/*.yml, GitLab CI, CircleCI) con jobs que corran en pull_request, no solo en push a main.',
48
+ scales: ['app', 'platform'],
49
+ },
50
+ ]
package/src/server.mjs CHANGED
@@ -1,7 +1,10 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
1
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
4
  import { z } from 'zod'
3
- import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, ApiError } from './api.mjs'
5
+ import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
4
6
  import { STAGES, PLAYBOOKS } from './stages.mjs'
7
+ import { FOUNDATIONS } from './foundations.mjs'
5
8
 
6
9
  function errorResult(err) {
7
10
  const text = err instanceof ApiError ? `Error (${err.status}): ${err.message}` : `Error: ${err.message}`
@@ -35,6 +38,15 @@ const PLATFORM_TYPES = ['android', 'ios', 'web', 'desktop-mac', 'desktop-win', '
35
38
  const INFRA_STATUSES = ['operational', 'degraded', 'down']
36
39
  const STAGE_IDS = STAGES.map(s => s.id)
37
40
 
41
+ // Adjuntos -- entityType en la tool es singular (task/note/idea/doc, más
42
+ // natural para el modelo), la API usa el plural de la colección de
43
+ // Firestore (tasks/notes/ideas/docs) -- este mapa traduce entre los dos.
44
+ const ATTACHMENT_ENTITY_PATH = { task: 'tasks', note: 'notes', idea: 'ideas', doc: 'docs' }
45
+ const ATTACHMENT_ENTITY_TYPES = Object.keys(ATTACHMENT_ENTITY_PATH)
46
+ const ATTACHMENT_IMAGE_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
47
+ const MAX_ATTACHMENT_IMAGE_BYTES = 15 * 1024 * 1024
48
+ const MAX_ATTACHMENT_CODE_CHARS = 200_000
49
+
38
50
  const SETUP_GUIDE = {
39
51
  intro: 'Para configurar o sincronizar un proyecto completo: revisa primero con la tool de "check" de cada paso; si ya existe algo que coincide por name/type/title, edítalo con la tool de "update"; si no existe, créalo con la de "create". Sigue el orden -- los pasos 4 en adelante referencian ids de los pasos 1-3 (statusId, categoryId, moduleId, distId).',
40
52
  steps: [
@@ -65,6 +77,8 @@ Si get_project_context devuelve un proyecto vacío (sin módulos ni tareas), es
65
77
  - Si es una idea nueva: llama a get_setup_guide y ofrece un playbook de etapa "pre" (get_stage_playbook + apply_stage_playbook) -- ahí sí basta con eso, el proyecto de verdad no tiene nada más que sincronizar todavía.
66
78
  - Si ya existe y está avanzado: NO te limites a update_project ni a las distributions -- eso es solo el primer paso, no el trabajo completo. Llama a get_setup_guide y recorre sus 9 pasos en orden real (estatus/flujo, categorías, infraestructura completa -- servicios Y entornos, no solo distribuciones --, módulos, tareas, ideas, notas, changelog, docs), usando list_* de cada uno antes de crear para no duplicar. No asumas ni dejes vacío lo que no puedas inferir del código -- pregúntale al usuario los servicios/entornos reales, los módulos o áreas de trabajo, y las tareas pendientes reales que tenga en mente. Ofrece el playbook de etapa "build" (no "pre") solo como plantilla de módulo+tareas inicial, no como sustituto de sincronizar el resto.
67
79
 
80
+ Al aplicar un playbook con apply_stage_playbook -- sobre todo el de etapa "post" (marketing/contenido) -- nunca asumas herramientas o canales que este proyecto no tiene registrados en Orquestra. Antes de crear tareas de contenido/publicación, llama a list_channels: si viene vacío, la plantilla ya incluye una tarea para conectar canales reales primero -- no la saltees ni generes tareas del tipo "publica en Instagram/LinkedIn/TikTok" para un canal que el proyecto no tiene. Si list_channels SÍ trae canales reales, no te quedes solo con la tarea recordatorio de "programa tu contenido" -- además creá piezas REALES con create_content (un plan de lanzamiento concreto: teaser, anuncio, demo del caso de uso, historia de por qué se construyó, 1-2 testimonios, repartidos entre esos canales) usando siempre un channelId de list_channels, nunca un canal inventado. Mismo criterio para cualquier otra tarea del playbook que dependa de una integración -- si el hueco es real (ej. no hay servicios/distribuciones configurados), la tarea correcta es configurar eso primero, no asumir que ya existe.
81
+
68
82
  Durante el trabajo normal de código, sin que el usuario te lo pida explícitamente y SIN ESPERAR a que lo mencione (si tienes esta tool disponible en la sesión, se usa por default en todo trabajo real, no solo cuando alguien dice "usa Orquestra"):
69
83
  - Si te pide una funcionalidad o arreglo real (no una pregunta rápida ni exploración de código sin cambios), revisa con list_tasks/search si ya existe una tarea relacionada; si no, créala con create_task ANTES de empezar a trabajar, en el estatus inicial del proyecto (ver list_statuses). Si no hay un proyecto default configurado para esta carpeta, no te rindas ahí -- usa get_workspace_summary para encontrar el proyecto correcto y pásalo explícito como projectId en cada llamada.
70
84
  - Mientras trabajas, mueve la tarea por los estatus reales según avanzas (no la dejes en el inicial hasta el final). Al terminar, muévela a su estatus final con update_task y registra una entrada de changelog con create_changelog_entry -- así el usuario nunca tiene que documentar esto a mano. Si sabes en qué repo real estás trabajando (podés correr "git remote get-url origin" o ya lo sabes por la carpeta), pásalo SIEMPRE como "repo" ("owner/name") -- encontrado real (17 ago 2026): ni el hook local ni el GitHub Action ni la reconciliación de orquestra-infra lo estaban mandando pese a que el campo existe hace rato, así que ninguna entrada automática sabía de qué repo/plataforma venía. Si además la distribución de ese repo ya está identificada (ver list_distributions), pásala en "distributions" -- sin esto el changelog no puede filtrar/agrupar por plataforma.
@@ -75,7 +89,8 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
75
89
  - Si tienes acceso al repo real de una distribución (ej. puedes correr "git remote get-url origin" en ese código), llena repoUrl en create_distribution/update_distribution sin preguntar -- es solo informativo, no requiere credenciales. Esto es distinto de conectar el repo con PAT para el changelog automático (Workspace → Conexiones en orquestra-web): eso sí es un paso deliberado del usuario, con una credencial real -- nunca lo hagas tú solo ni sugieras que ya está conectado por haber llenado repoUrl.
76
90
  - Al crear o editar una tarea (o un módulo), llena TODO campo para el que tengas un dato real, no solo title/moduleId/dueDate/comment: type (no lo dejes en el default "feature" si en realidad es un bug/mejora/doc/etc.), tags, stack (tecnologías que genuinamente tocaste), scope (paridad multiplataforma, si aplica), y los campos de código (branch/prNumber/prUrl/prStatus/commitCount/resolvedCommitSha/resolvedCommitMessage/resolvedCommitUrl/resolvedCommitAt) cuando de verdad sepas esos valores (el branch en el que estás trabajando, el PR que abriste, el commit que resolvió la tarea). No dejes un campo en blanco solo porque no es obligatorio -- pero esto no es licencia para inventar: si no tenés el dato real (ej. no hay branch/PR porque el proyecto commitea directo a main), dejalo vacío en vez de rellenarlo con algo plausible pero falso -- mismo criterio que ya se pedía para resolvedCommitSha.
77
91
  - Si una tarea tiene más de una actividad o paso concreto, no los amontones como texto corrido en description -- usa el checklist (add_checklist_item, un ítem por actividad, opcionalmente con responsable) para que cada paso se pueda marcar independientemente. description queda para el contexto/propósito general de la tarea (el qué y el por qué), no como lista de pasos. Si estás editando una tarea existente cuya description ya creció así, migra esos puntos a checklist en vez de seguir agregando ahí.
78
- - No todas las tareas necesitan una corrida de QA -- clasifica antes de disparar una: cambios que afectan comportamiento real (features, bugs, UI, lógica de negocio, endpoints/API) sí la necesitan; cambios que no alteran comportamiento (docs, comentarios, refactors sin cambio funcional, config/tooling interno, notas/reuniones/ideas) no. Si decides que sí, revisa primero con list_qa_agents que exista un agente para el stack que tocaste (si no hay ninguno, créalo con create_qa_agent -- necesita una máquina ya emparejada, ver Workspace → QA) y llama a create_qa_run en la rama real de ese trabajo (manda taskId si el trabajo era de una tarea puntual -- si el agente tiene la revisión con IA activada, la acota a eso en vez de "revisa todo el proyecto"), dejando la tarea en "En revisión" -- NUNCA la muevas a "Listo" ni digas que "QA lo revisó" sin haber confirmado con get_qa_run un estatus terminal "passed" de verdad (las corridas son asíncronas, tardan minutos en otra máquina). Si tarda, dile al usuario que quedó en cola (con el runId) y que confirmas cuando termine -- no lo des por hecho nunca.
92
+ - Al cerrar una tarea en la que trabajaste de verdad (o al final de una sesión de trabajo real sobre ella), registrá las horas con log_time -- el tiempo REAL que llevó ese trabajo en esta sesión (aproximalo con honestidad: minutos de trabajo real, no la duración del chat ni la estimación de la tarea), con una nota corta de qué se hizo. Ese registro alimenta "Horas del equipo" en Inversión de Contenido y es la única forma de que el costo real del proyecto no dependa de que el usuario lo cargue a mano. No lo inventes ni lo infles; si genuinamente no podés estimar el tiempo de la sesión, no registres nada.
93
+ - No todas las tareas necesitan una corrida de QA -- clasifica antes de disparar una: cambios que afectan comportamiento real (features, bugs, UI, lógica de negocio, endpoints/API) sí la necesitan; cambios que no alteran comportamiento (docs, comentarios, refactors sin cambio funcional, config/tooling interno, notas/reuniones/ideas) no. Si decides que sí, revisa primero con list_agents que exista un agente (kind="qa") para el stack que tocaste (si no hay ninguno, créalo con create_agent -- necesita una máquina ya emparejada, ver Workspace → Máquinas) y llama a create_agent_run en la rama real de ese trabajo (manda taskId si el trabajo era de una tarea puntual -- si el agente tiene la revisión con IA activada, la acota a eso en vez de "revisa todo el proyecto"), dejando la tarea en "En revisión" -- NUNCA la muevas a "Listo" ni digas que "QA lo revisó" sin haber confirmado con get_agent_run un estatus terminal "passed" de verdad (las corridas son asíncronas, tardan minutos en otra máquina). Si tarda, dile al usuario que quedó en cola (con el runId) y que confirmas cuando termine -- no lo des por hecho nunca.
79
94
 
80
95
  Si el usuario pide "sincronizar"/"revisar que todo esté al día"/"verificar el proyecto" (o algo equivalente -- una auditoría manual, no el trabajo normal de arriba), hacé una "revisión de sincronización" completa, de cero, no un vistazo rápido:
81
96
  1. Llamá a get_sync_report -- devuelve lo objetivo (tareas sin moduleId, tareas activas sin dueDate, referencias huérfanas a un módulo/categoría/tarea/estatus que ya no existe) y activeTasksSnapshot (TODAS las tareas activas, no solo las incompletas).
@@ -86,11 +101,13 @@ Si el usuario pide "sincronizar"/"revisar que todo esté al día"/"verificar el
86
101
 
87
102
  No le preguntes al usuario por workspaceId ni projectId -- ya están configurados. Solo pregúntale por el proyecto si tiene más de uno y no queda claro a cuál te refieres.
88
103
 
89
- No crees tareas ni entradas de changelog por preguntas triviales o exploración sin cambios reales -- solo por trabajo que de verdad avanza el proyecto.`
104
+ No crees tareas ni entradas de changelog por preguntas triviales o exploración sin cambios reales -- solo por trabajo que de verdad avanza el proyecto.
105
+
106
+ Si estás iniciando una sesión de trabajo real en un proyecto que YA tiene código (no un "vacío" recién conectado, ver arriba) y hace rato no se corrió este chequeo, o el usuario pide explícito "revisa fundamentos"/"qué le falta a este proyecto" (no lo hagas en cada mensaje ni en exploración sin cambios): llamá a get_foundations_checklist y, con acceso real al filesystem del repo (grep de dependencias, archivos de config, workflows de CI -- seguí el detectionHint de cada ítem), confirmá cuáles de verdad faltan -- filtrá por "scales" contra el scale real del proyecto (ver project.scale en get_project_context; un ítem con scales:["platform"] no aplica a una landing). Por cada uno que confirmes ausente con evidencia concreta, llamá a report_foundation_gap con esa evidencia específica -- nunca lo reportes solo porque el checklist lo menciona, sin haber revisado el código de verdad. No repitas un chequeo ya hecho recientemente en la misma sesión.`
90
107
 
91
108
  export function createServer() {
92
109
  const config = loadConfig()
93
- const server = new McpServer({ name: 'orquestra-mcp', version: '1.0.0' }, { instructions: SERVER_INSTRUCTIONS })
110
+ const server = new McpServer({ name: 'orquestra-mcp', version: '1.14.0' }, { instructions: SERVER_INSTRUCTIONS })
94
111
 
95
112
  function resolveProjectId(projectId) {
96
113
  if (config.defaultProjectId) return config.defaultProjectId
@@ -318,6 +335,41 @@ export function createServer() {
318
335
  }
319
336
  )
320
337
 
338
+ server.registerTool(
339
+ 'get_foundations_checklist',
340
+ {
341
+ description: 'Checklist de "fundamentos olvidados" -- prácticas chicas (analytics, crash reporting, alertas de error, escaneo de dependencias, CI) que casi ningún proyecto instrumenta por su cuenta pero suman mucho cuando faltan. No llama a la API -- datos estáticos. Cada ítem trae "scales" (a qué project.scale aplica -- "landing" queda afuera de la mayoría a propósito) y "detectionHint" para que TÚ (con acceso real al filesystem del repo) confirmes si de verdad falta antes de reportarlo con report_foundation_gap -- ver SERVER_INSTRUCTIONS para cuándo correr este chequeo.',
342
+ },
343
+ async () => textResult(FOUNDATIONS)
344
+ )
345
+
346
+ server.registerTool(
347
+ 'report_foundation_gap',
348
+ {
349
+ description: 'Reporta un ítem de get_foundations_checklist que confirmaste ausente con evidencia real del repo (no lo llames sin haber revisado el código de verdad). Crea una sugerencia "foundation_gap", visible en el drawer "Sugerencias de IA" de la web -- aceptarla crea una tarea real (mismo mecanismo que accept_suggestion). Si ese foundationKey ya tiene una sugerencia (pending, aceptada o descartada) no crea una duplicada -- descartar una no la hace reaparecer en el siguiente chequeo.',
350
+ inputSchema: {
351
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
352
+ foundationKey: z.string().describe('key del ítem de get_foundations_checklist, ej. "analytics"'),
353
+ title: z.string().describe('Título corto y específico al proyecto real, ej. "Falta Crashlytics en la app iOS" -- no copies el label genérico del checklist tal cual.'),
354
+ reason: z.string().describe('Evidencia concreta que encontraste (qué buscaste, qué NO encontraste) y por qué importa acá -- no una explicación genérica del ítem.'),
355
+ },
356
+ },
357
+ async ({ projectId, foundationKey, title, reason }) => {
358
+ try {
359
+ const resolvedProjectId = resolveProjectId(projectId)
360
+ const data = await apiPost(config, '/v1/suggestions', {
361
+ projectId: resolvedProjectId,
362
+ kind: 'foundation_gap',
363
+ foundationKey, title, reason,
364
+ idempotencyKey: `foundation:${resolvedProjectId}:${foundationKey}`,
365
+ })
366
+ return textResult(data)
367
+ } catch (err) {
368
+ return errorResult(err)
369
+ }
370
+ }
371
+ )
372
+
321
373
  // ═══ ESCRITURA (existentes) ═════════════════════════════════════════════════
322
374
 
323
375
  registerCreate(
@@ -530,6 +582,127 @@ export function createServer() {
530
582
  }
531
583
  )
532
584
 
585
+ // Adjuntos (imágenes + código) en tasks/notes/ideas/docs -- mismo criterio
586
+ // que checklist: entityType/entityId van en el path (dos segmentos
587
+ // dinámicos), así que server.registerTool directo. Ver
588
+ // orquestra-infra/functions/api/lib/attachments.mjs.
589
+ server.registerTool(
590
+ 'list_attachments',
591
+ {
592
+ description: 'Lista los adjuntos (imágenes + código) de una task/note/idea/doc de Orquestra.',
593
+ inputSchema: {
594
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
595
+ entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña de los adjuntos'),
596
+ entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
597
+ },
598
+ },
599
+ async ({ projectId, entityType, entityId }) => {
600
+ try {
601
+ const plural = ATTACHMENT_ENTITY_PATH[entityType]
602
+ const data = await apiGet(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolveProjectId(projectId) })
603
+ return textResult(data)
604
+ } catch (err) {
605
+ return errorResult(err)
606
+ }
607
+ }
608
+ )
609
+
610
+ // filePath vs content son alternativas -- filePath es lo normal para
611
+ // evidencia real (una captura, o un archivo de código ya en el repo);
612
+ // content sirve para pegar un snippet generado en la sesión sin haberlo
613
+ // guardado a un archivo. NUNCA se acepta base64 en el input: para una
614
+ // imagen, este server lee los bytes él mismo (tiene filesystem real) y
615
+ // los sube directo a S3 -- mandarlos por el input de la tool los metería
616
+ // en el contexto del modelo, carísimo e innecesario.
617
+ server.registerTool(
618
+ 'add_attachment',
619
+ {
620
+ description: 'Adjunta evidencia (una imagen o un pedazo de código) a una task/note/idea/doc de Orquestra. Manda EXACTAMENTE uno de filePath (ruta local -- una imagen png/jpg/jpeg/gif/webp se sube a S3, cualquier otra extensión se lee como texto/código) o content (texto/código directo, sin tocar el filesystem). Nunca mandes bytes de imagen en base64 dentro de content -- usa filePath para que este server los suba él mismo.',
621
+ inputSchema: {
622
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
623
+ entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad a la que se adjunta'),
624
+ entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
625
+ filePath: z.string().optional().describe('Ruta local a un archivo -- imagen (png/jpg/jpeg/gif/webp) o código/texto.'),
626
+ content: z.string().optional().describe('Texto/código a adjuntar directo, sin leer un archivo.'),
627
+ language: z.string().optional().describe('Label libre del lenguaje del snippet (ej. "python", "diff") -- solo informativo, no tokeniza (Orquestra no resalta sintaxis).'),
628
+ fileName: z.string().optional().describe('Nombre a mostrar -- si no se manda, sale de filePath o de un default genérico.'),
629
+ caption: z.string().optional().describe('Descripción corta de la evidencia (ej. "output del test que falló").'),
630
+ },
631
+ },
632
+ async ({ projectId, entityType, entityId, filePath, content, language, fileName, caption }) => {
633
+ try {
634
+ if (!filePath && !content) throw new Error('Manda filePath o content')
635
+ if (filePath && content) throw new Error('Manda solo uno de filePath o content -- no los dos')
636
+ const resolvedProjectId = resolveProjectId(projectId)
637
+ const plural = ATTACHMENT_ENTITY_PATH[entityType]
638
+
639
+ let body
640
+ if (content) {
641
+ if (content.length > MAX_ATTACHMENT_CODE_CHARS) {
642
+ throw new Error(`El snippet supera el máximo de ${MAX_ATTACHMENT_CODE_CHARS} caracteres`)
643
+ }
644
+ body = { kind: 'code', content, language, fileName: fileName || 'snippet.txt', caption }
645
+ } else {
646
+ const stat = statSync(filePath)
647
+ const ext = path.extname(filePath).slice(1).toLowerCase()
648
+ const mime = ATTACHMENT_IMAGE_MIME_BY_EXT[ext]
649
+ const resolvedFileName = fileName || path.basename(filePath)
650
+ if (mime) {
651
+ if (stat.size > MAX_ATTACHMENT_IMAGE_BYTES) {
652
+ throw new Error(`La imagen supera el máximo de ${MAX_ATTACHMENT_IMAGE_BYTES / 1024 / 1024}MB`)
653
+ }
654
+ const buffer = readFileSync(filePath)
655
+ const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
656
+ projectId: resolvedProjectId, entityType: plural, entityId,
657
+ fileName: resolvedFileName, fileType: mime, fileSize: stat.size,
658
+ })
659
+ await putToPresignedUrl(uploadUrl, buffer, mime)
660
+ body = { kind: 'image', key, fileName: resolvedFileName, fileType: mime, fileSize: stat.size, caption }
661
+ } else {
662
+ const text = readFileSync(filePath, 'utf8')
663
+ if (text.length > MAX_ATTACHMENT_CODE_CHARS) {
664
+ throw new Error(`El archivo supera el máximo de ${MAX_ATTACHMENT_CODE_CHARS} caracteres`)
665
+ }
666
+ body = { kind: 'code', content: text, language, fileName: resolvedFileName, caption }
667
+ }
668
+ }
669
+
670
+ const data = await apiPost(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolvedProjectId, ...body })
671
+ return textResult(data)
672
+ } catch (err) {
673
+ return errorResult(err)
674
+ }
675
+ }
676
+ )
677
+
678
+ // Segunda excepción real a "creación + edición, nunca borrado" (junto a
679
+ // delete_checklist_item, ver CLAUDE.md) -- decisión consciente de José:
680
+ // un agente puede subir evidencia equivocada (screenshot que no era,
681
+ // snippet viejo) y conviene que la pueda limpiar sin depender de un
682
+ // humano. Borra también el objeto en S3 si el adjunto era una imagen
683
+ // (ver deleteAttachment en attachments.mjs de orquestra-infra).
684
+ server.registerTool(
685
+ 'delete_attachment',
686
+ {
687
+ description: 'Borra un adjunto (imagen o código) de una task/note/idea/doc de Orquestra -- limpia también el objeto en S3 si era una imagen. Segunda excepción a "nunca borrado" (junto a delete_checklist_item) -- usalo para limpiar evidencia subida por error, no como reemplazo de mantener historial.',
688
+ inputSchema: {
689
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
690
+ entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña del adjunto'),
691
+ entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
692
+ attachmentId: z.string().describe('ID del adjunto a borrar (ver list_attachments)'),
693
+ },
694
+ },
695
+ async ({ projectId, entityType, entityId, attachmentId }) => {
696
+ try {
697
+ const plural = ATTACHMENT_ENTITY_PATH[entityType]
698
+ const data = await apiDelete(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolveProjectId(projectId), id: attachmentId })
699
+ return textResult(data)
700
+ } catch (err) {
701
+ return errorResult(err)
702
+ }
703
+ }
704
+ )
705
+
533
706
  server.registerTool(
534
707
  'convert_checklist_item_to_task',
535
708
  {
@@ -554,6 +727,51 @@ export function createServer() {
554
727
  }
555
728
  )
556
729
 
730
+ // Horas de una tarea (22 ago 2026, tarea "Time-tracking real") -- mismo
731
+ // criterio de registro directo que las tools de checklist (taskId en el
732
+ // path). Solo listar + registrar: corregir/borrar una entrada es de la
733
+ // web, este canal no borra (ver gotcha "nunca borrado" en CLAUDE.md).
734
+ server.registerTool(
735
+ 'list_time_entries',
736
+ {
737
+ description: 'Lista los registros de horas trabajadas de una tarea de Orquestra (horas, fecha, nota, quién). El total ya vive desnormalizado en la tarea (hoursLogged) -- usa esta tool solo si necesitás el detalle entrada por entrada.',
738
+ inputSchema: {
739
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
740
+ taskId: z.string().describe('ID de la tarea'),
741
+ },
742
+ },
743
+ async ({ projectId, taskId }) => {
744
+ try {
745
+ const data = await apiGet(config, `/v1/tasks/${taskId}/time`, { projectId: resolveProjectId(projectId) })
746
+ return textResult(data)
747
+ } catch (err) {
748
+ return errorResult(err)
749
+ }
750
+ }
751
+ )
752
+
753
+ server.registerTool(
754
+ 'log_time',
755
+ {
756
+ description: 'Registra horas trabajadas en una tarea de Orquestra -- el time-tracking honesto que alimenta "Horas del equipo" en Inversión de Contenido (horas del mes × tarifa del proyecto). Registrá el tiempo REAL de trabajo en la tarea (el de esta sesión, o el que el usuario te diga), nunca un número inventado ni la estimación de la tarea (estimatedDays es otra cosa). La entrada queda atribuida al miembro del token (para un agente, "agent_<slug>").',
757
+ inputSchema: {
758
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
759
+ taskId: z.string().describe('ID de la tarea en la que se trabajó'),
760
+ hours: z.number().positive().describe('Horas trabajadas, > 0 -- admite fracciones (0.5 = media hora)'),
761
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('El día en que se TRABAJÓ (no cuándo se registra) -- default hoy. Es el corte mensual de Inversión.'),
762
+ note: z.string().optional().describe('Qué se hizo en esas horas, texto corto'),
763
+ },
764
+ },
765
+ async ({ projectId, taskId, ...body }) => {
766
+ try {
767
+ const data = await apiPost(config, `/v1/tasks/${taskId}/time`, { projectId: resolveProjectId(projectId), ...body })
768
+ return textResult(data)
769
+ } catch (err) {
770
+ return errorResult(err)
771
+ }
772
+ }
773
+ )
774
+
557
775
  registerCreate(
558
776
  'create_idea',
559
777
  '/v1/ideas',
@@ -700,7 +918,7 @@ export function createServer() {
700
918
  registerRead(
701
919
  'list_suggestions',
702
920
  '/v1/suggestions',
703
- 'Lista las sugerencias de IA de un proyecto (reescrituras de changelog, tareas que avanzaron/cerraron por evidencia, hallazgos de QA, gaps de setup/contenido/publicidad, fusiones de tareas sugeridas, ángulos de contenido). Por default solo trae las "pending" -- usá status="all" para ver también las ya aceptadas/descartadas, o un status puntual.',
921
+ 'Lista las sugerencias de IA de un proyecto (reescrituras de changelog, tareas que avanzaron/cerraron por evidencia, hallazgos de QA, gaps de setup/contenido/publicidad/fundamentos, fusiones de tareas sugeridas, ángulos de contenido). Por default solo trae las "pending" -- usá status="all" para ver también las ya aceptadas/descartadas, o un status puntual.',
704
922
  { status: z.string().optional().describe('Default "pending". "all" trae todas, o un status puntual ("accepted"/"dismissed").') },
705
923
  (args, projectId) => ({ projectId, status: args.status })
706
924
  )
@@ -712,8 +930,8 @@ export function createServer() {
712
930
  inputSchema: {
713
931
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
714
932
  suggestionId: z.string().describe('Ver list_suggestions'),
715
- moduleId: z.string().optional().describe('Solo para kind="qa_finding" -- ese branch crea una tarea nueva, que exige moduleId real (mismas reglas que create_task). Ignorado en el resto de los kinds.'),
716
- dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Solo para kind="qa_finding" -- mismo motivo que moduleId arriba.'),
933
+ moduleId: z.string().optional().describe('Solo para kind="qa_finding"|"foundation_gap" -- esos branches crean una tarea nueva, que exige moduleId real (mismas reglas que create_task). Ignorado en el resto de los kinds.'),
934
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Solo para kind="qa_finding"|"foundation_gap" -- mismo motivo que moduleId arriba.'),
717
935
  overrides: z.record(z.any()).optional().describe('Solo para kind="content_angle" -- el form final de la pieza de contenido a crear (puede diferir de lo que propuso la IA). Mismos campos que create_content.'),
718
936
  },
719
937
  },
@@ -838,6 +1056,40 @@ export function createServer() {
838
1056
  }
839
1057
  )
840
1058
 
1059
+ // Convertir nota → tarea (23 ago 2026) -- mismo patrón que
1060
+ // convert_idea_to_task. Compuesta: POST /v1/notes/{id}/convert-to-task en
1061
+ // orquestra-infra crea la tarea (reusando createTask tal cual, mismas
1062
+ // reglas que create_task) Y en el mismo paso liga los dos lados: la nota
1063
+ // queda con taskId (ya existía, antes solo se llenaba a mano) y la tarea
1064
+ // nueva queda con sourceNoteId (campo nuevo en tasks.json) apuntando de
1065
+ // vuelta a la nota que la originó. La nota NUNCA se borra -- queda
1066
+ // trazable. No se puede convertir una nota que ya tiene taskId.
1067
+ server.registerTool(
1068
+ 'convert_note_to_task',
1069
+ {
1070
+ description: 'Convierte una nota en una tarea real -- crea la tarea (mismas reglas que create_task: moduleId y dueDate son obligatorios, de la nota o de este llamado) y liga los dos lados: la nota queda con taskId ("Se convirtió en la tarea X") y la tarea nueva queda con sourceNoteId ("Proviene de la nota X"). La nota nunca se borra. No se puede convertir una nota que ya fue convertida antes.',
1071
+ inputSchema: {
1072
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
1073
+ noteId: z.string().describe('Nota a convertir -- ver list_notes/search para encontrar su id.'),
1074
+ title: z.string().optional().describe('Default: el título de la nota.'),
1075
+ description: z.string().optional().describe('Default: el body de la nota.'),
1076
+ moduleId: z.string().optional().describe('Solo si la nota no tiene moduleId propio -- si no existe uno relacionado, revisá list_modules/create_module primero.'),
1077
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('OBLIGATORIO -- las notas no tienen dueDate propio, calculala con criterio real (prioridad/tamaño del trabajo), nunca un valor arbitrario.'),
1078
+ priority: z.enum(TASK_PRIORITIES).optional().describe('Default: medium -- las notas no tienen priority propia.'),
1079
+ },
1080
+ },
1081
+ async ({ projectId, noteId, title, description, moduleId, dueDate, priority }) => {
1082
+ try {
1083
+ const data = await apiPost(config, `/v1/notes/${noteId}/convert-to-task`, {
1084
+ projectId: resolveProjectId(projectId), title, description, moduleId, dueDate, priority,
1085
+ })
1086
+ return textResult(data)
1087
+ } catch (err) {
1088
+ return errorResult(err)
1089
+ }
1090
+ }
1091
+ )
1092
+
841
1093
  registerCreate(
842
1094
  'create_doc',
843
1095
  '/v1/docs',
@@ -1186,39 +1438,50 @@ export function createServer() {
1186
1438
  translationOf: z.string().optional(),
1187
1439
  })
1188
1440
 
1189
- // ═══ QA (módulo de runners -- ver orquestra-qa-agent) ═══════════════════════
1441
+ // ═══ AGENTES (módulo de runners, generalizado 22 ago 2026 desde "QA" --
1442
+ // ver orquestra-qa-agent) ═══════════════════════════════════════════════════
1190
1443
  // Asíncronas: una corrida puede tardar minutos en otra máquina. Ver
1191
1444
  // SERVER_INSTRUCTIONS para la regla de no asumir "passed" sin confirmar.
1192
1445
  //
1193
- // Un agente = un stack de este proyecto (web/ios/android/node/nestjs)
1194
- // atado a una máquina (runnerId) y una ruta de checkout en ella
1195
- // (localPath) -- "orquestra-qa-agent setup" crea/actualiza el suyo solo;
1196
- // estas tools existen para revisarlos/crearlos desde acá también.
1197
- const QA_AGENT_STACKS = ['web', 'ios', 'android', 'node', 'nestjs']
1198
-
1199
- registerRead('list_qa_agents', '/v1/qaAgents',
1200
- 'Lista los agentes de QA (stack+máquina+ruta local) configurados en un proyecto. Úsala antes de create_qa_agent o de create_qa_run con stack, para saber si ya existe uno.',
1201
- {}, (args, projectId) => ({ projectId }))
1202
-
1203
- registerCreate('create_qa_agent', '/v1/qaAgents', 'Crea un agente de QA: asocia un stack de este proyecto a una máquina (runner, ver Workspace → QA) y una ruta de checkout en ESA máquina.', {
1446
+ // Un agente tiene un `kind` atado a una máquina (runnerId); los 3 kinds de
1447
+ // hoy (23 ago 2026: qa/security/performance) exigen stack+localPath (un
1448
+ // checkout real en esa máquina), y performance además perfUrl (URL viva
1449
+ // contra la que corre Lighthouse). Los 3 solo tienen lógica real para
1450
+ // stack="web" por ahora. "orquestra-qa-agent setup" crea/actualiza solo
1451
+ // agentes kind="qa" -- security/performance se crean a mano acá o desde
1452
+ // la web.
1453
+ const AGENT_KINDS = ['qa', 'security', 'performance']
1454
+ const AGENT_STACKS = ['web', 'ios', 'android', 'node', 'nestjs']
1455
+
1456
+ registerRead('list_agents', '/v1/agents',
1457
+ 'Lista los agentes (kind+máquina+stack+ruta local) configurados en un proyecto. Úsala antes de create_agent o de create_agent_run con stack, para saber si ya existe uno.',
1458
+ { kind: z.enum(AGENT_KINDS).optional().describe('Filtra por kind: "qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse)') },
1459
+ (args, projectId) => ({ projectId }))
1460
+
1461
+ registerCreate('create_agent', '/v1/agents', 'Crea un agente: asocia un kind ("qa" | "security" | "performance") a una máquina (runner, ver Workspace → Máquinas), un stack de este proyecto y una ruta de checkout en ESA máquina. kind="performance" además necesita perfUrl.', {
1204
1462
  name: z.string().describe('Nombre a mostrar, ej. "Agente web"'),
1205
- stack: z.enum(QA_AGENT_STACKS),
1206
- runnerId: z.string().describe('ID de la máquina emparejada -- ver list_qa_agents o Workspace → QA'),
1207
- localPath: z.string().describe('Ruta absoluta del checkout en esa máquina'),
1463
+ kind: z.enum(AGENT_KINDS).default('qa').describe('"qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse) -- default "qa"'),
1464
+ runnerId: z.string().describe('ID de la máquina emparejada -- ver list_agents o Workspace → Máquinas'),
1465
+ stack: z.enum(AGENT_STACKS).optional().describe('Requerido para los 3 kinds -- security/performance solo tienen lógica real para "web" hoy'),
1466
+ localPath: z.string().optional().describe('Requerido para los 3 kinds -- ruta absoluta del checkout en esa máquina'),
1208
1467
  branch: z.string().optional().describe('Rama de este agente -- default "main" si no se manda'),
1209
1468
  distributionId: z.string().optional().describe('Distribución vinculada (repo/fuente de conocimiento)'),
1210
1469
  enabled: z.boolean().optional(),
1211
1470
  createTasks: z.boolean().optional().describe('Si los fallos de este agente generan tareas automáticas -- default true'),
1212
1471
  openStatusId: z.string().optional().describe('Estatus donde se abren los bugs de este agente -- default: estatus inicial del proyecto'),
1213
1472
  closeStatusId: z.string().optional().describe('Estatus donde se mueven los bugs de este agente al cerrarse solos -- default: primer estatus final'),
1214
- testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) que la revisión con IA puede usar para iniciar sesión y revisar pantallas autenticadas'),
1473
+ testUserEmail: z.string().optional().describe('Específico de kind="qa" -- cuenta de prueba DEDICADA (no una credencial real) que la revisión con IA puede usar para iniciar sesión y revisar pantallas autenticadas'),
1215
1474
  testUserPassword: z.string().optional().describe('Password de testUserEmail'),
1216
- plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- recordatorio manual de "próxima corrida planeada". QA no tiene scheduling real (todo trigger es manual/push): esto NO dispara ninguna corrida sola, solo alimenta el calendario global.'),
1475
+ plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- recordatorio manual de "próxima corrida planeada". Ningún kind tiene scheduling real (todo trigger es manual/push): esto NO dispara ninguna corrida sola, solo alimenta el calendario global.'),
1476
+ perfUrl: z.string().optional().describe('Requerido si kind="performance" -- URL viva (ej. staging/prod) contra la que corre Lighthouse'),
1477
+ maxBundleSizeKb: z.number().optional().describe('Específico de kind="performance" -- umbral en KB para el paso de tamaño de build (default 5000)'),
1478
+ minPerfScore: z.number().optional().describe('Específico de kind="performance" -- score mínimo de Lighthouse (0-100) para pasar (default 50)'),
1217
1479
  })
1218
1480
 
1219
- registerUpdate('update_qa_agent', '/v1/qaAgents', 'Edita un agente de QA existente.', {
1481
+ registerUpdate('update_agent', '/v1/agents', 'Edita un agente existente.', {
1220
1482
  name: z.string().optional(),
1221
- stack: z.enum(QA_AGENT_STACKS).optional(),
1483
+ kind: z.enum(AGENT_KINDS).optional(),
1484
+ stack: z.enum(AGENT_STACKS).optional(),
1222
1485
  runnerId: z.string().optional(),
1223
1486
  localPath: z.string().optional(),
1224
1487
  branch: z.string().optional(),
@@ -1229,32 +1492,36 @@ export function createServer() {
1229
1492
  closeStatusId: z.string().optional(),
1230
1493
  testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) para que la revisión con IA inicie sesión'),
1231
1494
  testUserPassword: z.string().optional(),
1232
- plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_qa_agent.'),
1495
+ plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_agent.'),
1496
+ perfUrl: z.string().optional().describe('Ver create_agent -- específico de kind="performance"'),
1497
+ maxBundleSizeKb: z.number().optional(),
1498
+ minPerfScore: z.number().optional(),
1233
1499
  })
1234
1500
 
1235
- registerCreate('create_qa_run', '/v1/qa/runs', 'Dispara una corrida de QA (lint/tests/build/E2E según el stack) en la rama indicada. Necesita al menos un agente de QA activo para el stack (o falla explícito -- ver list_qa_agents/create_qa_agent). La corrida queda "queued" -- nunca asumas que pasó, confirma con get_qa_run.', {
1236
- agentId: z.string().optional().describe('Agente específico -- si se manda, gana sobre stack'),
1501
+ registerCreate('create_agent_run', '/v1/agent-runs', 'Dispara una corrida de agente (qa: lint/tests/build/E2E; security: npm audit + gitleaks; performance: build size + Lighthouse) en la rama indicada. Necesita al menos un agente activo para el stack (o falla explícito -- ver list_agents/create_agent). La corrida queda "queued" -- nunca asumas que pasó, confirma con get_agent_run.', {
1502
+ agentId: z.string().optional().describe('Agente específico -- si se manda, gana sobre stack/kind'),
1237
1503
  branch: z.string().optional().describe('Rama a probar -- default: la del agente, o "main"'),
1238
1504
  commit: z.string().optional(),
1239
1505
  trigger: z.enum(['manual', 'push', 'schedule']).optional().describe('Default: manual'),
1240
- stack: z.string().optional().describe('Stack a probar (ej. "web", "ios", "android") -- resuelve contra los agentes activos de ese stack. Falla si hay 0 (créalo con create_qa_agent) o más de 1 (usa agentId).'),
1506
+ stack: z.string().optional().describe('Stack a probar (ej. "web", "ios", "android") -- resuelve contra los agentes activos de ese stack. Falla si hay 0 (créalo con create_agent) o más de 1 (usa agentId o suma kind para desambiguar).'),
1507
+ kind: z.enum(AGENT_KINDS).optional().describe('Desambigua junto con stack cuando el proyecto tiene más de un agente para el mismo stack (ej. un "qa" y un "security", ambos "web") -- sin esto, resuelve por stack solo y falla explícito si hay más de uno.'),
1241
1508
  runnerId: z.string().optional().describe('Máquina específica -- si se manda, gana sobre la del agente resuelto'),
1242
1509
  taskId: z.string().optional().describe('Acota la revisión con IA (si el agente la tiene activada) a esta tarea en vez de "revisa todo el proyecto" -- útil para pedir "corre QA de esto que acabo de hacer". Sin efecto en los pasos de script (lint/test/build corren igual siempre). No combinar con moduleId.'),
1243
1510
  moduleId: z.string().optional().describe('Igual que taskId pero acota a un módulo completo. No combinar con taskId.'),
1244
1511
  })
1245
1512
 
1246
1513
  server.registerTool(
1247
- 'get_qa_run',
1514
+ 'get_agent_run',
1248
1515
  {
1249
- description: 'Consulta el estatus real de una corrida de QA por id (queued|running|passed|failed|error|cancelled, con steps y summary). Única forma válida de confirmar un resultado -- nunca lo des por hecho.',
1516
+ description: 'Consulta el estatus real de una corrida de agente por id (queued|running|passed|failed|error|cancelled, con steps y summary -- steps no trae el log completo de cada paso, solo status/seconds). Única forma válida de confirmar un resultado -- nunca lo des por hecho.',
1250
1517
  inputSchema: {
1251
- runId: z.string().describe('ID de la corrida, devuelto por create_qa_run'),
1518
+ runId: z.string().describe('ID de la corrida, devuelto por create_agent_run'),
1252
1519
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
1253
1520
  },
1254
1521
  },
1255
1522
  async ({ runId, projectId }) => {
1256
1523
  try {
1257
- const data = await apiGet(config, `/v1/qa/runs/${runId}`, { projectId: resolveProjectId(projectId) })
1524
+ const data = await apiGet(config, `/v1/agent-runs/${runId}`, { projectId: resolveProjectId(projectId) })
1258
1525
  return textResult(data)
1259
1526
  } catch (err) {
1260
1527
  return errorResult(err)
package/src/stages.mjs CHANGED
@@ -71,12 +71,13 @@ export const PLAYBOOKS = {
71
71
  { title: 'Preparar assets de tienda (ASO): capturas, descripción, keywords', description: '5-8 capturas que cuenten una historia (no pantallas sueltas), descripción con el pitch arriba, y keywords que tu público realmente busca.', type: 'business', priority: 'high', tags: ['marketing', 'aso'], offsetDays: 5 },
72
72
  { title: 'Publicar landing con captura de correos', description: 'Una página: pitch, 3 beneficios, capturas y un CTA (descargar o dejar correo). Los correos capturados son tu canal propio para siempre.', type: 'feature', priority: 'high', tags: ['marketing', 'landing'], offsetDays: 7 },
73
73
  { title: 'Configurar analytics de adquisición', description: 'Fuentes de tráfico, conversión de visita→registro→activación. Necesitas saber DE DÓNDE llegan los usuarios que sí se quedan.', type: 'tech', priority: 'medium', tags: ['analytics', 'adquisición'], offsetDays: 8 },
74
- { title: 'Plan de contenido de lanzamiento (2 semanas)', description: 'Programa las publicaciones en la sección Contenido: teaser, anuncio, demo del caso de uso principal, historia de por qué lo construiste, testimonios de beta testers.', type: 'business', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 10 },
74
+ { title: 'Conectar tus canales reales de contenido', description: 'Antes de programar nada: registra en Contenido → Cuentas conectadas los canales donde de verdad vas a publicar (redes, blog, newsletter). El plan de contenido de abajo solo sirve con canales reales, no uno genérico — si personalizas este playbook con IA y ya tienes canales conectados, te genera piezas reales directo.', type: 'tech', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 9 },
75
+ { title: 'Plan de contenido de lanzamiento (2 semanas)', description: 'Con tus canales ya conectados (tarea anterior), programa en Contenido: teaser, anuncio, demo del caso de uso principal, historia de por qué lo construiste, testimonios de beta testers — uno por canal real, no una lista genérica.', type: 'business', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 10 },
75
76
  { title: 'Lanzar en canales: Product Hunt, comunidades y redes', description: 'Elige 3 canales donde vive tu público (PH, subreddits, grupos de FB/Discord, foros del nicho). Un post honesto de creador funciona mejor que uno de venta.', type: 'business', priority: 'high', tags: ['marketing', 'lanzamiento'], offsetDays: 14 },
76
77
  { title: 'Contactar 10 personas con audiencia en el nicho', description: 'Creators, newsletters, podcasts o prensa pequeña del tema. Mensaje corto y personalizado + acceso gratis. Con que respondan 2, ganaste.', type: 'followup', priority: 'medium', tags: ['marketing', 'outreach'], offsetDays: 16 },
77
78
  { title: 'Activar loop de feedback de usuarios reales', description: 'Un canal permanente: encuesta in-app, correo de bienvenida que pregunta "¿qué te trajo aquí?", o botón de sugerencias. Todo aterriza en Ideas.', type: 'improvement', priority: 'medium', tags: ['feedback'], offsetDays: 18 },
78
79
  { title: 'Definir y medir activación y retención (D1 / D7 / D30)', description: 'Activación: % que llega al momento de valor. Retención: % que regresa al día 1, 7 y 30. Estos números te dicen si tienes producto o solo descargas.', type: 'business', priority: 'medium', tags: ['métricas'], offsetDays: 21 },
79
- { title: 'Ritmo de contenido post-lanzamiento (3 publicaciones/semana)', description: 'El lanzamiento es un día; el crecimiento es constancia. Deja programadas en Contenido las siguientes 2 semanas: tips de uso, casos, mejoras del changelog.', type: 'business', priority: 'medium', tags: ['marketing', 'contenido'], offsetDays: 24 },
80
+ { title: 'Ritmo de contenido post-lanzamiento (3 publicaciones/semana)', description: 'El lanzamiento es un día; el crecimiento es constancia. Deja programadas en Contenido las siguientes 2 semanas: tips de uso, casos, mejoras del changelog — repartidas entre tus canales reales conectados.', type: 'business', priority: 'medium', tags: ['marketing', 'contenido'], offsetDays: 24 },
80
81
  { title: 'Publicar release notes / changelog público v1', description: 'Convierte tu Changelog en comunicación: qué hay de nuevo, en lenguaje de usuario. Cada release es una excusa para volver a aparecer.', type: 'docs', priority: 'low', tags: ['changelog'], offsetDays: 26 },
81
82
  { title: 'Revisión semanal de métricas y próximos pasos', description: 'Reunión recurrente (aunque seas solo tú): adquisición, activación, retención, top feedback. Decide UNA mejora prioritaria por semana.', type: 'meeting', priority: 'medium', tags: ['métricas', 'ritmo'], offsetDays: 28 },
82
83
  ],