orquestra-mcp 1.18.1 → 1.20.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
@@ -1,6 +1,6 @@
1
1
  # orquestra-mcp
2
2
 
3
- Servidor MCP (Model Context Protocol) para Orquestra. Expone **89 tools**
3
+ Servidor MCP (Model Context Protocol) para Orquestra. Expone **90 tools**
4
4
  que llaman a la API de `orquestra-infra`: lectura de contexto/dashboard/
5
5
  workspace/infra/paridad/listados, escritura de tareas/ideas/changelog/
6
6
  notas/docs/módulos/flujo/categorías/infra/proyectos, y corridas de QA —
@@ -192,7 +192,7 @@ create/update sigue siendo criterio del agente y del humano.
192
192
 
193
193
  ## Estado
194
194
 
195
- 89 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
195
+ 90 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
196
196
  sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
197
197
  skills, search) + `list_*` de cada colección + creación/edición de tareas
198
198
  (con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.18.1",
3
+ "version": "1.20.0",
4
4
  "description": "Servidor MCP de Orquestra -- 89 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": {
package/src/api.mjs CHANGED
@@ -4,19 +4,23 @@ const DEFAULT_API_BASE = 'https://api.orquestra.me'
4
4
 
5
5
  export function loadConfig() {
6
6
  const fileConfig = readConfigFile()
7
- const apiBase = process.env.ORQUESTRA_API_BASE || fileConfig?.apiBase || DEFAULT_API_BASE
8
- const apiToken = process.env.ORQUESTRA_API_TOKEN || fileConfig?.apiToken
9
- const workspaceId = process.env.ORQUESTRA_WORKSPACE_ID || fileConfig?.workspaceId
7
+ // Todo sale de la MISMA carpeta mapeada (ver resolveProjectForCwd) --
8
+ // apiToken/workspaceId/projectId siempre son de una sola cuenta, nunca
9
+ // el token de una carpeta con el proyecto de otra.
10
+ const folderConfig = resolveProjectForCwd(fileConfig?.projects, process.cwd())
11
+
12
+ const apiBase = process.env.ORQUESTRA_API_BASE || folderConfig?.apiBase || DEFAULT_API_BASE
13
+ const apiToken = process.env.ORQUESTRA_API_TOKEN || folderConfig?.apiToken
14
+ const workspaceId = process.env.ORQUESTRA_WORKSPACE_ID || folderConfig?.workspaceId
10
15
 
11
16
  // ORQUESTRA_DEFAULT_PROJECT_ID (env var, ej. desde un MCP config con
12
17
  // scope local/project de Claude Code) siempre gana -- es una anulación
13
- // explícita. Si no hay env var, se busca en el mapa carpeta->proyecto del
14
- // config file, subiendo desde cwd (ver resolveProjectForCwd). Si ninguna
15
- // carpeta ancestro está mapeada, defaultProjectId queda undefined a
16
- // propósito -- resolveProjectId() en server.mjs debe fallar explícito en
17
- // vez de usar el proyecto de otra carpeta.
18
- const defaultProjectId = process.env.ORQUESTRA_DEFAULT_PROJECT_ID
19
- || resolveProjectForCwd(fileConfig?.projects, process.cwd())
18
+ // explícita. Si no hay env var, se usa el projectId de la carpeta
19
+ // resuelta arriba. Si ninguna carpeta ancestro está mapeada,
20
+ // defaultProjectId queda undefined a propósito -- resolveProjectId() en
21
+ // server.mjs debe fallar explícito en vez de usar el proyecto de otra
22
+ // carpeta.
23
+ const defaultProjectId = process.env.ORQUESTRA_DEFAULT_PROJECT_ID || folderConfig?.projectId
20
24
 
21
25
  const missing = []
22
26
  if (!apiToken) missing.push('ORQUESTRA_API_TOKEN')
@@ -4,13 +4,40 @@ import { join, dirname, sep } from 'path'
4
4
 
5
5
  export const CONFIG_PATH = join(homedir(), '.orquestra', 'mcp-config.json')
6
6
 
7
+ // Formato viejo (hasta v1.18.1): apiBase/apiToken/workspaceId GLOBALES a
8
+ // nivel raíz, projects={folderPath: projectId}. Eso pisaba la cuenta de
9
+ // TODAS las carpetas cada vez que corrías "setup" con una cuenta distinta
10
+ // -- no había forma real de tener un proyecto de una cuenta y otro de otra
11
+ // cuenta al mismo tiempo. Migra en el momento a projects={folderPath:
12
+ // {apiBase, apiToken, workspaceId, projectId}}, reusando las credenciales
13
+ // globales viejas para cada carpeta ya mapeada (siguen apuntando a la
14
+ // cuenta con la que se logueó por última vez -- si eso ya está mal, hay
15
+ // que volver a correr "setup" en esa carpeta puntual).
16
+ function migrateLegacyConfig(raw) {
17
+ const entries = Object.entries(raw.projects || {})
18
+ if (!entries.some(([, value]) => typeof value === 'string')) return raw
19
+
20
+ const { apiBase, apiToken, workspaceId } = raw
21
+ const projects = {}
22
+ for (const [folderPath, value] of entries) {
23
+ projects[folderPath] = typeof value === 'string'
24
+ ? { apiBase, apiToken, workspaceId, projectId: value }
25
+ : value
26
+ }
27
+ return { projects }
28
+ }
29
+
7
30
  export function readConfigFile() {
8
31
  if (!existsSync(CONFIG_PATH)) return null
32
+ let raw
9
33
  try {
10
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
34
+ raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
11
35
  } catch {
12
36
  return null
13
37
  }
38
+ const migrated = migrateLegacyConfig(raw)
39
+ if (migrated !== raw) writeConfigFile(migrated)
40
+ return migrated
14
41
  }
15
42
 
16
43
  export function writeConfigFile(data) {
@@ -18,21 +45,24 @@ export function writeConfigFile(data) {
18
45
  writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), { mode: 0o600 })
19
46
  }
20
47
 
21
- // Guarda/actualiza el projectId de UNA carpeta sin pisar las demás -- setup
22
- // se puede correr varias veces, una por cada carpeta contenedora de repos
23
- // (ver resolveProjectForCwd), y cada corrida solo debe tocar su propia
24
- // entrada.
48
+ // Guarda/actualiza la cuenta+proyecto de UNA carpeta sin pisar las demás --
49
+ // setup se puede correr varias veces, una por cada carpeta contenedora de
50
+ // repos (ver resolveProjectForCwd), y cada corrida solo debe tocar su
51
+ // propia entrada -- incluida la cuenta (apiToken/workspaceId), para poder
52
+ // tener una carpeta en una cuenta de Orquestra y otra carpeta en otra
53
+ // cuenta distinta al mismo tiempo.
25
54
  export function upsertProjectMapping({ apiBase, apiToken, workspaceId, folderPath, projectId }) {
26
- const existing = readConfigFile() || {}
55
+ const existing = readConfigFile() || { projects: {} }
27
56
  const projects = { ...(existing.projects || {}) }
28
- projects[folderPath] = projectId
29
- writeConfigFile({ apiBase, apiToken, workspaceId, projects })
57
+ projects[folderPath] = { apiBase, apiToken, workspaceId, projectId }
58
+ writeConfigFile({ projects })
30
59
  }
31
60
 
32
61
  // Busca, subiendo desde cwd hacia la raíz, la carpeta mapeada más específica
33
62
  // -- mismo criterio que .editorconfig/.gitconfig: el ancestro más cercano
34
- // gana, así que un repo individual con su propio mapeo puede sobreescribir
35
- // el de la carpeta contenedora que lo engloba.
63
+ // gana, así que un repo individual con su propio mapeo (y su propia cuenta)
64
+ // puede sobreescribir el de la carpeta contenedora que lo engloba. Devuelve
65
+ // el objeto {apiBase, apiToken, workspaceId, projectId} de esa carpeta.
36
66
  export function resolveProjectForCwd(projects, cwd) {
37
67
  if (!projects) return undefined
38
68
  let best = null
@@ -265,4 +265,122 @@ export const PLATFORM_PLAYBOOKS = {
265
265
  },
266
266
  ],
267
267
  },
268
+ web: {
269
+ label: 'Web — SEO y analítica',
270
+ color: '#4A7CFF',
271
+ // Sin formulario de tienda ni "Completar con IA": una web no declara
272
+ // nada en una consola, solo tiene el checklist y la guía de referencia
273
+ // (DistributionGuide.jsx esconde la columna del formulario si es false).
274
+ hasForm: false,
275
+ consoleName: 'SEO y analítica',
276
+ moduleName: 'SEO y analítica web',
277
+ moduleDescription: 'Checklist para que la web/landing nazca medible e indexable: Search Console, analítica, rendimiento y SEO básico, para que no se olvide nada al lanzar.',
278
+ moduleTags: ['playbook', 'seo', 'analitica'],
279
+
280
+ referenceGuide: [
281
+ {
282
+ key: 'search_console',
283
+ title: 'Google Search Console',
284
+ body: 'Sin esto Google no te dice si te indexó ni por qué no apareces. Agrega la propiedad (mejor tipo "Dominio", verificando por registro DNS TXT, así cubre www, http/https y subdominios), envía el sitemap.xml y revisa el informe de Páginas indexadas a la semana. Es gratis y es la fuente real de errores de indexación y consultas de búsqueda.',
285
+ },
286
+ {
287
+ key: 'analytics',
288
+ title: 'Google Analytics 4 / Firebase Analytics',
289
+ body: 'Mide visitas, origen y conversiones. Si el sitio ya usa Firebase, activa Google Analytics en el proyecto y usa el SDK web (measurementId); si no, crea una propiedad GA4 y pega la etiqueta. Define al menos un evento de conversión (registro, clic en CTA, descarga). Si el sitio sirve a usuarios de la UE/México, acuerda el aviso de cookies y consentimiento antes de activar.',
290
+ },
291
+ {
292
+ key: 'bing_webmaster',
293
+ title: 'Bing Webmaster Tools (opcional)',
294
+ body: 'Importa la propiedad directo desde Search Console en un clic. Cubre Bing, DuckDuckGo y parte de los asistentes con IA que usan su índice.',
295
+ },
296
+ {
297
+ key: 'sitemap_robots',
298
+ title: 'sitemap.xml y robots.txt',
299
+ body: 'Ambos en la raíz del dominio y respondiendo 200. robots.txt debe apuntar al sitemap y NO bloquear todo (Disallow: /) por un resto del entorno de staging. En SPAs (React/Vite) verifica que el sitemap lo genere el build, no que sea un archivo olvidado a mano.',
300
+ },
301
+ {
302
+ key: 'meta_tags',
303
+ title: 'Título, meta description y etiquetas por página',
304
+ body: 'Cada página indexable necesita <title> único (≤60 caracteres) y meta description (≤155) que venda el clic, un solo <h1>, y <link rel="canonical">. Una SPA que sirve el mismo <title> en todas las rutas pierde casi todo el SEO: usa prerender/SSR o un manejador de head por ruta.',
305
+ },
306
+ {
307
+ key: 'social_preview',
308
+ title: 'Open Graph / Twitter Cards',
309
+ body: 'og:title, og:description y og:image (1200x630) para que al compartir el enlace en WhatsApp, LinkedIn o X se vea una tarjeta, no una URL pelona. Pruébalo pegando el enlace en cada red antes de lanzar.',
310
+ },
311
+ {
312
+ key: 'performance',
313
+ title: 'Rendimiento: Core Web Vitals / PageSpeed',
314
+ body: 'Mide con PageSpeed Insights (móvil primero). Los puntos que casi siempre pesan: imágenes sin comprimir ni dimensiones (usa WebP/AVIF y lazy-loading), fuentes bloqueantes, JS sin dividir. LCP < 2.5s, CLS < 0.1, INP < 200ms son los umbrales de "bueno" de Google.',
315
+ },
316
+ {
317
+ key: 'structured_data',
318
+ title: 'Datos estructurados (schema.org)',
319
+ body: 'JSON-LD de Organization (y SoftwareApplication, FAQPage o Product según el sitio) para optar a resultados enriquecidos. Valida con la prueba de resultados enriquecidos de Google antes de publicar.',
320
+ },
321
+ {
322
+ key: 'https_domain',
323
+ title: 'HTTPS, dominio canónico y redirecciones',
324
+ body: 'Elige una sola versión (con o sin www) y redirige 301 las demás, incluido http → https. Evita cadenas de redirecciones y páginas duplicadas bajo dos URLs.',
325
+ },
326
+ {
327
+ key: 'privacy_legal',
328
+ title: 'Privacidad y consentimiento',
329
+ body: 'Si usas analítica o cookies, la política de privacidad debe mencionarlas y estar enlazada en el pie. Es requisito de Google Analytics aceptar sus términos de procesamiento de datos.',
330
+ },
331
+ ],
332
+
333
+ tasks: [
334
+ {
335
+ title: 'Agregar el sitio a Google Search Console y enviar el sitemap',
336
+ description: 'Crear la propiedad (tipo Dominio, verificación por DNS TXT), enviar sitemap.xml y confirmar que no marque errores de indexación. Sin esto no hay forma de saber si Google ve el sitio.',
337
+ type: 'tech', priority: 'high', tags: ['seo', 'web', 'search-console'], offsetDays: 1,
338
+ },
339
+ {
340
+ title: 'Instalar analítica (Google Analytics 4 / Firebase Analytics) y definir una conversión',
341
+ description: 'Activar GA4 (o Google Analytics en el proyecto Firebase y usar el SDK web con measurementId), confirmar en Tiempo real que registra visitas propias y marcar al menos un evento como conversión (registro, CTA, descarga).',
342
+ type: 'tech', priority: 'high', tags: ['seo', 'web', 'analytics'], offsetDays: 2,
343
+ },
344
+ {
345
+ title: 'Verificar que sitemap.xml y robots.txt respondan 200 y no bloqueen el sitio',
346
+ description: 'Abrirlos en el navegador en el dominio de producción. Revisar que robots.txt no tenga "Disallow: /" heredado de staging y que apunte al sitemap. En SPAs, comprobar que el hosting no devuelva index.html para estas rutas.',
347
+ type: 'tech', priority: 'high', tags: ['seo', 'web'], offsetDays: 2,
348
+ },
349
+ {
350
+ title: 'Revisar título, meta description, h1 y canonical de cada página indexable',
351
+ description: 'Un <title> único (≤60) y meta description (≤155) por página, un solo h1 y canonical. Si es SPA, confirmar que cada ruta los entrega al rastreador (prerender/SSR o manejador de head).',
352
+ type: 'tech', priority: 'medium', tags: ['seo', 'web'], offsetDays: 3,
353
+ },
354
+ {
355
+ title: 'Configurar Open Graph / Twitter Cards con imagen 1200x630',
356
+ description: 'og:title, og:description, og:image y twitter:card. Probar el enlace pegándolo en WhatsApp, LinkedIn y X.',
357
+ type: 'business', priority: 'medium', tags: ['seo', 'web', 'social'], offsetDays: 3,
358
+ },
359
+ {
360
+ title: 'Medir y corregir Core Web Vitals en PageSpeed Insights (móvil)',
361
+ description: 'Correr PageSpeed Insights sobre la home y las páginas clave. Objetivo: LCP < 2.5s, CLS < 0.1, INP < 200ms. Atacar primero imágenes sin comprimir, fuentes bloqueantes y JS sin dividir.',
362
+ type: 'tech', priority: 'medium', tags: ['seo', 'web', 'performance'], offsetDays: 5,
363
+ },
364
+ {
365
+ title: 'Fijar dominio canónico y redirecciones 301 (www / http → https)',
366
+ description: 'Elegir una sola versión del dominio y redirigir las demás con 301, sin cadenas de redirecciones. Confirmar HTTPS válido.',
367
+ type: 'tech', priority: 'medium', tags: ['seo', 'web'], offsetDays: 3,
368
+ },
369
+ {
370
+ title: 'Agregar datos estructurados (JSON-LD) y validarlos',
371
+ description: 'Organization como mínimo, más SoftwareApplication/FAQPage/Product según el sitio. Validar con la prueba de resultados enriquecidos de Google.',
372
+ type: 'tech', priority: 'low', tags: ['seo', 'web'], offsetDays: 7,
373
+ },
374
+ {
375
+ title: 'Importar la propiedad a Bing Webmaster Tools',
376
+ description: 'Importar desde Search Console en un clic. Opcional, pero cubre Bing/DuckDuckGo y asistentes de IA que usan su índice.',
377
+ type: 'business', priority: 'low', tags: ['seo', 'web'], offsetDays: 7,
378
+ },
379
+ {
380
+ title: 'Verificar/crear política de privacidad que mencione la analítica y cookies',
381
+ description: 'Si el sitio usa analítica o cookies, la política de privacidad tiene que mencionarlo y estar enlazada en el pie. Revisar también si hace falta aviso de consentimiento de cookies.',
382
+ type: 'business', priority: 'medium', tags: ['seo', 'web', 'compliance'], offsetDays: 3, checkKey: 'privacy',
383
+ },
384
+ ],
385
+ },
268
386
  }
