orquestra-mcp 1.7.9 → 1.9.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 +384 -38
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.7.9",
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.9.0",
4
+ "description": "Servidor MCP de Orquestra -- 71 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
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) {
@@ -43,9 +43,9 @@ const SETUP_GUIDE = {
43
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)' },
44
44
  { step: 4, section: 'Módulos', check: 'list_modules', createOrUpdate: 'create_module / update_module', matchBy: 'name', dependsOn: ['statusId (paso 1)', 'categoryId (paso 2)'] },
45
45
  { step: 5, section: 'Tareas', check: 'list_tasks', createOrUpdate: 'create_task / update_task', matchBy: 'title', dependsOn: ['statusId', 'categoryId', 'moduleId'] },
46
- { step: 6, section: 'Ideas', check: 'search', createOrUpdate: 'create_idea (sin update_idea todavía)', matchBy: 'title' },
47
- { step: 7, section: 'Notas', check: 'search', createOrUpdate: 'create_note (sin update_note todavía)', matchBy: 'title' },
48
- { step: 8, section: 'Changelog', check: 'get_dashboard_summary (recent.changelog) o search', createOrUpdate: 'create_changelog_entry (sin update todavía)', matchBy: 'text' },
46
+ { step: 6, section: 'Ideas', check: 'search', createOrUpdate: 'create_idea / update_idea / convert_idea_to_task', matchBy: 'title' },
47
+ { step: 7, section: 'Notas', check: 'list_notes', createOrUpdate: 'create_note / update_note', matchBy: 'title' },
48
+ { step: 8, section: 'Changelog', check: 'list_changelog', createOrUpdate: 'create_changelog_entry / update_changelog_entry', matchBy: 'text' },
49
49
  { step: 9, section: 'Docs', check: 'search', createOrUpdate: 'create_doc (sin update_doc todavía)', matchBy: 'title' },
50
50
  ],
51
51
  }
@@ -170,9 +170,26 @@ export function createServer() {
170
170
  registerRead(
171
171
  'list_tasks',
172
172
  '/v1/tasks',
173
- 'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus.',
174
- { statusId: z.string().optional().describe('ID de estatus para filtrar (opcional)') },
175
- (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
+ })
176
193
  )
177
194
 
178
195
  registerRead(
@@ -306,7 +323,7 @@ export function createServer() {
306
323
  registerCreate(
307
324
  'create_task',
308
325
  '/v1/tasks',
309
- '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.',
310
327
  {
311
328
  title: z.string().describe('Título de la tarea'),
312
329
  description: z.string().optional(),
@@ -315,7 +332,7 @@ export function createServer() {
315
332
  statusId: z.string().optional().describe('Default: el primer estatus del proyecto'),
316
333
  categoryId: z.string().optional(),
317
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.'),
318
- 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.'),
319
336
  assignedTo: z.string().optional().describe('uid del miembro asignado'),
320
337
  tags: z.array(z.string()).optional(),
321
338
  scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
@@ -372,6 +389,52 @@ export function createServer() {
372
389
  }
373
390
  )
374
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
+
375
438
  // Checklist de una tarea -- registradas directo con server.registerTool
376
439
  // (no con registerCreate/registerUpdate) porque su ruta necesita DOS ids
377
440
  // dinámicos (taskId + itemId del checklist), y esos helpers solo arman
@@ -439,6 +502,58 @@ export function createServer() {
439
502
  }
440
503
  )
441
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
+
442
557
  registerCreate(
443
558
  'create_idea',
444
559
  '/v1/ideas',
@@ -460,6 +575,176 @@ export function createServer() {
460
575
  effort: z.enum(IDEA_IMPACT_EFFORT).optional(),
461
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.'),
462
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
+ }
463
748
  }
464
749
  )
465
750
 
@@ -481,6 +766,7 @@ export function createServer() {
481
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.'),
482
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.'),
483
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.'),
484
770
  }
485
771
  )
486
772
 
@@ -496,6 +782,7 @@ export function createServer() {
496
782
  })).optional(),
