orquestra-mcp 1.16.0 → 1.16.3

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 **38 tools**
3
+ Servidor MCP (Model Context Protocol) para Orquestra. Expone **89 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,11 +192,13 @@ create/update sigue siendo criterio del agente y del humano.
192
192
 
193
193
  ## Estado
194
194
 
195
- 38 tools: 9 de lectura (contexto/tasks/search + dashboard/workspace/infra/
196
- paridad/setup guide/stage playbook) + 6 `list_*` + 19 de creación/edición
197
- (tasks/ideas/changelog/notes/docs + módulos/flujo/categorías/servicios/
198
- entornos/distribuciones/proyecto) + 2 de Agentes (create_agent_run/get_agent_run) +
199
- 2 compuestas (init_project, apply_stage_playbook). No hay `delete_*`, ni
200
- `update_idea`/`update_note`/`update_doc`/`update_changelog_entry`
201
- todavía. Publicado en npm como `orquestra-mcp` (unscoped) -- `npx
202
- orquestra-mcp setup`.
195
+ 89 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
196
+ sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
197
+ skills, search) + `list_*` de cada colección + creación/edición de tareas
198
+ (con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
199
+ docs, changelog (con corte de versión), módulos, flujo, categorías,
200
+ infraestructura, proyecto, contenido (canales, campañas, contenido,
201
+ Postproxy), agentes, skills y sugerencias + compuestas (init_project,
202
+ apply_stage_playbook, apply_platform_playbook, install_skill). Sin
203
+ `delete_*` salvo adjuntos e ítems de checklist. Publicado en npm como
204
+ `orquestra-mcp` (unscoped) -- `npx orquestra-mcp setup`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.16.0",
3
+ "version": "1.16.3",
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": {
@@ -71,6 +71,11 @@ export const PLATFORM_PLAYBOOKS = {
71
71
  title: 'Eliminar cuenta desde la app (Guideline 5.1.1(v))',
72
72
  body: 'Obligatorio desde 2022: si la app permite crear cuenta, tiene que permitir ELIMINARLA desde DENTRO de la app -- "escríbenos por correo" no cumple el requisito, aunque sea rápido y real. Verifica que exista un flujo de borrado self-service (botón "Eliminar cuenta" en Perfil/Ajustes, con confirmación) ANTES de enviar a revisión -- si no existe, Apple puede rechazar la app durante el review, incluso si el resto de la metadata está perfecta. Orquestra ya tiene este flujo (Zona de riesgo en Perfil, backend server-side que borra cuenta+datos), úsalo de referencia de implementación si tu app lo necesita desde cero.',
73
73
  },
74
+ {
75
+ key: 'sign_in_apple',
76
+ title: 'Sign in with Apple si hay login de terceros (Guideline 4.8)',
77
+ body: 'Si la app ofrece login con Google, Facebook u otro proveedor de terceros, Apple exige que TAMBIÉN ofrezcas "Sign in with Apple" como alternativa equivalente -- Guideline 4.8 ("Login Services"). No es una recomendación: si falta, Apple RECHAZA el build completo en revisión (motivo real, no hipotético) sin importar que el resto de la metadata esté perfecta. Implementación de referencia: botón nativo (`AuthenticationServices`, nonce) + capability "Sign in with Apple" habilitada en el App ID (Apple Developer Portal) + provider "Apple" activado en Firebase Auth si se usa Firebase. Si el login es solo email/password propio, sin ningún proveedor de terceros, esta regla no aplica.',
78
+ },
74
79
  ],
75
80
 
