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.
- package/package.json +2 -2
- package/src/api.mjs +13 -0
- package/src/server.mjs +434 -39
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Servidor MCP de Orquestra --
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
{
|
|
162
|
-
|
|
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
|
|
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().
|
|
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
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
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
|
|
727
|
-
channelId: z.string().describe('Canal donde se publica -- ver list_channels'),
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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,
|