497
783
  repo: z.string().optional().describe('"owner/name" del repo de origen.'),
498
784
  taskId: z.string().optional(),
785
+ plannedReleaseDate: z.string().optional().describe('YYYY-MM-DD -- ver create_changelog_entry.'),
499
786
  }
500
787
  )
501
788
 
@@ -509,6 +796,10 @@ export function createServer() {
509
796
  }
510
797
  )
511
798
 
799
+ registerRead('list_notes', '/v1/notes',
800
+ 'Lista TODAS las notas de un proyecto de Orquestra, con todos sus campos -- a diferencia de get_project_context/search (resumen recortado), útil para auditar o encontrar el id exacto antes de update_note.',
801
+ {}, (args, projectId) => ({ projectId }))
802
+
512
803
  registerCreate(
513
804
  'create_note',
514
805
  '/v1/notes',
@@ -524,6 +815,26 @@ export function createServer() {
524
815
  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.'),
525
816
  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).'),
526
817
  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.'),
818
+ 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.'),
819
+ 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.'),
820
+ }
821
+ )
822
+
823
+ registerUpdate('update_note', '/v1/notes',
824
+ 'Edita una nota existente. Todos los campos son opcionales -- solo se cambian los que mandes.',
825
+ {
826
+ title: z.string().optional(),
827
+ body: z.string().optional(),
828
+ pinned: z.boolean().optional(),
829
+ moduleId: z.string().optional(),
830
+ taskId: z.string().optional(),
831
+ type: z.enum(NOTE_TYPES).optional(),
832
+ alternativesConsidered: z.string().optional(),
833
+ wouldRevertIf: z.string().optional(),
834
+ visibility: z.enum(NOTE_VISIBILITY).optional(),
835
+ relatedNoteId: z.string().optional(),
836
+ adrDocId: z.string().optional().describe('Liga esta nota a un doc ADR ya creado. No lo mandes junto con publishAsAdr.'),
837
+ publishAsAdr: z.boolean().optional().describe('Genera el doc ADR recién ahora (útil si la nota se marcó "decision" después de creada) y liga adrDocId -- falla si la nota ya tiene uno.'),
527
838
  }
528
839
  )
529
840
 
@@ -735,12 +1046,37 @@ export function createServer() {
735
1046
  knowledgeSource: z.string().optional().describe('Doc libre tipo CLAUDE.md para el agente de QA de esta distribución.'),
736
1047
  })
737
1048
 
738
- // ═══ Contenido (Fase 1 del rediseño, 11 ago 2026) ═══════════════════════════
739
- // Un canal = una cuenta real (o a conectar) donde se publica. Una
740
- // publicación (content) = un canal (channelId), no varios -- para
741
- // publicar en N canales a la vez, crea N piezas de contenido con
742
- // create_content, una por channelId.
1049
+ // ═══ Contenido (Fase 1 del rediseño, 11 ago 2026; modelo multi-canal
1050
+ // Fase 3, 20 ago 2026) ═══════════════════════════════════════════════════
1051
+ // Un canal = una cuenta real (o a conectar) donde se publica. Una pieza
1052
+ // de contenido puede ser de UN canal (channelId a nivel doc, shape
1053
+ // legacy) o MULTI-CANAL (channels[], un elemento por canal con su propio
1054
+ // caption/status/fecha/desempeño -- ver pieceVariants en contentPlan.js,
1055
+ // orquestra-web). Para una pieza en varios canales a la vez, mandá
1056
+ // channels[] en vez de channelId -- ya no hace falta llamar create_content
1057
+ // una vez por canal (aunque channelId de 1 solo canal sigue andando igual
1058
+ // para el caso simple).
743
1059
  const CONTENT_STATUSES = ['idea', 'draft', 'review', 'approved', 'published']