76
81
  tasks: [
@@ -97,7 +102,7 @@ export const PLATFORM_PLAYBOOKS = {
97
102
  {
98
103
  title: 'Preparar capturas de pantalla en los tamaños exactos que pide App Store Connect',
99
104
  description: 'Las dimensiones válidas cambian con cada generación de iPhone/iPad -- si App Store Connect rechaza una captura al subirla, el mensaje de error indica el tamaño exacto que espera para ese dispositivo; ajusta según ese mensaje real, no de memoria.',
100
- type: 'business', priority: 'medium', tags: ['publicacion', 'ios'], offsetDays: 7,
105
+ type: 'business', priority: 'medium', tags: ['publicacion', 'ios'], offsetDays: 7, checklistTool: 'screenshots',
101
106
  },
102
107
  {
103
108
  title: 'Completar el cuestionario de privacidad de la tienda (Data Collection)',
@@ -114,6 +119,11 @@ export const PLATFORM_PLAYBOOKS = {
114
119
  description: 'Guideline 5.1.1(v), obligatorio -- "escríbenos por correo" NO cumple el requisito. Tiene que haber un botón real de "Eliminar cuenta" (con confirmación) en Perfil/Ajustes que borre la cuenta y los datos sin salir de la app. Si no existe, constrúyelo ANTES de mandar a revisión, no después de un rechazo.',
115
120
  type: 'security', priority: 'high', tags: ['publicacion', 'ios', 'compliance'], offsetDays: 1,
116
121
  },
122
+ {
123
+ title: 'Verificar que si hay login de terceros (Google/Facebook/etc.), también esté "Sign in with Apple"',
124
+ description: 'Guideline 4.8, obligatorio -- si la app ofrece login con Google, Facebook u otro proveedor externo, Apple rechaza la app en revisión si no ofrece también "Sign in with Apple" como alternativa. No aplica si el login es solo email/password propio, sin proveedores de terceros. Si falta, agrégalo ANTES de mandar a revisión, no después de un rechazo.',
125
+ type: 'security', priority: 'high', tags: ['publicacion', 'ios', 'compliance'], offsetDays: 1,
126
+ },
117
127
  ],
118
128
  },
119
129
 
@@ -190,6 +200,11 @@ export const PLATFORM_PLAYBOOKS = {
190
200
  title: 'App access / Sign-in details',
191
201
  body: 'Si la app requiere login, "Add sign in details" pide un set de credenciales de prueba REALES (nombre descriptivo del set, usuario/email, contraseña) que el reviewer va a usar de verdad para entrar -- tienen que funcionar y, si es posible, mostrar contenido de ejemplo real (no una cuenta vacía cayendo en un onboarding). Marca el checkbox de "esta cuenta da acceso completo a todas las funciones" si no hay niveles pagos/premium.',
192
202
  },
203
+ {
204
+ key: 'google_signin_sha1',
205
+ title: 'Google Sign-In: SHA-1 de las TRES firmas en Firebase (debug, upload key y Play App Signing)',
206
+ body: 'Si la app tiene "Continuar con Google", Google valida la firma del APK contra los SHA-1 registrados en Firebase → Configuración del proyecto → app Android → "Agregar huella digital". Son TRES firmas distintas y cada una necesita su SHA-1: (1) debug (`~/.android/debug.keystore`, builds locales), (2) upload key (el keystore con el que firmas el AAB, `assembleRelease`), y (3) Play App Signing -- la clave con la que Google RE-FIRMA los APK que instalan testers y usuarios desde Play; NO es la upload key, y es la que siempre se olvida. Se ve en Play Console → Test and release → App integrity → Play Store protection → Play app signing → "App signing key certificate" (columna Classical key). Las dos primeras salen de `./gradlew signingReport`. Síntoma si falta alguna: al elegir la cuenta "no pasa nada" -- Play Services lo reporta como cancelación del usuario, no como error de configuración, así que no hay ningún mensaje que te avise. Después de registrarlas, vuelve a descargar google-services.json.',
207
+ },
193
208
  {
194
209
  key: 'delete_account',
195
210
  title: 'Eliminar cuenta desde la app (política de borrado de cuenta de Google Play)',
@@ -228,10 +243,15 @@ export const PLATFORM_PLAYBOOKS = {
228
243
  description: 'Política de borrado de cuenta de Google Play (equivalente a la Guideline 5.1.1(v) de Apple) -- lo ideal es un flujo real dentro de la app, no solo la "Delete account URL" de la ficha. Si no existe, constrúyelo antes de publicar.',
229
244
  type: 'security', priority: 'high', tags: ['publicacion', 'android', 'compliance'], offsetDays: 1,
230
245
  },
246
+ {
247
+ title: 'Registrar en Firebase el SHA-1 de las TRES firmas (debug, upload key y Play App Signing) si la app usa Google Sign-In',
248
+ description: 'Gotcha real: cada firma distinta del APK necesita su propio SHA-1 en Firebase (Configuración del proyecto → app Android → "Agregar huella digital"), y la que siempre se olvida es la de Play App Signing -- Google re-firma con ella los APK que se instalan desde Play, así que un login con Google que funciona en debug y en el release local falla en el build de los testers sin ningún mensaje (Play Services lo reporta como cancelación). Debug y upload key: `./gradlew signingReport`. Play App Signing: Play Console → App integrity → Play app signing → "App signing key certificate". Al terminar, re-descargar google-services.json.',
249
+ type: 'tech', priority: 'high', tags: ['publicacion', 'android', 'google-sign-in'], offsetDays: 1,
250
+ },
231
251
  {
232
252
  title: 'Preparar capturas de pantalla en los tamaños exactos que pide Play Console',
233
253
  description: 'Mínimo 2 capturas de teléfono, más el gráfico destacado (1024x500) y el ícono (512x512) -- Play Console valida las dimensiones al subir, ajusta según lo que rechace.',
234
- type: 'business', priority: 'medium', tags: ['publicacion', 'android'], offsetDays: 7,
254
+ type: 'business', priority: 'medium', tags: ['publicacion', 'android'], offsetDays: 7, checklistTool: 'screenshots',
235
255
  },
236
256
  {
237
257
  title: 'Completar el formulario de seguridad de los datos (Data safety)',
package/src/server.mjs CHANGED
@@ -10,6 +10,10 @@ import { PLATFORM_PLAYBOOKS } from './platformPlaybooks.mjs'
10
10
  import { SKILLS_CATALOG } from './skillsCatalog.mjs'
11
11
  import { parseSource, previewSkill, installSkill } from './skillInstall.mjs'
12
12
 
13
+ // Versión leída de package.json (3 sep 2026) -- antes estaba hardcodeada
14
+ // acá y quedaba atrás en cada bump (decía 1.16.0 con package.json en 1.16.2).
15
+ const PKG_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
16
+
13
17
  function errorResult(err) {
14
18
  const text = err instanceof ApiError ? `Error (${err.status}): ${err.message}` : `Error: ${err.message}`
15
19
  return { content: [{ type: 'text', text }], isError: true }
@@ -115,7 +119,7 @@ Si el usuario pregunta explícito qué Claude Skills de terceros le faltan (o pi
115
119
 
116
120
  export function createServer() {
117
121
  const config = loadConfig()
118
- const server = new McpServer({ name: 'orquestra-mcp', version: '1.16.0' }, { instructions: SERVER_INSTRUCTIONS })
122
+ const server = new McpServer({ name: 'orquestra-mcp', version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS })
119
123
 
120
124
  function resolveProjectId(projectId) {
121
125
  if (config.defaultProjectId) return config.defaultProjectId
@@ -613,10 +617,10 @@ export function createServer() {
613
617
  server.registerTool(
614
618
  'connect_postproxy_channel',
615
619
  {
616
- description: 'Inicia la conexión de un canal (facebook/instagram/tiktok) 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, pages} -- solo pasa con facebook, cuando ya hay un login de Facebook conectado en la cuenta (una sola cuenta da acceso a varias Pages, no hace falta repetir el OAuth) y hace falta elegir cuál Page 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. No confundir con connect_channel_to_buttondown -- esto es solo para los tipos que soporta Postproxy.',
620
+ 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, 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. Solo para los tipos que soporta Postproxy -- un canal newsletter (Buttondown) se conecta desde la web, no con esta tool.',
617
621
  inputSchema: {
618
622
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
619
- channelId: z.string().describe('Canal a conectar (ver list_channels) -- su `type` tiene que ser facebook/instagram/tiktok'),
623
+ channelId: z.string().describe('Canal a conectar (ver list_channels) -- su `type` tiene que ser facebook/instagram/tiktok/linkedin'),
620
624
  platform: z.string().describe('Plataforma tal cual la espera Postproxy (mismo valor que channel.type -- ej. "instagram")'),
621
625
  },
622
626
  },
@@ -1518,7 +1522,7 @@ export function createServer() {
1518
1522
 
1519
1523
  registerCreate('create_channel', '/v1/channels', 'Crea un canal (cuenta de contenido) en un proyecto -- ej. "LinkedIn", "Blog de Atlas". No conecta ninguna cuenta real todavía (eso es OAuth, fuera de alcance por ahora); solo registra el canal para poder programar contenido en él.', {
1520
1524
  name: z.string().describe('Ej. "LinkedIn"'),
1521
- type: z.string().optional().describe('linkedin | x | blog | newsletter | instagram | other -- libre'),
1525
+ type: z.string().optional().describe('facebook | instagram | tiktok | linkedin | x | blog | newsletter | other -- libre. facebook/instagram/tiktok/linkedin se conectan después con connect_postproxy_channel'),
1522
1526
  handle: z.string().optional().describe('Ej. "@atlaspagos" o una URL/dominio'),
1523
1527
  color: z.string().optional(),
1524
1528
  cadenceCount: z.number().optional().describe('Ritmo objetivo de publicaciones, ej. 2'),
@@ -1589,6 +1593,8 @@ export function createServer() {
1589
1593
  saves: z.number().optional().describe('Guardados -- entrada manual, típico de X/Instagram (solo con channelId suelto)'),
1590
1594
  avgReadMinutes: z.number().optional().describe('Minutos de lectura media -- entrada manual, típico de blog (solo con channelId suelto)'),
1591
1595
  imagePrompt: z.string().optional().describe('Descripción visual en inglés para generar la portada -- no genera la imagen, solo guarda el texto (solo con channelId suelto)'),
1596
+ recommendedMedia: z.enum(['image', 'video']).optional().describe('Qué conviene más de portada para esta pieza -- puramente informativo, ningún flujo cambia solo por esto (solo con channelId suelto)'),
1597
+ videoPrompt: z.string().optional().describe('Análogo a imagePrompt pero para video: descripción de la escena en movimiento, en inglés, para pegar en un generador de video externo -- Orquestra no genera video, solo guarda el texto (solo con channelId suelto)'),
1592
1598
  language: z.string().optional().describe('\'en\' si esta pieza es la traducción de otra. Sin valor = idioma original.'),
1593
1599
  translationOf: z.string().optional().describe('Si esta pieza es una traducción, id del content original'),
1594
1600
  })
@@ -1616,6 +1622,8 @@ export function createServer() {
1616
1622
  saves: z.number().optional(),
1617
1623
  avgReadMinutes: z.number().optional(),
1618
1624
  imagePrompt: z.string().optional(),
1625
+ recommendedMedia: z.enum(['image', 'video']).optional(),
1626
+ videoPrompt: z.string().optional(),
1619
1627
  language: z.string().optional(),
1620
1628
  translationOf: z.string().optional(),
1621
1629
  })
@@ -1628,24 +1636,26 @@ export function createServer() {
1628
1636
  // Un agente tiene un `kind` atado a una máquina (runnerId); los 3 kinds de
1629
1637
  // hoy (23 ago 2026: qa/security/performance) exigen stack+localPath (un
1630
1638
  // checkout real en esa máquina), y performance además perfUrl (URL viva
1631
- // contra la que corre Lighthouse). Los 3 solo tienen lógica real para
1632
- // stack="web" por ahora. "orquestra-qa-agent setup" crea/actualiza solo
1633
- // agentes kind="qa" -- security/performance se crean a mano acá o desde
1634
- // la web.
1635
- const AGENT_KINDS = ['qa', 'security', 'performance']
1639
+ // contra la que corre Lighthouse). qa/security/performance solo tienen
1640
+ // lógica real para stack="web" por ahora; screenshots es lo inverso, solo
1641
+ // stack="ios"|"android" (captura automática para el checklist de
1642
+ // publicación, ver ScreenshotChecklist.jsx en orquestra-web). "orquestra-qa-agent
1643
+ // setup" crea/actualiza solo agentes kind="qa" -- el resto se crean a
1644
+ // mano acá o desde la web.
1645
+ const AGENT_KINDS = ['qa', 'security', 'performance', 'screenshots']
1636
1646
  const AGENT_STACKS = ['web', 'ios', 'android', 'node', 'nestjs']
1637
1647
 
1638
1648
  registerRead('list_agents', '/v1/agents',
1639
1649
  '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.',
1640
- { kind: z.enum(AGENT_KINDS).optional().describe('Filtra por kind: "qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse)') },
1650
+ { kind: z.enum(AGENT_KINDS).optional().describe('Filtra por kind: "qa" (lint/test/build/E2E), "security" (npm audit + gitleaks), "performance" (build size + Lighthouse) o "screenshots" (captura de pantallas para el checklist de publicación, solo ios/android)') },
1641
1651
  (args, projectId) => ({ projectId }))
1642
1652
 
1643
- 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.', {
1653
+ registerCreate('create_agent', '/v1/agents', 'Crea un agente: asocia un kind ("qa" | "security" | "performance" | "screenshots") 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.', {
1644
1654
  name: z.string().describe('Nombre a mostrar, ej. "Agente web"'),
1645
- kind: z.enum(AGENT_KINDS).default('qa').describe('"qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse) -- default "qa"'),
1655
+ kind: z.enum(AGENT_KINDS).default('qa').describe('"qa" (lint/test/build/E2E), "security" (npm audit + gitleaks), "performance" (build size + Lighthouse) o "screenshots" (captura de pantallas para tiendas, solo stack ios/android) -- default "qa"'),
1646
1656
  runnerId: z.string().describe('ID de la máquina emparejada -- ver list_agents o Workspace → Máquinas'),
1647
- stack: z.enum(AGENT_STACKS).optional().describe('Requerido para los 3 kinds -- security/performance solo tienen lógica real para "web" hoy'),
1648
- localPath: z.string().optional().describe('Requerido para los 3 kinds -- ruta absoluta del checkout en esa máquina'),
1657
+ stack: z.enum(AGENT_STACKS).optional().describe('Requerido para los 4 kinds. security/performance solo tienen lógica real para "web" hoy; screenshots solo para "ios"/"android"'),
1658
+ localPath: z.string().optional().describe('Requerido para los 4 kinds -- ruta absoluta del checkout en esa máquina'),
1649
1659
  branch: z.string().optional().describe('Rama de este agente -- default "main" si no se manda'),
1650
1660
  distributionId: z.string().optional().describe('Distribución vinculada (repo/fuente de conocimiento)'),
1651
1661
  enabled: z.boolean().optional(),
@@ -1680,7 +1690,7 @@ export function createServer() {
1680
1690
  minPerfScore: z.number().optional(),
1681
1691
  })
1682
1692
 
1683
- 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.', {
1693
+ 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; screenshots: captura de pantallas para el checklist de publicación) 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.', {
1684
1694
  agentId: z.string().optional().describe('Agente específico -- si se manda, gana sobre stack/kind'),
1685
1695
  branch: z.string().optional().describe('Rama a probar -- default: la del agente, o "main"'),
1686
1696
  commit: z.string().optional(),
@@ -1688,7 +1698,7 @@ export function createServer() {
1688
1698
  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).'),
1689
1699
  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.'),
1690
1700
  runnerId: z.string().optional().describe('Máquina específica -- si se manda, gana sobre la del agente resuelto'),
1691
- 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.'),
1701
+ 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). Para kind="screenshots" es además OBLIGATORIO en la práctica: es la tarea a la que se van a adjuntar solas las capturas al terminar la corrida -- sin taskId, las capturas se suben pero nadie las liga a nada. No combinar con moduleId.'),
1692
1702
  moduleId: z.string().optional().describe('Igual que taskId pero acota a un módulo completo. No combinar con taskId.'),
1693
1703
  })
1694
1704
 
@@ -1812,7 +1822,7 @@ export function createServer() {
1812
1822
  server.registerTool(
1813
1823
  'apply_platform_playbook',
1814
1824
  {
1815
- 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.',
1825
+ 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.',
1816
1826
  inputSchema: {
1817
1827
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
1818
1828
  platform: z.enum(['ios', 'android']).describe('ios (App Store Connect) | android (Google Play Console) -- ver get_platform_playbook para el contenido'),
@@ -1820,40 +1830,58 @@ export function createServer() {
1820
1830
  moduleName: z.string().optional().describe('Nombre del módulo -- default: el de la plantilla'),
1821
1831
  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.'),
1822
1832
  confirmReplace: z.boolean().optional().describe('Default false. Mandá true solo cuando el usuario ya confirmó explícitamente reemplazar valores existentes que te devolvió publishFormConflict en un llamado anterior.'),
1833
+ force: z.boolean().optional().describe('Default false. Si este playbook ya se aplicó antes sobre esta distribución, por default la tool NO crea un segundo módulo/checklist -- mandá true solo cuando el usuario pidió explícitamente crear uno nuevo encima del que ya existe.'),
1823
1834
  },
1824
1835
  },
1825
- async ({ projectId, platform, distributionId, moduleName, publishForm, confirmReplace }) => {
1836
+ async ({ projectId, platform, distributionId, moduleName, publishForm, confirmReplace, force }) => {
1826
1837
  try {
1827
1838
  const pid = resolveProjectId(projectId)
1828
1839
  const playbook = PLATFORM_PLAYBOOKS[platform]
1829
- const moduleData = await apiPost(config, '/v1/modules', {
1830
- projectId: pid,
1831
- name: moduleName || playbook.moduleName,
1832
- description: playbook.moduleDescription,
1833
- tags: playbook.moduleTags,
1834
- })
1840
+ const appliedKey = `${distributionId}_publish`
1835
1841
 
1836
- const taskIds = []
1837
- for (const t of playbook.tasks) {
1838
- const dueDate = new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
1839
- const created = await apiPost(config, '/v1/tasks', {
1842
+ const project = await apiGet(config, `/v1/projects/${pid}`, {})
1843
+ const alreadyApplied = !!(project.appliedPlaybooks || {})[appliedKey]
1844
+
1845
+ const result = {}
1846
+
1847
+ if (alreadyApplied && !force) {
1848
+ result.alreadyApplied = true
1849
+ result.message = 'Este playbook ya se había aplicado antes sobre esta distribución (el módulo y sus tareas ya existen en el proyecto) -- no se creó nada de nuevo para no duplicarlo. Si el usuario pide explícitamente un checklist nuevo, volvé a llamar con force:true.'
1850
+ } else {
1851
+ const moduleData = await apiPost(config, '/v1/modules', {
1840
1852
  projectId: pid,
1841
- title: t.title,
1842
- description: t.description,
1843
- type: t.type,
1844
- priority: t.priority,
1845
- tags: t.tags,
1846
- moduleId: moduleData.id,
1847
- dueDate,
1853
+ name: moduleName || playbook.moduleName,
1854
+ description: playbook.moduleDescription,
1855
+ tags: playbook.moduleTags,
1848
1856
  })
1849
- taskIds.push(created.id)
1850
- }
1851
1857
 
1852
- const result = { module: moduleData, tasksCreated: taskIds.length, taskIds }
1858
+ const taskIds = []
1859
+ for (const t of playbook.tasks) {
1860
+ const dueDate = new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
1861
+ const created = await apiPost(config, '/v1/tasks', {
1862
+ projectId: pid,
1863
+ title: t.title,
1864
+ description: t.description,
1865
+ type: t.type,
1866
+ priority: t.priority,
1867
+ tags: t.tags,
1868
+ moduleId: moduleData.id,
1869
+ dueDate,
1870
+ })
1871
+ taskIds.push(created.id)
1872
+ }
1873
+
1874
+ result.module = moduleData
1875
+ result.tasksCreated = taskIds.length
1876
+ result.taskIds = taskIds
1877
+
1878
+ await apiPatch(config, '/v1/projects', { id: pid, appliedPlaybooks: { [appliedKey]: true } })
1879
+ result.markedApplied = true
1880
+ }
1853
1881
 
1854
1882
  if (publishForm && Object.keys(publishForm).length > 0) {
1855
1883
  const distributions = await apiGet(config, '/v1/distributions', { projectId: pid })
1856
- const dist = distributions.find(d => d.id === distributionId)
1884
+ const dist = distributions.items.find(d => d.id === distributionId)
1857
1885
  if (!dist) {
1858
1886
  throw new Error(`No se encontró la distribución "${distributionId}" en este proyecto -- revisá list_distributions.`)
1859
1887
  }