package/src/server.mjs CHANGED
@@ -369,7 +369,7 @@ export function createServer() {
369
369
  'get_platform_playbook',
370
370
  {
371
371
  description: 'Guía de referencia + checklist accionable para publicar en App Store Connect (iOS) o Google Play Console (Android) -- "Platform Playbook", paralelo a get_stage_playbook pero sobre una PLATAFORMA/distribución concreta, no la etapa del proyecto. No llama a la API -- datos estáticos. "referenceGuide" es texto de referencia (no genera tareas); "tasks" es el checklist accionable (misma forma que get_stage_playbook, se puede convertir en módulo + tareas reales).',
372
- inputSchema: { platform: z.enum(['ios', 'android']).describe('ios (App Store Connect) | android (Google Play Console)') },
372
+ inputSchema: { platform: z.enum(['ios', 'android', 'web']).describe('ios (App Store Connect) | android (Google Play Console) | web (SEO y analítica: Search Console, GA4/Firebase Analytics, sitemap, meta tags, Core Web Vitals)') },
373
373
  },
374
374
  async ({ platform }) => textResult(PLATFORM_PLAYBOOKS[platform])
375
375
  )
@@ -508,7 +508,7 @@ export function createServer() {
508
508
  categoryId: z.string().optional(),
509
509
  moduleId: z.string().describe('OBLIGATORIO -- módulo al que pertenece esta tarea, para poder agruparla/revisarla después. Si no existe uno relacionado, llamá primero a list_modules (para no duplicar) y create_module.'),
510
510
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('OBLIGATORIO -- fecha estimada de término (YYYY-MM-DD), calculada con criterio real según prioridad/tamaño del trabajo (mismo criterio que la "revisión de sincronización" de get_sync_report), nunca un valor arbitrario.'),
511
- reviewDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Fecha para volver a MIRAR esta tarea -- recordatorio, NO un entregable. Úsalo para tareas de espera sin deadline real (ej. "esperar respuesta de Apple y revisar en 5 días"): en ese caso NO llenes dueDate, llena reviewDate. Distinto de dueDate (fecha de término real de un entregable) -- una tarea puede tener ambos, uno solo, o ninguno. Dispara un aviso (notificación + correo) al assignedTo el día que llega, un solo disparo.'),
511
+ reviewDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Fecha para volver a MIRAR esta tarea -- recordatorio, NO un entregable. Para un aviso CON HORA (ej. en 5 horas) usa set_task_reminder. Úsalo para tareas de espera sin deadline real (ej. "esperar respuesta de Apple y revisar en 5 días"): en ese caso NO llenes dueDate, llena reviewDate. Distinto de dueDate (fecha de término real de un entregable) -- una tarea puede tener ambos, uno solo, o ninguno. Dispara un aviso (notificación + correo) al assignedTo el día que llega, un solo disparo.'),
512
512
  assignedTo: z.string().optional().describe('uid del miembro asignado'),
513
513
  tags: z.array(z.string()).optional(),
514
514
  scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
@@ -613,6 +613,70 @@ export function createServer() {
613
613
  }
614
614
  )