1060
+ const CONTENT_VARIANT_SCHEMA = z.object({
1061
+ channelId: z.string().describe('Canal de esta variante -- ver list_channels'),
1062
+ format: z.string().optional(),
1063
+ status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea'),
1064
+ caption: z.string().optional(),
1065
+ hashtags: z.array(z.string()).optional(),
1066
+ scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar'),
1067
+ approvalRequired: z.boolean().optional(),
1068
+ approverUid: z.string().optional(),
1069
+ campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece ESTE canal -- ver list_campaigns'),
1070
+ organicRate: z.number().optional(),
1071
+ leads: z.number().optional(),
1072
+ reach: z.number().optional(),
1073
+ clicksToSite: z.number().optional(),
1074
+ saves: z.number().optional(),
1075
+ avgReadMinutes: z.number().optional(),
1076
+ imagePrompt: z.string().optional().describe('Descripción visual en inglés para generar la portada (no genera la imagen, solo guarda el texto)'),
1077
+ language: z.string().optional(),
1078
+ translationOf: z.string().optional(),
1079
+ })
744
1080
 
745
1081
  registerRead('list_channels', '/v1/channels',
746
1082
  '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.',
@@ -796,32 +1132,36 @@ export function createServer() {
796
1132
  'Lista las publicaciones de contenido (posts programados/publicados) de un proyecto.',
797
1133
  {}, (args, projectId) => ({ projectId }))
798
1134
 
799
- 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.', {
800
- channelId: z.string().describe('Canal donde se publica -- ver list_channels'),
801
- title: z.string().describe('Referencia interna, no es lo que se publica'),
802
- format: z.string().optional().describe('Ej. "Post", "Reel", "Hilo" -- texto libre según el canal'),
803
- status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea'),
804
- caption: z.string().optional().describe('Texto real de la publicación'),
1135
+ 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.', {
1136
+ 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.'),
1137
+ channels: z.array(CONTENT_VARIANT_SCHEMA).optional().describe('Pieza multi-canal -- una entrada por canal. No mandes channelId si usás esto.'),
1138
+ title: z.string().describe('Referencia interna, no es lo que se publica -- compartida entre todos los canales si es multi-canal'),
1139
+ format: z.string().optional().describe('Ej. "Post", "Reel", "Hilo" -- texto libre según el canal (solo con channelId suelto -- en channels[] va por variante)'),
1140
+ status: z.enum(CONTENT_STATUSES).optional().describe('Default: idea (solo con channelId suelto -- en channels[] va por variante)'),
1141
+ caption: z.string().optional().describe('Texto real de la publicación (solo con channelId suelto)'),
805
1142
  hashtags: z.array(z.string()).optional(),
806
- scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar'),
1143
+ scheduledAt: z.number().optional().describe('Timestamp en ms de cuándo se programa publicar (solo con channelId suelto)'),
807
1144
  approvalRequired: z.boolean().optional(),
808
1145
  approverUid: z.string().optional().describe('uid de quien debe aprobar antes de publicar'),
809
- changelogVersion: z.string().optional().describe('Versión del changelog que esta publicación anuncia ("Atada a") -- ver list_tasks/create_changelog_entry'),
810
- moduleId: z.string().optional(),
811
- ideaId: z.string().optional().describe('Si esta pieza viene de una idea existente'),
812
- campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece -- ver list_campaigns'),
813
- organicRate: z.number().optional().describe('% de interacción, medido a mano por el usuario en la plataforma real -- solo tiene sentido con status published'),
814
- leads: z.number().optional().describe('"Registros" atribuidos a esta pieza orgánica -- entrada manual'),
815
- reach: z.number().optional().describe('Alcance absoluto -- entrada manual, Fase 4 de Resultados'),
816
- clicksToSite: z.number().optional().describe('Clics al sitio/docs -- entrada manual'),
817
- saves: z.number().optional().describe('Guardados -- entrada manual, típico de X/Instagram'),
818
- avgReadMinutes: z.number().optional().describe('Minutos de lectura media -- entrada manual, típico de blog'),
1146
+ 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.'),
1147
+ 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.'),
1148
+ moduleId: z.string().optional().describe('Siempre a nivel pieza'),
1149
+ ideaId: z.string().optional().describe('Si esta pieza viene de una idea existente -- siempre a nivel pieza'),
1150
+ campaignId: z.string().optional().describe('Campaña de pauta a la que pertenece -- ver list_campaigns (solo con channelId suelto)'),
1151
+ 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)'),
1152
+ leads: z.number().optional().describe('"Registros" atribuidos a esta pieza orgánica -- entrada manual (solo con channelId suelto)'),
1153
+ reach: z.number().optional().describe('Alcance absoluto -- entrada manual, Fase 4 de Resultados (solo con channelId suelto)'),
1154
+ clicksToSite: z.number().optional().describe('Clics al sitio/docs -- entrada manual (solo con channelId suelto)'),
1155
+ saves: z.number().optional().describe('Guardados -- entrada manual, típico de X/Instagram (solo con channelId suelto)'),
1156
+ avgReadMinutes: z.number().optional().describe('Minutos de lectura media -- entrada manual, típico de blog (solo con channelId suelto)'),
1157
+ 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)'),
819
1158
  language: z.string().optional().describe('\'en\' si esta pieza es la traducción de otra. Sin valor = idioma original.'),
