orquestra-mcp 1.7.2 → 1.8.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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/api.mjs +13 -0
  3. package/src/server.mjs +434 -39
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.7.2",
4
- "description": "Servidor MCP de Orquestra -- 51 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
3
+ "version": "1.8.0",
4
+ "description": "Servidor MCP de Orquestra -- 69 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
7
  "orquestra-mcp": "./bin/orquestra-mcp.mjs"
package/src/api.mjs CHANGED
@@ -89,3 +89,16 @@ export async function apiPatch(config, path, { projectId, id, ...body }) {
89
89
  })
90
90
  return parseResponse(res)
91
91
  }
92
+
93
+ // Único método DELETE del cliente (21 ago 2026, para delete_checklist_item
94
+ // -- ver CLAUDE.md, "único delete_* real de la API/MCP"). Mismo shape que
95
+ // apiPatch (arma la URL como `${path}/${id}`), sin body -- ningún delete de
96
+ // esta API necesita mandar datos, solo identificar el recurso.
97
+ export async function apiDelete(config, path, { projectId, id }) {
98
+ const url = buildUrl(config, `${path}/${id}`, { projectId })
99
+ const res = await fetch(url, {
100
+ method: 'DELETE',
101
+ headers: { Authorization: `Bearer ${config.apiToken}` },
102
+ })
103
+ return parseResponse(res)
104
+ }
package/src/server.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { z } from 'zod'
3
- import { loadConfig, apiGet, apiPost, apiPatch, ApiError } from './api.mjs'
3
+ import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, ApiError } from './api.mjs'
4
4
  import { STAGES, PLAYBOOKS } from './stages.mjs'
5
5
 
6
6
  function errorResult(err) {
@@ -17,8 +17,19 @@ const PROJECT_ID_DESC = 'ID del proyecto dentro del workspace configurado. Si ha
17
17
  const TASK_TYPES = ['feature', 'bug', 'improvement', 'tech', 'docs', 'idea', 'design', 'security', 'meeting', 'call', 'followup', 'business']
18
18
  const TASK_PRIORITIES = ['high', 'medium', 'low']
19
19
  const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
20
- const DOC_TYPES = ['readme', 'usecase', 'diagram', 'env', 'adr', 'runbook', 'api']
20
+ // 'postmortem' faltaba acá (gap preexistente, encontrado 12 ago 2026) --
21
+ // mismo fix del lado de write.mjs en orquestra-infra.
22
+ const DOC_TYPES = ['readme', 'usecase', 'diagram', 'env', 'adr', 'runbook', 'postmortem', 'api']
21
23
  const ADR_STATUSES = ['propuesto', 'aceptado', 'deprecado', 'rechazado']
24
+ const DOC_FOLDERS = ['arquitectura', 'api', 'operaciones', 'decisiones']
25
+ const DOC_VISIBILITY = ['team', 'workspace', 'public']
26
+ // 'hecha' agregado 12 ago 2026 -- ver misma nota en write.mjs de
27
+ // orquestra-infra (convertToTask ya no borra la idea, la deja en 'hecha').
28
+ const IDEA_STATUSES = ['nueva', 'en_estudio', 'planeada', 'hecha', 'en_espera', 'descartada']
29
+ const IDEA_ORIGINS = ['cliente', 'soporte', 'equipo', 'ventas']
30
+ const IDEA_IMPACT_EFFORT = ['alto', 'bajo']
31
+ const NOTE_TYPES = ['decision', 'incidente', 'cliente', 'planificacion']
32
+ const NOTE_VISIBILITY = ['team', 'private']
22
33
  const SERVICE_TYPES = ['backend', 'database', 'auth', 'storage', 'push', 'email', 'payments', 'analytics', 'realtime', 'search', 'maps', 'flags', 'admin', 'cdn', 'queue']
23
34
  const PLATFORM_TYPES = ['android', 'ios', 'web', 'desktop-mac', 'desktop-win', 'backend', 'extension', 'other']
24
35
  const INFRA_STATUSES = ['operational', 'degraded', 'down']
@@ -32,9 +43,9 @@ const SETUP_GUIDE = {
32
43
  { step: 3, section: 'Infraestructura', check: 'list_services / list_environments / list_distributions (o get_infra_status para los 3 juntos)', createOrUpdate: 'create_service|environment|distribution / update_*', matchBy: 'name (o type para servicios/distribuciones)' },
33
44
  { step: 4, section: 'Módulos', check: 'list_modules', createOrUpdate: 'create_module / update_module', matchBy: 'name', dependsOn: ['statusId (paso 1)', 'categoryId (paso 2)'] },
34
45
  { step: 5, section: 'Tareas', check: 'list_tasks', createOrUpdate: 'create_task / update_task', matchBy: 'title', dependsOn: ['statusId', 'categoryId', 'moduleId'] },
35
- { step: 6, section: 'Ideas', check: 'search', createOrUpdate: 'create_idea (sin update_idea todavía)', matchBy: 'title' },
46
+ { step: 6, section: 'Ideas', check: 'search', createOrUpdate: 'create_idea / update_idea / convert_idea_to_task', matchBy: 'title' },
36
47
  { step: 7, section: 'Notas', check: 'search', createOrUpdate: 'create_note (sin update_note todavía)', matchBy: 'title' },
37
- { step: 8, section: 'Changelog', check: 'get_dashboard_summary (recent.changelog) o search', createOrUpdate: 'create_changelog_entry (sin update todavía)', matchBy: 'text' },
48
+ { step: 8, section: 'Changelog', check: 'list_changelog', createOrUpdate: 'create_changelog_entry / update_changelog_entry', matchBy: 'text' },
38
49
  { step: 9, section: 'Docs', check: 'search', createOrUpdate: 'create_doc (sin update_doc todavía)', matchBy: 'title' },
39
50
  ],
40
51
  }
@@ -56,12 +67,14 @@ Si get_project_context devuelve un proyecto vacío (sin módulos ni tareas), es
56
67
 
57
68
  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"):
58
69
  - 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.