615
615
 
616
+ // Recordatorio con hora de una tarea (2 oct 2026, a pedido de José) --
617
+ // "recuérdame en 5 horas revisar X con Ana" / "mañana revisa Y para poder
618
+ // cerrarla". Azúcar sobre PATCH /v1/tasks { remindAt (epoch ms), remindNote }:
619
+ // el job orquestra-reminders (modo remindAt, cada ~15 min) avisa por
620
+ // notificación + correo a assignedTo (o createdBy) y deja remindAt en null.
621
+ // El cálculo de "en 5h"/"mañana" se hace ACÁ (hora local de la máquina del
622
+ // usuario), no en el server, que no conoce su zona horaria.
623
+ const REMIND_IN_RE = /^(\d+)\s*(m|min|h|hr|d)$/i
624
+ function resolveRemindAt({ remindIn, remindAt }) {
625
+ if (remindIn && remindAt) throw new Error('Manda remindIn O remindAt, no los dos.')
626
+ if (remindAt) {
627
+ const ms = Date.parse(remindAt)
628
+ if (Number.isNaN(ms)) throw new Error('remindAt inválido -- formato ISO 8601, idealmente con zona (ej. 2026-10-03T09:00:00-06:00).')
629
+ return ms
630
+ }
631
+ if (!remindIn) throw new Error('Falta remindIn (ej. "5h", "30m", "2d", "tomorrow") o remindAt (ISO 8601).')
632
+ const v = remindIn.trim().toLowerCase()
633
+ if (v === 'tomorrow' || v === 'mañana') {
634
+ const d = new Date()
635
+ d.setDate(d.getDate() + 1)
636
+ d.setHours(9, 0, 0, 0)
637
+ return d.getTime()
638
+ }
639
+ const m = REMIND_IN_RE.exec(v)
640
+ if (!m) throw new Error('remindIn inválido -- usa "30m", "5h", "2d" o "tomorrow" (mañana 9:00 hora local).')
641
+ const n = Number(m[1])
642
+ const unit = m[2][0] // m | h | d
643
+ const ms = unit === 'm' ? n * 60000 : unit === 'h' ? n * 3600000 : n * 86400000
644
+ return Date.now() + ms
645
+ }
646
+
647
+ server.registerTool(
648
+ 'set_task_reminder',
649
+ {
650
+ description: 'Programa (o quita) un recordatorio CON HORA sobre una tarea: a esa hora Orquestra avisa por notificación in-app + correo a la persona asignada (o a quien creó la tarea si no hay asignado), una sola vez. Úsalo para "recuérdame mañana revisar X para poder cerrarla" o "en 5 horas revisarla con Ana": pasa la nota con QUÉ hay que revisar/con quién. Resolución real ~15 min (no es un reloj al minuto). Para un recordatorio por DÍA sin hora sigue sirviendo reviewDate en update_task. Mandar clear=true quita el recordatorio pendiente.',
651
+ inputSchema: {
652
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
653
+ taskId: z.string().describe('Tarea sobre la que se programa el recordatorio'),
654
+ remindIn: z.string().optional().describe('Relativo a ahora: "30m", "5h", "2d" o "tomorrow" (mañana 9:00, hora local). Alternativa a remindAt.'),
655
+ remindAt: z.string().optional().describe('Momento exacto en ISO 8601, idealmente con zona horaria (ej. 2026-10-03T16:30:00-06:00). Alternativa a remindIn.'),
656
+ note: z.string().max(500).optional().describe('Qué hay que revisar o con quién (aparece en la notificación y el correo). Muy recomendable.'),
657
+ clear: z.boolean().optional().describe('true = quita el recordatorio pendiente de la tarea (ignora remindIn/remindAt).'),
658
+ },
659
+ },
660
+ async ({ projectId, taskId, remindIn, remindAt, note, clear }) => {
661
+ try {
662
+ const body = { projectId: resolveProjectId(projectId), id: taskId }
663
+ if (clear) {
664
+ body.remindAt = null
665
+ } else {
666
+ body.remindAt = resolveRemindAt({ remindIn, remindAt })
667
+ if (note !== undefined) body.remindNote = note
668
+ }
669
+ const data = await apiPatch(config, '/v1/tasks', body)
670
+ return textResult({
671
+ ...data,
672
+ reminder: clear ? 'quitado' : { remindAt: new Date(body.remindAt).toISOString(), note: note ?? null },
673
+ })
674
+ } catch (err) {
675
+ return errorResult(err)
676
+ }
677
+ }
678
+ )
679
+
616
680
  // Postproxy (integración de Contenido, 24 ago 2026) -- server.registerTool
