orquestra-mcp 1.16.3 → 1.18.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.mjs +21 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.16.3",
3
+ "version": "1.18.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/server.mjs CHANGED
@@ -97,6 +97,7 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
97
97
  - Si una nota que vas a crear (create_note) es claramente sobre un módulo o una tarea específica -- por ejemplo durante un setup guiado donde acabas de crear ambos -- mándale moduleId/taskId para ligarla, en vez de dejarla suelta. No inventes la relación si no es clara.
98
98
  - create_changelog_entry SIEMPRE deja la entrada en Unreleased (nunca mandes version salvo que el usuario te haya dado un número real) -- cortar una versión es release_changelog_version, una acción DELIBERADA que solo se llama si el usuario la pide explícitamente ("cortá la versión", "hacé el release de X") o confirma un número/fecha real que vos propusiste. No la dispares solo porque terminaste una tarea o un lote de trabajo grande.
99
99
  - 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.
100
+ - Paridad por producto: las distribuciones que son el mismo producto en distintas plataformas (típicamente Web/iOS/Android) comparten un productKey (list_distributions lo muestra). Si al crear una tarea con scope en una de ellas la respuesta trae parityAddedDistIds, el server sumó las hermanas como pendientes -- dícelo al usuario ("también quedó pendiente en iOS y Android") y no lo deshagas salvo que el trabajo sea genuinamente de una sola plataforma (entonces skipParity:true, o quítalas con update_task/scope explicando por qué). Al cerrar una tarea con scope multiplataforma, marca done:true SOLO en la plataforma donde de verdad se hizo (update_task/scope) -- el estatus "Listo" no mueve esos flags solo; dejar las hermanas en done:false es exactamente lo que hace que el desfase aparezca en get_parity_summary y en la revisión de sincronización (tasksClosedWithParityPending), que es el punto: que nunca se olvide el trabajo del otro lado. Si el proyecto tiene 2+ plataformas y ninguna tiene productKey (get_parity_summary → productGroups.configured=false), pregúntale al usuario cuáles son el mismo producto y configúralo con update_distribution -- nunca lo infieras solo del tipo de plataforma.
100
101
  - 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.
101
102
  - 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í.
102
103
  - 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.
@@ -107,6 +108,7 @@ Si el usuario pide "sincronizar"/"revisar que todo esté al día"/"verificar el
107
108
  2. Para cada tarea en tasksMissingModule: buscá con list_modules si hay uno que aplique de verdad; si no, create_module antes de asignarlo -- nunca dejes una sin módulo (ver moduleId obligatorio en create_task, esto es limpiar deuda vieja de antes de esa regla).
108
109
  3. Para CADA tarea de activeTasksSnapshot (no solo las que tienen dueDate/priority en blanco -- una prioridad "medium" puesta por default nunca se distingue de una elegida a propósito): revisá el contexto real (código, conversación, changelog, dependencias vía relatedTaskId) y asigná una fecha límite realista y un nivel de urgencia real con update_task si el valor actual no refleja la urgencia/plazo real -- no inventes una fecha arbitraria sin ninguna base ("en 3 días" porque sí); si genuinamente no hay forma de estimar una fecha con la información disponible, dejala en blanco en vez de inventar una.
109
110
  4. Las referencias huérfanas (tasksWithOrphanModuleId/CategoryId/RelatedTaskId/StatusId) casi nunca se pueden arreglar solas con criterio -- no adivines a qué debería apuntar. Marcalas para el resumen final, y si es obvio por contexto a qué debió apuntar, proponéselo al usuario en vez de aplicarlo directo.
111
+ 4b. Paridad (solo si productGroups.configured): tasksClosedWithParityPending son tareas ya cerradas con trabajo pendiente en una plataforma hermana (pendingDistNames). Para cada una decidí con el usuario: si en realidad YA se hizo ahí, marcá done:true en ese scope con update_task; si no se hizo, creá la tarea de esa plataforma (mismo módulo, relatedTaskId opcional) -- nunca marques done sin evidencia real. tasksMissingSiblingScope son tareas activas que tocan una hermana sin declarar a las demás: confirmá si aplica y extendé el scope, o dejalo si es trabajo de una sola plataforma.
110
112
  5. Cerrá SIEMPRE con un resumen estructurado en 3 grupos, aunque algún grupo quede vacío: **No tiene sentido / necesita tu decisión** (referencias huérfanas, contradicciones que encontraste), **Se llenó** (campos que estaban vacíos y ahora tienen un valor real, con qué tarea y qué campo), **Se completó** (lo que quedó totalmente resuelto en esta pasada -- ej. una tarea que ya tenía todo en orden, o un hueco que se cerró del todo). Si tocaste tareas de verdad (no solo leíste el reporte), dejá rastro con un comment en cada una vía update_task explicando qué cambiaste y por qué -- mismo criterio que un cambio de statusId, no debería quedar invisible.