59
- - 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.
70
+ - 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.
60
71
  - Todo cambio de statusId vía update_task valida la transición (nextIds) y queda en el historial de la tarea -- pero ese historial no dice nada por sí solo. Manda SIEMPRE el parámetro "comment" cuando cambies statusId, explicando qué hiciste o por qué -- sin esto, el usuario ve que la tarea cambió pero no sabe por qué ni qué se hizo de verdad (encontrado real: quedaba invisible, a diferencia de cuando un humano mueve la tarjeta a mano en el kanban).
61
72
  - Antes de crear un módulo/estatus/categoría/servicio/entorno/distribución, revisa con el list_* correspondiente que no exista ya uno con ese nombre.
62
73
  - 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.
74
+ - 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.
63
75
  - 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.
64
76
  - 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
+ - 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í.
65
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.
66
79
 
67
80
  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:
@@ -157,9 +170,26 @@ export function createServer() {
157
170
  registerRead(
158
171
  'list_tasks',
159
172
  '/v1/tasks',
160
- 'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus.',
161
- { statusId: z.string().optional().describe('ID de estatus para filtrar (opcional)') },
162
- (args, projectId) => ({ projectId, statusId: args.statusId })
173
+ 'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true.',
174
+ {
175
+ statusId: z.string().optional().describe('ID de estatus para filtrar (opcional) -- pedir un estatus puntual (incluso uno cerrado) siempre se respeta tal cual, sin el filtro default de abiertas'),
176
+ moduleId: z.string().optional().describe('ID de módulo para filtrar (opcional)'),
177
+ type: z.enum(TASK_TYPES).optional().describe('Tipo de tarea para filtrar (opcional)'),
178
+ priority: z.enum(TASK_PRIORITIES).optional().describe('Prioridad para filtrar (opcional)'),
179
+ assignedTo: z.string().optional().describe('uid del responsable para filtrar (opcional)'),
180
+ tag: z.string().optional().describe('Un tag exacto para filtrar -- solo uno, no una lista (opcional)'),
181
+ includeClosed: z.boolean().optional().describe('Default false -- sin esto, tareas en un estatus isFinal/isCancel/isArchive quedan afuera. Ponelo en true solo si de verdad necesitás el historial cerrado (auditoría, "¿cuándo se hizo X?").'),
182
+ },
183
+ (args, projectId) => ({
184
+ projectId,
185
+ statusId: args.statusId,
186
+ moduleId: args.moduleId,
187
+ type: args.type,
188
+ priority: args.priority,
189
+ assignedTo: args.assignedTo,
190
+ tag: args.tag,
191
+ includeClosed: args.includeClosed,
192
+ })
163
193
  )
164
194
 
165
195
  registerRead(
@@ -293,7 +323,7 @@ export function createServer() {
293
323
  registerCreate(
294
324
  'create_task',
295
325
  '/v1/tasks',
296
- 'Crea una tarea en un proyecto de Orquestra. moduleId es OBLIGATORIO -- revisa list_modules primero; si ninguno aplica, creá uno con create_module antes de crear la tarea (nunca la dejes sin agrupar).',
326
+ 'Crea una tarea en un proyecto de Orquestra. moduleId y dueDate son OBLIGATORIOS -- revisa list_modules primero (si ninguno aplica, creá uno con create_module antes de crear la tarea, nunca la dejes sin agrupar); dueDate se calcula con criterio real (prioridad/tamaño del trabajo), nunca un valor arbitrario.',
297
327
  {
298
328
  title: z.string().describe('Título de la tarea'),
299
329
  description: z.string().optional(),
@@ -302,7 +332,7 @@ export function createServer() {
302
332
  statusId: z.string().optional().describe('Default: el primer estatus del proyecto'),
303
333
  categoryId: z.string().optional(),
304
334
  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.'),
305
- dueDate: z.string().optional().describe('YYYY-MM-DD'),
335
+ 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.'),
306
336
  assignedTo: z.string().optional().describe('uid del miembro asignado'),
307
337
  tags: z.array(z.string()).optional(),
308
338
  scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
@@ -359,6 +389,52 @@ export function createServer() {
359
389
  }
360
390
  )
361
391
 
392
+ // Fusionar tareas (20 ago 2026) -- registradas directo con
393
+ // server.registerTool (no registerCreate/registerUpdate) porque son
394
+ // POST a `${path}/${id}/merge|unmerge`, no un CRUD estándar. Fusión
395
+ // SIEMPRE blanda -- ver mergeTask/unmergeTask en write.mjs (orquestra-infra)
396
+ // y mergeTask/unmergeTask en useAppStore.js (orquestra-web) para la
397
+ // contraparte real: nunca copia ni borra checklist/comentarios, solo
398
+ // enlaza mergedIntoId y saca la tarea fusionada de las vistas activas.
399
+ server.registerTool(
400
+ 'merge_task',
401
+ {
402
+ description: 'Fusiona una tarea duplicada dentro de otra ("blanda": nunca copia ni borra checklist/comentarios de ninguna de las dos -- solo enlaza taskId a targetId y saca a taskId de las vistas activas, mismo criterio que una tarea cerrada). Reversible con unmerge_task. targetId es la que sobrevive -- si no estás seguro de cuál de las dos conviene mantener, usa list_tasks/get_project_context para comparar antes (ej. más checklist/comentarios/historial suele ser la "buena").',
403
+ inputSchema: {
404
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
405
+ taskId: z.string().describe('Tarea que se fusiona -- queda enlazada y oculta de las vistas activas'),
406
+ targetId: z.string().describe('Tarea destino que sobrevive'),
407
+ },
408
+ },
409
+ async ({ projectId, taskId, targetId }) => {
410
+ try {
411
+ const data = await apiPost(config, `/v1/tasks/${taskId}/merge`, { projectId: resolveProjectId(projectId), targetId })
412
+ return textResult(data)
413
+ } catch (err) {
414
+ return errorResult(err)
415
+ }
416
+ }
417
+ )
418
+
419
+ server.registerTool(
420
+ 'unmerge_task',
421
+ {
422
+ description: 'Deshace una fusión hecha con merge_task -- limpia mergedIntoId/mergedAt/mergedBy de taskId, que vuelve a aparecer en las vistas activas.',
423
+ inputSchema: {
424
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
425
+ taskId: z.string().describe('Tarea fusionada a "des-fusionar"'),
426
+ },
427
+ },
428
+ async ({ projectId, taskId }) => {
429
+ try {
430
+ const data = await apiPost(config, `/v1/tasks/${taskId}/unmerge`, { projectId: resolveProjectId(projectId) })
431
+ return textResult(data)
432
+ } catch (err) {
433
+ return errorResult(err)
434
+ }
435
+ }
436
+ )
437
+
362
438
  // Checklist de una tarea -- registradas directo con server.registerTool
363
439
  // (no con registerCreate/registerUpdate) porque su ruta necesita DOS ids
364
440
  // dinámicos (taskId + itemId del checklist), y esos helpers solo arman
@@ -426,6 +502,58 @@ export function createServer() {
426
502
  }
427
503
  )
428
504
 
505
+ // Único delete_* real de la API/MCP (21 ago 2026, decisión consciente de
506
+ // José de romper "Escritura = creación + edición, nunca borrado" -- ver
507
+ // gotcha abajo en CLAUDE.md) -- acotado a un sub-ítem de checklist, nunca
508
+ // a una entidad de nivel top. Existe porque convert_checklist_item_to_task
509
+ // (justo abajo) necesita replicar el mismo comportamiento que
510
+ // convertChecklistItemToTask en useAppStore.js: crea la tarea Y borra el
511
+ // ítem origen, sin dejar ningún vínculo entre ambos (a diferencia de
512
+ // convert_idea_to_task, que sí liga con taskId).
513
+ server.registerTool(
514
+ 'delete_checklist_item',
515
+ {
516
+ description: 'Borra un ítem del checklist de una tarea de Orquestra. Único borrado real que expone este MCP -- usalo solo cuando de verdad no tiene sentido dejar rastro (ej. como parte de convert_checklist_item_to_task); para "ya no aplica" en general, preferí marcarlo done en vez de borrarlo si querés conservar el historial.',
517
+ inputSchema: {
518
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
519
+ taskId: z.string().describe('ID de la tarea'),
520
+ itemId: z.string().describe('ID del ítem del checklist a borrar'),
521
+ },
522
+ },
523
+ async ({ projectId, taskId, itemId }) => {
524
+ try {
525
+ const data = await apiDelete(config, `/v1/tasks/${taskId}/checklist`, { projectId: resolveProjectId(projectId), id: itemId })
526
+ return textResult(data)
527
+ } catch (err) {
528
+ return errorResult(err)
529
+ }
530
+ }
531
+ )
532
+
533
+ server.registerTool(
534
+ 'convert_checklist_item_to_task',
535
+ {
536
+ description: 'Convierte un ítem del checklist de una tarea en una tarea nueva independiente -- crea la tarea (hereda moduleId/categoryId/scope de la tarea padre, y assignedTo del ítem si tenía responsable) y BORRA el ítem origen del checklist, sin dejar ningún vínculo entre ambos (a diferencia de convert_idea_to_task, que sí liga con taskId -- mismo comportamiento que convertChecklistItemToTask en la web). Igual que create_task, moduleId real es obligatorio (de la tarea padre o de este llamado) y dueDate también -- la web lo deja vacío pero este canal no.',
537
+ inputSchema: {
538
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
539
+ taskId: z.string().describe('Tarea dueña del ítem'),
540
+ itemId: z.string().describe('Ítem del checklist a convertir (ver list_checklist_items)'),
541
+ moduleId: z.string().optional().describe('Solo si la tarea padre no tiene moduleId propio.'),
542
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('Obligatorio -- la tarea padre no tiene una fecha que heredar para este ítem, calculala con criterio real.'),
543
+ },
544
+ },
545
+ async ({ projectId, taskId, itemId, moduleId, dueDate }) => {
546
+ try {
547
+ const data = await apiPost(config, `/v1/tasks/${taskId}/checklist/${itemId}/convert-to-task`, {
548
+ projectId: resolveProjectId(projectId), moduleId, dueDate,
549
+ })
550
+ return textResult(data)
551
+ } catch (err) {
552
+ return errorResult(err)
553
+ }
554
+ }
555
+ )
556
+
429
557
  registerCreate(
430
558
  'create_idea',
431
559
  '/v1/ideas',
@@ -433,12 +561,197 @@ export function createServer() {
433
561
  {
434
562
  title: z.string().describe('Título de la idea'),
435
563
  description: z.string().optional(),
436
- priority: z.enum(TASK_PRIORITIES).optional().describe('Default: medium'),
564
+ priority: z.enum(TASK_PRIORITIES).optional().describe('Default: medium -- se puede derivar de impact+effort si los mandas (alto+bajo→high, alto+alto o bajo+bajo→medium, bajo+alto→low).'),
437
565
  categoryId: z.string().optional(),
438
566
  dueDate: z.string().optional().describe('YYYY-MM-DD'),
567
+ moduleId: z.string().optional(),
568
+ status: z.enum(IDEA_STATUSES).optional().describe('Default: nueva. "hecha" es a dónde llega una idea convertida en tarea (ver convertToTask en la web) -- no la mandes vos al crear, no tiene sentido crear una idea ya "hecha".'),
569
+ origin: z.enum(IDEA_ORIGINS).optional(),
570
+ requestedBy: z.array(z.object({
571
+ name: z.string().describe('Nombre del cliente, en texto libre (no hay entidad "cliente" en Orquestra)'),
572
+ quote: z.string().optional().describe('Cita textual de por qué lo pidió, si la tenés'),
573
+ })).optional().describe('Quién pidió esta idea -- cada uno suma +1 a voteCount. `requestedAt` se estampa solo, server-side, no lo mandes.'),
574
+ impact: z.enum(IDEA_IMPACT_EFFORT).optional(),
575
+ effort: z.enum(IDEA_IMPACT_EFFORT).optional(),
576
+ risk: z.enum(IDEA_IMPACT_EFFORT).optional().describe('Riesgo de esta idea (alto/bajo) -- agregado 12 ago 2026, alimenta la recomendación del detalle de idea junto con impact/effort.'),
577
+ voteCount: z.number().optional().describe('Default: 1 + requestedBy.length'),
578
+ linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Reemplaza el array completo si ya tenía algo, no hace merge.'),
579
+ }
580
+ )
581
+
582
+ registerUpdate('update_idea', '/v1/ideas', 'Edita una idea existente. Todos los campos son opcionales -- solo se cambian los que mandes.', {
583
+ title: z.string().optional(),
584
+ description: z.string().optional(),
585
+ priority: z.enum(TASK_PRIORITIES).optional(),
586
+ categoryId: z.string().optional(),
587
+ dueDate: z.string().optional().describe('YYYY-MM-DD'),
588
+ moduleId: z.string().optional(),
589
+ status: z.enum(IDEA_STATUSES).optional().describe('Para marcarla "hecha" a mano preferí convert_idea_to_task (hace las dos cosas -- crea la tarea Y marca la idea -- de una sola vez y queda enlazada con taskId). Usá esto solo si querés cambiar el status sin convertirla (ej. "descartada").'),
590
+ origin: z.enum(IDEA_ORIGINS).optional(),
591
+ requestedBy: z.array(z.object({
592
+ name: z.string().describe('Nombre del cliente, en texto libre (no hay entidad "cliente" en Orquestra)'),
593
+ quote: z.string().optional().describe('Cita textual de por qué lo pidió, si la tenés'),
594
+ })).optional().describe('Reemplaza la lista completa (no agrega un elemento suelto) -- `requestedAt` se estampa solo, server-side, no lo mandes.'),
595
+ impact: z.enum(IDEA_IMPACT_EFFORT).optional(),
596
+ effort: z.enum(IDEA_IMPACT_EFFORT).optional(),
597
+ risk: z.enum(IDEA_IMPACT_EFFORT).optional(),
598
+ voteCount: z.number().optional(),
599
+ linkedTaskIds: z.array(z.string()).optional().describe('Tareas YA EXISTENTES relacionadas con esta idea (no creadas a partir de ella) -- ver list_tasks. Distinto de taskId (la tarea "principal"/de conversión, que solo se setea vía convert_idea_to_task): acá pueden ser varias. Reemplaza el array completo, no hace merge -- si querés agregar una sin perder las que ya tenía, primero mirá la idea con search/get_project_context.'),
600
+ })
601
+
602
+ // Convertir idea → tarea (21 ago 2026, gap encontrado: no había forma de
603
+ // hacer por MCP lo que la web ya hace en handleConvertTask de
604
+ // IdeaDetail.jsx). Compuesta -- POST /v1/ideas/{id}/convert-to-task en
605
+ // orquestra-infra crea la tarea (reusando createTask tal cual, mismas
606
+ // reglas que create_task) Y marca la idea 'hecha' con taskId enlazado,
607
+ // las dos cosas en un solo paso server-side. La idea NUNCA se borra --
608
+ // queda trazable, mismo criterio que la web. No se puede convertir una
609
+ // idea que ya tiene taskId (falla con un error claro).
610
+ //
611
+ // moduleId/dueDate son opcionales acá PERO create_task (por debajo)
612
+ // sigue exigiendo los dos reales -- si la idea no los tiene seteados,
613
+ // hay que mandarlos en esta llamada con el mismo criterio real de
614
+ // create_task (nunca fabricados). Si la idea sí los tiene, no hace falta
615
+ // repetirlos.
616
+ server.registerTool(
617
+ 'convert_idea_to_task',
618
+ {
619
+ description: 'Convierte una idea en una tarea real -- crea la tarea (mismas reglas que create_task: moduleId y dueDate son obligatorios, de la idea o de este llamado) y marca la idea "hecha" con un taskId enlazado, sin borrarla. No se puede convertir una idea que ya fue convertida antes.',
620
+ inputSchema: {
621
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
622
+ ideaId: z.string().describe('Idea a convertir -- usá search para encontrar su id (las ideas no tienen su propio list_ideas todavía)'),
623
+ moduleId: z.string().optional().describe('Solo si la idea no tiene moduleId propio -- si no existe uno relacionado, revisá list_modules/create_module primero.'),
624
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Solo si la idea no tiene dueDate propio -- calculado con criterio real, no un valor arbitrario.'),
625
+ },
626
+ },
627
+ async ({ projectId, ideaId, moduleId, dueDate }) => {
628
+ try {
629
+ const data = await apiPost(config, `/v1/ideas/${ideaId}/convert-to-task`, {
630
+ projectId: resolveProjectId(projectId), moduleId, dueDate,
631
+ })
632
+ return textResult(data)
633
+ } catch (err) {
634
+ return errorResult(err)
635
+ }
636
+ }
637
+ )
638
+
639
+ // Comentarios de ideas (21 ago 2026, a pedido de José) -- el modelo y la
640
+ // UI YA existían del lado web (ideas/{id}/comments, mismo shape que
641
+ // comentarios de tareas), pero nunca tuvieron ruta HTTP -- un agente MCP
642
+ // no podía leer ni sumar contexto/objeciones a una idea. Registradas
643
+ // directo con server.registerTool (no registerRead/registerCreate)
644
+ // porque la ruta cuelga de `ideaId`, no de un `id` genérico al final.
645
+ server.registerTool(
646
+ 'list_idea_comments',
647
+ {
648
+ description: 'Lista los comentarios de una idea de Orquestra, en orden cronológico.',
649
+ inputSchema: {
650
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
651
+ ideaId: z.string().describe('Idea -- usá search para encontrar su id'),
652
+ },
653
+ },
654
+ async ({ projectId, ideaId }) => {
655
+ try {
656
+ const data = await apiGet(config, `/v1/ideas/${ideaId}/comments`, { projectId: resolveProjectId(projectId) })
657
+ return textResult(data)
658
+ } catch (err) {
659
+ return errorResult(err)
660
+ }
661
+ }
662
+ )
663
+
664
+ server.registerTool(
665
+ 'add_idea_comment',
666
+ {
667
+ description: 'Agrega un comentario a una idea de Orquestra -- para sumar contexto, una objeción, o el resultado de investigarla, sin tener que convertirla en tarea para dejar rastro.',
668
+ inputSchema: {
669
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
670
+ ideaId: z.string().describe('Idea -- usá search para encontrar su id'),
671
+ text: z.string().describe('Texto del comentario'),
672
+ authorName: z.string().optional().describe('Default "IA" -- solo cambialo si el comentario es una cita textual de otra persona, no lo tuyo.'),
673
+ },
674
+ },
675
+ async ({ projectId, ideaId, text, authorName }) => {
676
+ try {
677
+ const data = await apiPost(config, `/v1/ideas/${ideaId}/comments`, { projectId: resolveProjectId(projectId), text, authorName })
678
+ return textResult(data)
679
+ } catch (err) {
680
+ return errorResult(err)
681
+ }
682
+ }
683
+ )
684
+
685
+ // Sugerencias de IA (21 ago 2026, gap encontrado a pedido de José: el MCP
686
+ // no tenía NINGUNA tool relacionada con `suggestions` -- un agente ni
687
+ // podía leer qué había pendiente (reescrituras de changelog, tareas que
688
+ // avanzaron/cerraron por evidencia, hallazgos de QA, gaps de setup/
689
+ // contenido/publicidad, fusiones de tareas sugeridas, ángulos de
690
+ // contenido), mucho menos actuarlo. Contraparte server-side de
691
+ // useGlobalSuggestions.js + acceptSuggestion/dismissSuggestion en
692
+ // useAppStore.js -- accept_suggestion aplica el mismo efecto real que
693
+ // aceptar desde el drawer "Sugerencias de IA" de la web (reusa create_task/
694
+ // update_task/merge_task/etc. por debajo, ver acceptSuggestion en
695
+ // write.mjs de orquestra-infra), nunca escribe el cambio directo.
696
+ // `content_angle` (piezas de Contenido sugeridas por IA) SÍ aparece acá a
697
+ // diferencia del drawer de la web (que lo excluye porque ese kind vive en
698
+ // la UI de Content.jsx, no en el drawer) -- acá no hay dos superficies
699
+ // separadas para un mismo canal, así que se incluye.
700
+ registerRead(
701
+ 'list_suggestions',
702
+ '/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.',
704
+ { status: z.string().optional().describe('Default "pending". "all" trae todas, o un status puntual ("accepted"/"dismissed").') },
705
+ (args, projectId) => ({ projectId, status: args.status })
706
+ )
707
+
708
+ server.registerTool(
709
+ 'accept_suggestion',
710
+ {
711
+ description: 'Acepta una sugerencia de IA y aplica su efecto real según su "kind" (ver list_suggestions): mueve/cierra la tarea que corresponda, reescribe el changelog, crea el servicio/canal/contenido/tarea sugerido, o fusiona dos tareas duplicadas. Mismo efecto que aceptarla desde el drawer "Sugerencias de IA" de la web. No se puede aceptar una que ya no esté "pending".',
712
+ inputSchema: {
713
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
714
+ 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.'),
717
+ 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
+ },
719
+ },
720
+ async ({ projectId, suggestionId, moduleId, dueDate, overrides }) => {
721
+ try {
722
+ const data = await apiPost(config, `/v1/suggestions/${suggestionId}/accept`, {
723
+ projectId: resolveProjectId(projectId), moduleId, dueDate, overrides,
724
+ })
725
+ return textResult(data)
726
+ } catch (err) {
727
+ return errorResult(err)
728
+ }
729
+ }
730
+ )
731
+
732
+ server.registerTool(
733
+ 'dismiss_suggestion',
734
+ {
735
+ description: 'Descarta una sugerencia de IA sin aplicar su efecto (queda en status "dismissed", no se borra).',
736
+ inputSchema: {
737
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
738
+ suggestionId: z.string().describe('Ver list_suggestions'),
739
+ },
740
+ },
741
+ async ({ projectId, suggestionId }) => {
742
+ try {
743
+ const data = await apiPost(config, `/v1/suggestions/${suggestionId}/dismiss`, { projectId: resolveProjectId(projectId) })
744
+ return textResult(data)
745
+ } catch (err) {
746
+ return errorResult(err)
747
+ }
439
748
  }
440
749
  )
441
750
 
751
+ registerRead('list_changelog', '/v1/changelog',
752
+ '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.',
753
+ {}, (args, projectId) => ({ projectId }))
754
+
442
755
  registerCreate(
443
756
  'create_changelog_entry',
444
757
  '/v1/changelog',
@@ -447,6 +760,39 @@ export function createServer() {
447
760
  section: z.enum(CHANGELOG_SECTIONS).describe('Sección del changelog'),
448
761
  text: z.string().describe('Descripción de la entrada'),
449
762
  version: z.string().optional(),
763
+ distributions: z.array(z.object({
764
+ distId: z.string(),
765
+ version: z.string().optional(),
766
+ })).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.'),
767
+ 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.'),
768
+ 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.'),
769
+ 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.'),
770
+ }
771
+ )
772
+
773
+ registerUpdate('update_changelog_entry', '/v1/changelog',
774
+ '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.',
775
+ {
776
+ section: z.enum(CHANGELOG_SECTIONS).optional(),
777
+ text: z.string().optional(),
778
+ version: z.string().optional(),
779
+ distributions: z.array(z.object({
780
+ distId: z.string(),
781
+ version: z.string().optional(),
782
+ })).optional(),
783
+ repo: z.string().optional().describe('"owner/name" del repo de origen.'),
784
+ taskId: z.string().optional(),
785
+ plannedReleaseDate: z.string().optional().describe('YYYY-MM-DD -- ver create_changelog_entry.'),
786
+ }
787
+ )
788
+
789
+ registerCreate(
790
+ 'release_changelog_version',
791
+ '/v1/changelog/release',
792
+ 'Corta una versión del changelog: toma TODAS las entradas que están en Unreleased (o, si mandas distributionId, solo las de esa distribución) y les asigna un número de versión real, mismo efecto que el botón "+ Nueva versión" de la web -- también actualiza distribution.version de las distribuciones tocadas. NUNCA lo llames sin que el usuario lo haya pedido explícitamente (o confirmado un número/fecha de release real) -- cortar una versión es una decisión de producto, no un paso automático de "buena higiene" como sí lo es create_changelog_entry.',
793
+ {
794
+ version: z.string().optional().describe('Semver real, ej. "1.4.0". Si se omite, se sugiere solo (Removed/Security -> mayor, Added -> menor, resto -> parche) a partir de la versión conocida más alta -- decíselo al usuario ANTES de aplicar si vos elegiste el número, no lo apliques en silencio.'),
795
+ distributionId: z.string().optional().describe('Si se manda, solo libera las entradas de ESA distribución (ver list_distributions) -- las entradas globales/de otras distribuciones quedan en Unreleased. Sin esto, libera todo el proyecto.'),
450
796
  }
451
797
  )
452
798
 
@@ -460,6 +806,13 @@ export function createServer() {
460
806
  pinned: z.boolean().optional(),
461
807
  moduleId: z.string().optional().describe('Módulo relacionado, si la nota es sobre uno específico -- ligarla ayuda a encontrarla después (ver list_modules)'),
462
808
  taskId: z.string().optional().describe('Tarea relacionada, si la nota es sobre una específica (ver list_tasks)'),
809
+ type: z.enum(NOTE_TYPES).optional().describe('Default: planificacion (nota sin tipo especial). "decision" habilita alternativesConsidered/wouldRevertIf.'),
810
+ alternativesConsidered: z.string().optional().describe('Solo para type=\'decision\'. Qué más se evaluó y por qué no.'),
811
+ wouldRevertIf: z.string().optional().describe('Solo para type=\'decision\'. La condición que haría cambiar de opinión -- sin esto la decisión se vuelve dogma.'),
812
+ visibility: z.enum(NOTE_VISIBILITY).optional().describe('Default: team. "private" restringe la lectura a quien la creó (enforcement real en firestore.rules, no solo la UI).'),
813
+ relatedNoteId: z.string().optional().describe('Otra nota relacionada, si es clara la relación (ver search) -- agregado 12 ago 2026. No inventes el vínculo si no es evidente.'),
814
+ adrDocId: z.string().optional().describe('Liga esta nota a un doc ADR ya creado con create_doc (type="adr"). No lo mandes junto con publishAsAdr -- ese genera su propio doc.'),
815
+ publishAsAdr: z.boolean().optional().describe('Solo para type=\'decision\'. Genera automáticamente el doc ADR correspondiente (numerado "ADR-XXX: {title}", con la plantilla Contexto/Alternativas consideradas/Decisión/Consecuencias a partir de body/alternativesConsidered/wouldRevertIf) y liga esta nota a él -- mismo flujo que el toggle "Publicar como ADR" de NewNoteModal.jsx en la web, en un solo paso.'),
463
816
  }
464
817
  )
465
818
 
@@ -474,6 +827,11 @@ export function createServer() {
474
827
  adrStatus: z.enum(ADR_STATUSES).optional().describe('Solo para type=\'adr\'. "aceptado" es lo único que alimenta Material (Contenido) como fuente de ángulos -- ver adrContext.'),
475
828
  adrContext: z.string().optional().describe('Solo para type=\'adr\'. Qué problema/situación llevó a la decisión, en texto estructurado (no el markdown libre de content).'),
476
829
  adrConsequences: z.string().optional().describe('Solo para type=\'adr\'. Qué cambia de ahora en adelante.'),
830
+ moduleId: z.string().optional().describe('Módulo al que pertenece (ver list_modules) -- vincularlo es lo que permite detectar "puede estar desactualizado" cuando el módulo acumula actividad después de la última edición.'),
831
+ folder: z.enum(DOC_FOLDERS).optional().describe('Carpeta donde se archiva en la UI -- independiente de `type` (ej. un ADR se puede archivar en cualquier carpeta).'),
832
+ visibility: z.enum(DOC_VISIBILITY).optional().describe('"team" (default) | "workspace" | "public". "public" expone el doc sin sesión en una URL propia.'),
833
+ reviewOwnerUid: z.string().optional().describe('uid de quién recibe el aviso cuando el doc queda desactualizado.'),
834
+ reviewIntervalDays: z.number().optional().describe('Cada cuántos días revisar por calendario (30/90/180) -- sin esto, solo se detecta vencimiento por actividad del módulo vinculado, nunca por fecha.'),
477
835
  }
478
836
  )
479
837
 
@@ -532,10 +890,14 @@ export function createServer() {
532
890
  registerCreate('create_category', '/v1/categories', 'Crea una categoría (agrupación/audiencia) en un proyecto.', {
533
891
  name: z.string().describe('Nombre de la categoría'),
534
892
  color: z.string().optional().describe('Color hex'),
893
+ description: z.string().optional().describe('Para qué sirve esta categoría, en pocas palabras -- agregado 12 ago 2026.'),
894
+ order: z.number().optional().describe('Orden manual de aparición (más chico = más arriba) -- agregado 12 ago 2026.'),
535
895
  })
536
896
  registerUpdate('update_category', '/v1/categories', 'Edita una categoría existente.', {
537
897
  name: z.string().optional(),
538
898
  color: z.string().optional(),
899
+ description: z.string().optional(),
900
+ order: z.number().optional(),
539
901
  })
540
902
 
541
903
  // Campos específicos por tipo de servicio -- unión deduplicada de las
@@ -662,12 +1024,37 @@ export function createServer() {
662
1024
  knowledgeSource: z.string().optional().describe('Doc libre tipo CLAUDE.md para el agente de QA de esta distribución.'),
663
1025
  })
664
1026
 
665
- // ═══ Contenido (Fase 1 del rediseño, 11 ago 2026) ═══════════════════════════
666
- // Un canal = una cuenta real (o a conectar) donde se publica. Una
667
- // publicación (content) = un canal (channelId), no varios -- para
668
- // publicar en N canales a la vez, crea N piezas de contenido con
669
- // create_content, una por channelId.
1027
+ // ═══ Contenido (Fase 1 del rediseño, 11 ago 2026; modelo multi-canal
1028
+ // Fase 3, 20 ago 2026) ═══════════════════════════════════════════════════
1029
+ // Un canal = una cuenta real (o a conectar) donde se publica. Una pieza
1030
+ // de contenido puede ser de UN canal (channelId a nivel doc, shape
1031
+ // legacy) o MULTI-CANAL (channels[], un elemento por canal con su propio
1032
+ // caption/status/fecha/desempeño -- ver pieceVariants en contentPlan.js,
1033
+ // orquestra-web). Para una pieza en varios canales a la vez, mandá
1034
+ // channels[] en vez de channelId -- ya no hace falta llamar create_content
1035
+ // una vez por canal (aunque channelId de 1 solo canal sigue andando igual
1036
+ // para el caso simple).
670
1037
  const CONTENT_STATUSES = ['idea', 'draft', 'review', 'approved', 'published']
1038
+ const CONTENT_VARIANT_SCHEMA = z.object({
1039
+ channelId: z.string().describe('Canal de esta variante -- ver list_channels'),
1040
+ format: z.string().optional(),
1041
+ status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea'),
1042
+ caption: z.string().optional(),
1043
+ hashtags: z.array(z.string()).optional(),
1044
+ scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar'),
1045
+ approvalRequired: z.boolean().optional(),
1046
+ approverUid: z.string().optional(),
1047
+ campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece ESTE canal -- ver list_campaigns'),
1048
+ organicRate: z.number().optional(),
1049
+ leads: z.number().optional(),
1050
+ reach: z.number().optional(),
1051
+ clicksToSite: z.number().optional(),
1052
+ saves: z.number().optional(),
1053
+ avgReadMinutes: z.number().optional(),
1054
+ imagePrompt: z.string().optional().describe('Descripción visual en inglés para generar la portada (no genera la imagen, solo guarda el texto)'),
1055
+ language: z.string().optional(),
1056
+ translationOf: z.string().optional(),
1057
+ })
671
1058
 
672
1059
  registerRead('list_channels', '/v1/channels',
673
1060
  'Lista los canales (cuentas de redes/blog/newsletter, reales o a conectar) de un proyecto. Úsala antes de create_channel para no duplicar, y antes de create_content para saber qué channelId usar.',
@@ -723,32 +1110,36 @@ export function createServer() {
723
1110
  'Lista las publicaciones de contenido (posts programados/publicados) de un proyecto.',
724
1111
  {}, (args, projectId) => ({ projectId }))
725
1112
 
726
- registerCreate('create_content', '/v1/content', 'Crea una publicación de contenido en un canal (ver list_channels/create_channel). Para publicar la misma pieza en varios canales, llama a esto una vez por cada channelId.', {
727
- channelId: z.string().describe('Canal donde se publica -- ver list_channels'),
728
- title: z.string().describe('Referencia interna, no es lo que se publica'),
729
- format: z.string().optional().describe('Ej. "Post", "Reel", "Hilo" -- texto libre según el canal'),
730
- status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea'),
731
- caption: z.string().optional().describe('Texto real de la publicación'),
1113
+ registerCreate('create_content', '/v1/content', 'Crea una pieza de contenido. Dos formas: (1) channelId suelto -- 1 canal, shape simple, seguí usándolo si solo publicás en un lado. (2) channels[] -- una pieza multi-canal con una variante por canal (cada una con su propio caption/status/fecha/desempeño), reemplaza tener que llamar esto varias veces para la "misma" publicación en distintos canales.', {
1114
+ channelId: z.string().optional().describe('Canal donde se publica -- ver list_channels. Usalo para una pieza de 1 solo canal; si vas a publicar en varios, mandá channels[] en vez de esto.'),
1115
+ channels: z.array(CONTENT_VARIANT_SCHEMA).optional().describe('Pieza multi-canal -- una entrada por canal. No mandes channelId si usás esto.'),
1116
+ title: z.string().describe('Referencia interna, no es lo que se publica -- compartida entre todos los canales si es multi-canal'),
1117
+ format: z.string().optional().describe('Ej. "Post", "Reel", "Hilo" -- texto libre según el canal (solo con channelId suelto -- en channels[] va por variante)'),
1118
+ status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea (solo con channelId suelto -- en channels[] va por variante)'),
1119
+ caption: z.string().optional().describe('Texto real de la publicación (solo con channelId suelto)'),
732
1120
  hashtags: z.array(z.string()).optional(),
733
- scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar'),
1121
+ scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar (solo con channelId suelto)'),
734
1122
  approvalRequired: z.boolean().optional(),
735
1123
  approverUid: z.string().optional().describe('uid de quien debe aprobar antes de publicar'),
736
- changelogVersion: z.string().optional().describe('Versión del changelog que esta publicación anuncia ("Atada a") -- ver list_tasks/create_changelog_entry'),
737
- moduleId: z.string().optional(),
738
- ideaId: z.string().optional().describe('Si esta pieza viene de una idea existente'),
739
- campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece -- ver list_campaigns'),
740
- organicRate: z.number().optional().describe('% de interacción, medido a mano por el usuario en la plataforma real -- solo tiene sentido con status published'),
741
- leads: z.number().optional().describe('"Registros" atribuidos a esta pieza orgánica -- entrada manual'),
742
- reach: z.number().optional().describe('Alcance absoluto -- entrada manual, Fase 4 de Resultados'),
743
- clicksToSite: z.number().optional().describe('Clics al sitio/docs -- entrada manual'),
744
- saves: z.number().optional().describe('Guardados -- entrada manual, típico de X/Instagram'),
745
- avgReadMinutes: z.number().optional().describe('Minutos de lectura media -- entrada manual, típico de blog'),
1124
+ changelogVersion: z.string().optional().describe('Versión del changelog que esta pieza anuncia ("Atada a") -- ver list_tasks/create_changelog_entry. Siempre a nivel pieza, compartida entre canales.'),
1125
+ seriesName: z.string().optional().describe('Texto libre que agrupa piezas relacionadas, ej. "Lanzamiento agosto" (Fase 4, 20 ago 2026) -- siempre a nivel pieza. Usá list_content primero para ver nombres de serie ya usados y no crear variantes del mismo nombre por typo.'),
1126
+ moduleId: z.string().optional().describe('Siempre a nivel pieza'),
1127
+ ideaId: z.string().optional().describe('Si esta pieza viene de una idea existente -- siempre a nivel pieza'),
1128
+ campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece -- ver list_campaigns (solo con channelId suelto)'),
1129
+ organicRate: z.number().optional().describe('% de interacción, medido a mano por el usuario en la plataforma real -- solo tiene sentido con status published (solo con channelId suelto)'),
1130
+ leads: z.number().optional().describe('"Registros" atribuidos a esta pieza orgánica -- entrada manual (solo con channelId suelto)'),
1131
+ reach: z.number().optional().describe('Alcance absoluto -- entrada manual, Fase 4 de Resultados (solo con channelId suelto)'),
1132
+ clicksToSite: z.number().optional().describe('Clics al sitio/docs -- entrada manual (solo con channelId suelto)'),
1133
+ saves: z.number().optional().describe('Guardados -- entrada manual, típico de X/Instagram (solo con channelId suelto)'),
1134
+ avgReadMinutes: z.number().optional().describe('Minutos de lectura media -- entrada manual, típico de blog (solo con channelId suelto)'),
1135
+ 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)'),
746
1136
  language: z.string().optional().describe('\'en\' si esta pieza es la traducción de otra. Sin valor = idioma original.'),
747
1137
  translationOf: z.string().optional().describe('Si esta pieza es una traducción, id del content original'),
748
1138
  })
749
1139
 
750
- registerUpdate('update_content', '/v1/content', 'Edita una publicación de contenido existente. Al pasar status a "approved" o "published" el server estampa quién/cuándo solo -- no lo mandes tú.', {
1140
+ registerUpdate('update_content', '/v1/content', 'Edita una pieza de contenido existente. Al pasar status a "approved" o "published" el server estampa quién/cuándo solo -- no lo mandes tú (mismo estampado por variante si mandás channels[]). Para patchear un solo canal de una pieza multi-canal, mandá el array `channels` COMPLETO con esa variante modificada -- Firestore no puede editar un campo adentro de un elemento de array, así que reemplaza el array entero.', {
751
1141
  channelId: z.string().optional(),
1142
+ channels: z.array(CONTENT_VARIANT_SCHEMA).optional().describe('Reemplaza el array de variantes completo -- mandá TODAS las variantes (las que cambian y las que no), no solo la que estás editando.'),
752
1143
  title: z.string().optional(),
753
1144
  format: z.string().optional(),
754
1145
  status: z.enum(CONTENT_STATUSES).optional(),
@@ -758,6 +1149,7 @@ export function createServer() {
758
1149
  approvalRequired: z.boolean().optional(),
759
1150
  approverUid: z.string().optional(),
760
1151
  changelogVersion: z.string().optional(),
1152
+ seriesName: z.string().optional(),
761
1153
  moduleId: z.string().optional(),
762
1154
  ideaId: z.string().optional(),
763
1155
  campaignId: z.string().optional(),
@@ -767,6 +1159,7 @@ export function createServer() {
767
1159
  clicksToSite: z.number().optional(),
768
1160
  saves: z.number().optional(),
769
1161
  avgReadMinutes: z.number().optional(),
1162
+ imagePrompt: z.string().optional(),
770
1163
  language: z.string().optional(),
771
1164
  translationOf: z.string().optional(),
772
1165
  })
@@ -798,6 +1191,7 @@ export function createServer() {
798
1191
  closeStatusId: z.string().optional().describe('Estatus donde se mueven los bugs de este agente al cerrarse solos -- default: primer estatus final'),
799
1192
  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'),
800
1193
  testUserPassword: z.string().optional().describe('Password de testUserEmail'),
1194
+ 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.'),
801
1195
  })
802
1196
 
803
1197
  registerUpdate('update_qa_agent', '/v1/qaAgents', 'Edita un agente de QA existente.', {
@@ -813,6 +1207,7 @@ export function createServer() {
813
1207
  closeStatusId: z.string().optional(),
814
1208
  testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) para que la revisión con IA inicie sesión'),
815
1209
  testUserPassword: z.string().optional(),
1210
+ plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_qa_agent.'),
816
1211
  })
817
1212
 
818
1213
  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.', {
@@ -902,10 +1297,9 @@ export function createServer() {
902
1297
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
903
1298
  stageId: z.enum(STAGE_IDS).describe('pre | build | post -- ver get_stage_playbook para el contenido'),
904
1299
  moduleName: z.string().optional().describe('Nombre del módulo -- default: el de la plantilla'),
905
- includeDates: z.boolean().optional().describe('Si asigna dueDate a cada tarea usando los offsetDays de la plantilla (default: false)'),
906
1300
  },
907
1301
  },
908
- async ({ projectId, stageId, moduleName, includeDates }) => {
1302
+ async ({ projectId, stageId, moduleName }) => {
909
1303
  try {
910
1304
  const pid = resolveProjectId(projectId)
911
1305
  const playbook = PLAYBOOKS[stageId]
@@ -918,9 +1312,10 @@ export function createServer() {
918
1312
 
919
1313
  const taskIds = []
920
1314
  for (const t of playbook.tasks) {
921
- const dueDate = includeDates
922
- ? new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
923
- : undefined
1315
+ // dueDate es obligatorio en create_task (ver más abajo) -- ya no
1316
+ // es opcional (antes flag `includeDates`, default false, rompía
1317
+ // en seco apenas dueDate se volvió requerido server-side).
1318
+ const dueDate = new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
924
1319
  const created = await apiPost(config, '/v1/tasks', {
925
1320
  projectId: pid,
926
1321
  title: t.title,