820
1159
  translationOf: z.string().optional().describe('Si esta pieza es una traducción, id del content original'),
821
1160
  })
822
1161
 
823
- 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ú.', {
1162
+ 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.', {
824
1163
  channelId: z.string().optional(),
1164
+ 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.'),
825
1165
  title: z.string().optional(),
826
1166
  format: z.string().optional(),
827
1167
  status: z.enum(CONTENT_STATUSES).optional(),
@@ -831,6 +1171,7 @@ export function createServer() {
831
1171
  approvalRequired: z.boolean().optional(),
832
1172
  approverUid: z.string().optional(),
833
1173
  changelogVersion: z.string().optional(),
1174
+ seriesName: z.string().optional(),
834
1175
  moduleId: z.string().optional(),
835
1176
  ideaId: z.string().optional(),
836
1177
  campaignId: z.string().optional(),
@@ -840,6 +1181,7 @@ export function createServer() {
840
1181
  clicksToSite: z.number().optional(),
841
1182
  saves: z.number().optional(),
842
1183
  avgReadMinutes: z.number().optional(),
1184
+ imagePrompt: z.string().optional(),
843
1185
  language: z.string().optional(),
844
1186
  translationOf: z.string().optional(),
845
1187
  })
@@ -871,6 +1213,7 @@ export function createServer() {
871
1213
  closeStatusId: z.string().optional().describe('Estatus donde se mueven los bugs de este agente al cerrarse solos -- default: primer estatus final'),
872
1214
  testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) que la revisión con IA puede usar para iniciar sesión y revisar pantallas autenticadas'),
873
1215
  testUserPassword: z.string().optional().describe('Password de testUserEmail'),
1216
+ plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- recordatorio manual de "próxima corrida planeada". QA no tiene scheduling real (todo trigger es manual/push): esto NO dispara ninguna corrida sola, solo alimenta el calendario global.'),
874
1217
  })
875
1218
 
876
1219
  registerUpdate('update_qa_agent', '/v1/qaAgents', 'Edita un agente de QA existente.', {
@@ -886,6 +1229,7 @@ export function createServer() {
886
1229
  closeStatusId: z.string().optional(),
887
1230
  testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) para que la revisión con IA inicie sesión'),
888
1231
  testUserPassword: z.string().optional(),
1232
+ plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_qa_agent.'),
889
1233
  })
890
1234
 