111
113
 
112
114
  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.
@@ -250,7 +252,7 @@ export function createServer() {
250
252
  registerRead(
251
253
  'get_parity_summary',
252
254
  '/v1/parity',
253
- 'Desfases de paridad multiplataforma: tareas y módulos cuyo scope avanza en una distribución pero sigue pendiente en otra.',
255
+ 'Desfases de paridad multiplataforma: tareas y módulos cuyo scope avanza en una distribución pero sigue pendiente en otra HERMANA (mismo productKey -- ver productGroups en la respuesta; si configured=false, el proyecto nunca marcó qué plataformas son el mismo producto y todas se comparan entre sí: sugiérele al usuario configurarlo con update_distribution/productKey). singleDoneItems trae las candidatas a portar con missingDistIds concretos.',
254
256
  {},
255
257
  (args, projectId) => ({ projectId })
256
258
  )
@@ -506,10 +508,12 @@ export function createServer() {
506
508
  categoryId: z.string().optional(),
507
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.'),
508
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.'),
509
512
  assignedTo: z.string().optional().describe('uid del miembro asignado'),
510
513
  tags: z.array(z.string()).optional(),
511
514
  scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
512
- .describe('Alcance por distribución (paridad multiplataforma) -- para setearlo desde la creación en vez de crear y después update_task. Usa get_parity_summary/list_distributions para ver qué distId existen.'),
515
+ .describe('Alcance por distribución (paridad multiplataforma) -- para setearlo desde la creación en vez de crear y después update_task. Usa get_parity_summary/list_distributions para ver qué distId existen. Si una distId tiene hermanas (mismo productKey), el server suma las que falten solo, con done:false -- la respuesta trae parityAddedDistIds con lo que agregó: avísale al usuario que la tarea también quedó pendiente en esas plataformas.'),
516
+ skipParity: z.boolean().optional().describe('true = NO sumar las plataformas hermanas al scope aunque tengan el mismo productKey. Solo cuando el trabajo es genuinamente de UNA plataforma (ej. un ajuste de Gradle que no tiene contraparte en iOS) -- no lo mandes por default. Las tareas type="bug" nunca propagan, sin necesidad de esto.'),
513
517
  relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea. Esta tarea se muestra como bloqueada mientras esa otra no llegue a un estatus final (isFinal). Úsalo cuando el trabajo real depende de que otra tarea termine primero.'),
514
518
  location: z.string().optional().describe('Solo relevante para type "meeting"/"call" (plataforma o lugar, ej. "Zoom", "Oficina") o "followup"/"business" (contexto libre) -- ignóralo para el resto de los tipos.'),
515
519
  estimatedDays: z.number().optional().describe('Estimación en días de trabajo.'),
@@ -540,6 +544,7 @@ export function createServer() {
540
544
  categoryId: z.string().optional(),
541
545
  moduleId: z.string().optional(),
542
546
  dueDate: z.string().optional().describe('YYYY-MM-DD'),
547
+ reviewDate: z.string().optional().describe('YYYY-MM-DD. Ver create_task -- recordatorio para volver a revisar, distinto de dueDate. Mandá "" para quitarlo.'),
543
548
  assignedTo: z.string().optional(),
544
549
  tags: z.array(z.string()).optional(),
545
550
  scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
@@ -617,16 +622,17 @@ export function createServer() {
617
622
  server.registerTool(
618
623
  'connect_postproxy_channel',
619
624
  {
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.',
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.',
621
626
  inputSchema: {
622
627
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
623
628
  channelId: z.string().describe('Canal a conectar (ver list_channels) -- su `type` tiene que ser facebook/instagram/tiktok/linkedin'),
624
629
  platform: z.string().describe('Plataforma tal cual la espera Postproxy (mismo valor que channel.type -- ej. "instagram")'),
630
+ forceOAuth: z.boolean().optional().describe('true = iniciar un OAuth nuevo aunque ya exista un login de esa red en la cuenta de Postproxy (conectar OTRA cuenta de Facebook/LinkedIn, o reconectar una vencida). Sin esto, para facebook/linkedin se reusa el login existente y se devuelve needsPageSelection.'),
625
631
  },
626
632
  },
627
- async ({ projectId, channelId, platform }) => {
633
+ async ({ projectId, channelId, platform, forceOAuth }) => {
628
634
  try {
629
- const data = await apiPost(config, `/v1/channels/${channelId}/connect-postproxy`, { projectId: resolveProjectId(projectId), platform })
635
+ const data = await apiPost(config, `/v1/channels/${channelId}/connect-postproxy`, { projectId: resolveProjectId(projectId), platform, ...(forceOAuth ? { forceOAuth: true } : {}) })
630
636
  return textResult(data)
631
637
  } catch (err) {
632
638
  return errorResult(err)
@@ -637,15 +643,16 @@ export function createServer() {
637
643
  server.registerTool(
638
644
  'publish_content_postproxy',
639
645
  {
640
- description: 'Publica o programa una pieza de Contenido en redes sociales vía Postproxy -- solo funciona si la pieza tiene al menos una variante en estatus "approved" y su canal ya está conectado (ver connect_postproxy_channel/list_channels). Respeta approvalRequired -- no la llames para saltarte una aprobación pendiente.',
646
+ description: 'Publica o programa una pieza de Contenido en redes sociales vía Postproxy -- solo funciona si la pieza tiene al menos una variante en estatus "approved" y su canal ya está conectado (ver connect_postproxy_channel/list_channels). Respeta approvalRequired -- no la llames para saltarte una aprobación pendiente. Cada variante aprobada va en SU PROPIO post de Postproxy (caption/media/fecha propios); antes de cada uno el server verifica el perfil EN VIVO y rechaza con un 400 claro si la cuenta está vencida, ya no existe, o (Instagram/TikTok) vive en el group de Postproxy de otro proyecto -- es decir, el canal apunta a la cuenta de otro proyecto y hay que reconectarlo. Devuelve {posts:[{channelId, postproxyPostId, status}], failed:[{channelId, channelName, error}]}: con éxito parcial responde 200 igual, así que revisa `failed` y cuéntaselo al usuario -- cada variante queda con su externalGuid o su publishError en la pieza.',
641
647
  inputSchema: {
642
648
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
643
649
  contentId: z.string().describe('Pieza de Contenido a publicar (ver list_content)'),
650
+ channelId: z.string().optional().describe('Publica solo la variante de ese canal. Sin channelId se publican todas las variantes aprobadas de la pieza, cada una en su propio post.'),
644
651
  },
645
652
  },
646
- async ({ projectId, contentId }) => {
653
+ async ({ projectId, contentId, channelId }) => {
647
654
  try {
648
- const data = await apiPost(config, `/v1/content/${contentId}/publish-postproxy`, { projectId: resolveProjectId(projectId) })
655
+ const data = await apiPost(config, `/v1/content/${contentId}/publish-postproxy`, { projectId: resolveProjectId(projectId), ...(channelId ? { channelId } : {}) })
649
656
  return textResult(data)
650
657
  } catch (err) {
651
658
  return errorResult(err)
@@ -1133,7 +1140,7 @@ export function createServer() {
1133
1140
  )
1134
1141
 
1135
1142
  registerRead('list_changelog', '/v1/changelog',
1136
- 'Lista TODAS las entradas de changelog de un proyecto, con todos sus campos (id, section, text, version, distributions, repo, taskId, createdAt, createdBy) -- a diferencia de get_dashboard_summary/get_project_context, que solo traen un resumen recortado (texto/título, sin repo ni distributions). Útil para auditar (ej. "¿qué entradas viejas no tienen repo?") antes de update_changelog_entry.',
1143
+ 'Lista TODAS las entradas de changelog de un proyecto, con todos sus campos (id, section, text, version, distributions, repo, taskId, moduleId, createdAt, createdBy) -- a diferencia de get_dashboard_summary/get_project_context, que solo traen un resumen recortado (texto/título, sin repo ni distributions). Útil para auditar (ej. "¿qué entradas viejas no tienen repo?") antes de update_changelog_entry.',
1137
1144
  {}, (args, projectId) => ({ projectId }))
1138
1145
 
1139
1146
  registerCreate(
@@ -1150,12 +1157,13 @@ export function createServer() {
1150
1157
  })).optional().describe('Distribuciones a las que aplica esta entrada (ver list_distributions) -- vacío/omitido = aplica a todo el proyecto. Cada una puede tener su propia versión dentro de esta entrada.'),
1151
1158
  repo: z.string().optional().describe('"owner/name" del repo de origen del commit/PR que generó esta entrada, si se sabe -- alimenta el chip de repo en la tarjeta de versión.'),
1152
1159
  taskId: z.string().optional().describe('Tarea que esta entrada documenta o cierra, si se sabe (ver list_tasks) -- alimenta el chip de tarea, clicable al detalle, en la tarjeta de versión.'),
1160
+ moduleId: z.string().optional().describe('Módulo al que pertenece esta entrada, si se sabe (ver list_modules). Agregado ORQ-178 -- también se puede llenar después vía accept_suggestion sobre una sugerencia kind="module_match" que generó la reconciliación de orquestra-infra.'),
1153
1161
  plannedReleaseDate: z.string().optional().describe('YYYY-MM-DD -- fecha objetivo de corte de esta versión (solo tiene sentido si la entrada queda en Unreleased). Puramente informativo, alimenta el calendario global -- no dispara release_changelog_version solo.'),
1154
1162
  }
1155
1163
  )
1156
1164
 
1157
1165
  registerUpdate('update_changelog_entry', '/v1/changelog',
1158
- 'Edita una entrada de changelog existente (section/text/version/distributions/repo/taskId) -- primera edición puntual por id que existe (antes solo había create + el bulk-update de release_changelog_version). Pensada sobre todo para backfill/corrección (ej. completar "repo" en entradas viejas que se crearon antes de que ese campo se mandara), no para reescribir historia -- si el usuario solo quiere corregir el texto de una entrada reciente por error de tipeo, esto también sirve.',
1166
+ 'Edita una entrada de changelog existente (section/text/version/distributions/repo/taskId/moduleId) -- primera edición puntual por id que existe (antes solo había create + el bulk-update de release_changelog_version). Pensada sobre todo para backfill/corrección (ej. completar "repo" en entradas viejas que se crearon antes de que ese campo se mandara), no para reescribir historia -- si el usuario solo quiere corregir el texto de una entrada reciente por error de tipeo, esto también sirve.',
1159
1167
  {
1160
1168
  section: z.enum(CHANGELOG_SECTIONS).optional(),
1161
1169
  text: z.string().optional(),
@@ -1166,6 +1174,7 @@ export function createServer() {
1166
1174
  })).optional(),
1167
1175
  repo: z.string().optional().describe('"owner/name" del repo de origen.'),
1168
1176
  taskId: z.string().optional(),
1177
+ moduleId: z.string().optional().describe('Ver create_changelog_entry.'),
1169
1178
  plannedReleaseDate: z.string().optional().describe('YYYY-MM-DD -- ver create_changelog_entry.'),
1170
1179
  }
1171
1180
  )
@@ -1447,6 +1456,7 @@ export function createServer() {
1447
1456
  label: z.string().describe('Nombre a mostrar por default'),
1448
1457
  name: z.string().optional().describe('Nombre editable -- si no se manda, la UI usa label'),
1449
1458
  categoryId: z.string().optional(),
1459
+ productKey: z.string().optional().describe('Paridad por producto: clave libre (ej. "app") que agrupa las distribuciones que son EL MISMO producto en distintas plataformas (Web/iOS/Android). Hermanas = misma clave. Con eso la paridad solo compara entre hermanas y create_task propone el scope de las demás solo. Landing/backend/MCP normalmente van sin clave. Solo asígnala si el usuario confirma qué plataformas son el mismo producto -- no la infieras del tipo.'),
1450
1460
  status: z.enum(INFRA_STATUSES).optional().describe('Default: operational'),
1451
1461
  phase: z.string().optional().describe('idea | design | alpha | beta | live | deprecated'),
1452
1462
  identifier: z.string().optional().describe('Legado -- preferir bundleId (no-web) o domain (web)'),
@@ -1467,6 +1477,7 @@ export function createServer() {
1467
1477
  label: z.string().optional(),
1468
1478
  name: z.string().optional(),
1469
1479
  categoryId: z.string().optional(),
1480
+ productKey: z.string().optional().describe('Paridad por producto -- misma clave en las distribuciones que son el mismo producto (ver create_distribution). "" la quita.'),
1470
1481
  status: z.enum(INFRA_STATUSES).optional(),
1471
1482
  phase: z.string().optional(),
1472
1483
  identifier: z.string().optional(),