617
681
  // directo, mismo criterio que merge_task/unmerge_task: son POST a
618
682
  // `${path}/${id}/connect-postproxy|publish-postproxy`, no un CRUD
@@ -622,7 +686,7 @@ export function createServer() {
622
686
  server.registerTool(
623
687
  'connect_postproxy_channel',
624
688
  {
625
- description: 'Inicia la conexión de un canal (facebook/instagram/tiktok/linkedin) a Postproxy. Devuelve UNA de dos formas: {url} -- OAuth hosteado por Postproxy, el usuario tiene que abrirlo y completarlo a mano (el agente no puede terminar un OAuth por su cuenta), pásale esa URL tal cual; o {needsPageSelection, profileId, profileLabel, pages} -- pasa con facebook y linkedin, cuando ya hay un login de esa red conectado en la cuenta (un solo login da acceso a varias Pages de Facebook, o al perfil personal y las Organization Pages de LinkedIn, no hace falta repetir el OAuth) y hace falta elegir cuál corresponde a este canal. Esa elección hoy es un flujo de la web (Cuentas conectadas) -- si te llega `needsPageSelection`, dile al usuario que complete la selección ahí, esta tool no tiene una contraparte para elegir la página. Modelo real de Postproxy: cada perfil (login) vive en UN solo profile group y Orquestra usa un group por proyecto -- el OAuth de esta tool deja el perfil nuevo en el group de este proyecto; una cuenta de Instagram/TikTok conectada desde otro proyecto NO se puede reusar acá (sería publicar en la cuenta de otro proyecto), hay que conectar la cuenta correcta. Solo para los tipos que soporta Postproxy -- un canal newsletter (Buttondown) se conecta desde la web, no con esta tool.',
689
+ description: 'Inicia la conexión de un canal (facebook/instagram/tiktok/linkedin) a Postproxy. DISPONIBLE BAJO SOLICITUD (1 oct 2026): si el workspace no tiene Postproxy habilitado, la API responde 403 `postproxy_not_enabled` -- no reintentes ni busques otra vía; dile al usuario que un admin la solicite desde Contenido → Cuentas conectadas → "Solicitar conexión" en la web (se habilita a mano, le avisan por correo). Puede seguir planeando y redactando la pieza mientras tanto. Devuelve UNA de dos formas: {url} -- OAuth hosteado por Postproxy, el usuario tiene que abrirlo y completarlo a mano (el agente no puede terminar un OAuth por su cuenta), pásale esa URL tal cual; o {needsPageSelection, profileId, profileLabel, pages} -- pasa con facebook y linkedin, cuando ya hay un login de esa red conectado en la cuenta (un solo login da acceso a varias Pages de Facebook, o al perfil personal y las Organization Pages de LinkedIn, no hace falta repetir el OAuth) y hace falta elegir cuál corresponde a este canal. Esa elección hoy es un flujo de la web (Cuentas conectadas) -- si te llega `needsPageSelection`, dile al usuario que complete la selección ahí, esta tool no tiene una contraparte para elegir la página. Modelo real de Postproxy: cada perfil (login) vive en UN solo profile group y Orquestra usa un group por proyecto -- el OAuth de esta tool deja el perfil nuevo en el group de este proyecto; una cuenta de Instagram/TikTok conectada desde otro proyecto NO se puede reusar acá (sería publicar en la cuenta de otro proyecto), hay que conectar la cuenta correcta. Solo para los tipos que soporta Postproxy -- un canal newsletter (Buttondown) se conecta desde la web, no con esta tool.',
626
690
  inputSchema: {
627
691
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
628
692
  channelId: z.string().describe('Canal a conectar (ver list_channels) -- su `type` tiene que ser facebook/instagram/tiktok/linkedin'),
@@ -1060,7 +1124,7 @@ export function createServer() {
1060
1124
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
1061
1125
  ideaId: z.string().describe('Idea -- usá search para encontrar su id'),
1062
1126
  text: z.string().describe('Texto del comentario'),
1063
- authorName: z.string().optional().describe('Default "IA" -- solo cambialo si el comentario es una cita textual de otra persona, no lo tuyo.'),
1127
+ authorName: z.string().optional().describe('Default: el nombre del agente registrado al instalar el MCP ("IA" si no hay) -- solo cambialo si el comentario es una cita textual de otra persona, no lo tuyo.'),
1064
1128
  },
1065
1129
  },
1066
1130
  async ({ projectId, ideaId, text, authorName }) => {
@@ -1836,7 +1900,7 @@ export function createServer() {
1836
1900
  description: 'Aplica el Platform Playbook de publicación (iOS App Store / Google Play) sobre una distribución real: crea el módulo + tareas de la plantilla (igual que apply_stage_playbook) y, si mandás publishForm, también completa el formulario de la Guía de publicación de esa distribución (mismo dato que llena DistributionGuide.jsx en la web). Solo completá campos para los que tengas una base real del proyecto (código, get_project_context, servicios/entornos configurados) -- nunca inventes pricing, availability, package/bundle ID, cuentas demo para el reviewer, o el nombre exacto de la app; dejalos afuera de publishForm para que el usuario los complete a mano. Si la distribución YA tiene valores guardados en alguna de las claves que mandás, la tool NO los sobreescribe en silencio -- devuelve publishFormConflict con el valor actual vs. el propuesto por cada clave en conflicto, sin tocar publishForm. Mostrale ese conflicto al usuario y volvé a llamar con confirmReplace:true SOLO si confirma explícitamente el reemplazo. Si este playbook YA se aplicó antes sobre esta misma distribución (módulo+tareas ya creados), la tool lo detecta sola (mismo mecanismo que el badge "Ya aplicado" de DistributionGuide.jsx) y NO vuelve a crear módulo/tareas duplicados -- devuelve alreadyApplied:true. Volvé a llamar con force:true SOLO si el usuario pide explícitamente un checklist nuevo encima del que ya existe.',
1837
1901
  inputSchema: {
1838
1902
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
1839
- platform: z.enum(['ios', 'android']).describe('ios (App Store Connect) | android (Google Play Console) -- ver get_platform_playbook para el contenido'),
1903
+ platform: z.enum(['ios', 'android', 'web']).describe('ios (App Store Connect) | android (Google Play Console) | web (SEO y analítica, sin formulario) -- ver get_platform_playbook para el contenido'),
1840
1904
  distributionId: z.string().describe('Distribución real sobre la que aplicar el playbook (ver list_distributions) -- ahí vive el publishForm existente, si lo hay.'),
1841
1905
  moduleName: z.string().optional().describe('Nombre del módulo -- default: el de la plantilla'),
1842
1906
  publishForm: z.record(z.string()).optional().describe('Respuestas a completar, clave = key de referenceGuide (ver get_platform_playbook). Omitido o vacío = solo crea módulo + tareas, sin tocar el formulario.'),
@@ -1890,7 +1954,9 @@ export function createServer() {
1890
1954
  result.markedApplied = true
1891
1955
  }
1892
1956
 
1893
- if (publishForm && Object.keys(publishForm).length > 0) {
1957
+ if (publishForm && Object.keys(publishForm).length > 0 && playbook.hasForm === false) {
1958
+ result.publishFormIgnored = 'La guía web no tiene formulario de publicación (no hay consola que declarar) -- publishForm se ignoró.'
1959
+ } else if (publishForm && Object.keys(publishForm).length > 0) {
1894
1960
  const distributions = await apiGet(config, '/v1/distributions', { projectId: pid })
1895
1961
  const dist = distributions.items.find(d => d.id === distributionId)
1896
1962
  if (!dist) {