891
1235
  registerCreate('create_qa_run', '/v1/qa/runs', 'Dispara una corrida de QA (lint/tests/build/E2E según el stack) en la rama indicada. Necesita al menos un agente de QA activo para el stack (o falla explícito -- ver list_qa_agents/create_qa_agent). La corrida queda "queued" -- nunca asumas que pasó, confirma con get_qa_run.', {
@@ -929,6 +1273,7 @@ export function createServer() {
929
1273
  description: z.string().optional(),
930
1274
  phase: z.string().optional().describe('idea | mvp | v1 | v2 | beta | produccion (default: idea)'),
931
1275
  projectType: z.enum(['code', 'content', 'hybrid']).optional().describe('Default: hybrid'),
1276
+ scale: z.enum(['landing', 'app', 'platform']).optional().describe('Default implícito: app. Eje ORTOGONAL a projectType -- "landing" es una página estática sin backend/auth/db propios (evita que las sugerencias de setup propongan infra que no aplica).'),
932
1277
  url: z.string().optional(),
933
1278
  distributions: z.array(z.enum(PLATFORM_TYPES)).optional().describe('Plataformas a crear, ej: ["web","ios"]'),
934
1279
  categories: z.array(z.string()).optional().describe('Nombres de categorías a crear (default: ["General"])'),
@@ -947,13 +1292,14 @@ export function createServer() {
947
1292
  server.registerTool(
948
1293
  'update_project',
949
1294
  {
950
- description: 'Edita el proyecto configurado (name, description, phase, projectType, url). Útil para sincronizar la fase real de un proyecto que ya estaba avanzado antes de conectarlo a Orquestra -- get_project_context puede verse "vacío" (sin módulos/tareas) sin que eso signifique que el proyecto recién empieza.',
1295
+ description: 'Edita el proyecto configurado (name, description, phase, projectType, scale, url). Útil para sincronizar la fase real de un proyecto que ya estaba avanzado antes de conectarlo a Orquestra -- get_project_context puede verse "vacío" (sin módulos/tareas) sin que eso signifique que el proyecto recién empieza.',
951
1296
  inputSchema: {
952
1297
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
953
1298
  name: z.string().optional(),
954
1299
  description: z.string().optional(),
955
1300
  phase: z.string().optional().describe('idea | mvp | v1 | v2 | beta | produccion'),
956
1301
  projectType: z.enum(['code', 'content', 'hybrid']).optional(),
1302
+ scale: z.enum(['landing', 'app', 'platform']).optional().describe('Eje ORTOGONAL a projectType, no lo reemplaza -- "landing" es una página estática sin backend/auth/db propios (ej. un portafolio o sitio de marketing): marcalo así si el proyecto es claramente eso, para que las sugerencias de setup dejen de proponer backend/auth/database. Default implícito "app" si no se manda -- sin este campo, el comportamiento es igual que antes.'),
957
1303
  url: z.string().optional(),
958
1304
  },
959
1305
  },
@@ -975,10 +1321,9 @@ export function createServer() {
975
1321
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
976
1322
  stageId: z.enum(STAGE_IDS).describe('pre | build | post -- ver get_stage_playbook para el contenido'),
977
1323
  moduleName: z.string().optional().describe('Nombre del módulo -- default: el de la plantilla'),
978
- includeDates: z.boolean().optional().describe('Si asigna dueDate a cada tarea usando los offsetDays de la plantilla (default: false)'),
979
1324
  },
980
1325
  },
981
- async ({ projectId, stageId, moduleName, includeDates }) => {
1326
+ async ({ projectId, stageId, moduleName }) => {
982
1327
  try {
983
1328
  const pid = resolveProjectId(projectId)
984
1329
  const playbook = PLAYBOOKS[stageId]
@@ -991,9 +1336,10 @@ export function createServer() {
991
1336
 
992
1337
  const taskIds = []
993
1338
  for (const t of playbook.tasks) {
994
- const dueDate = includeDates
995
- ? new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
996
- : undefined
1339
+ // dueDate es obligatorio en create_task (ver más abajo) -- ya no
1340
+ // es opcional (antes flag `includeDates`, default false, rompía
1341
+ // en seco apenas dueDate se volvió requerido server-side).
1342
+ const dueDate = new Date(Date.now() + t.offsetDays * 86400000).toISOString().slice(0, 10)
997
1343
  const created = await apiPost(config, '/v1/tasks', {
998
1344
  projectId: pid,
999
1345
  title: t.title,