orquestra-mcp 1.7.9 → 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 +358 -36
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) {
|
|
@@ -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
|
|
46
|
+
{ step: 6, section: 'Ideas', check: 'search', createOrUpdate: 'create_idea / update_idea / convert_idea_to_task', matchBy: 'title' },
|
|
47
47
|
{ step: 7, section: 'Notas', check: 'search', createOrUpdate: 'create_note (sin update_note todavía)', matchBy: 'title' },
|
|
48
|
-
{ step: 8, section: 'Changelog', check: '
|
|
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
|
-
{
|
|
175
|
-
|
|
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
|
|
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().
|
|
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
|
|
|
@@ -524,6 +811,8 @@ export function createServer() {
|
|
|
524
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.'),
|
|
525
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).'),
|
|
526
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.'),
|
|
527
816
|
}
|
|
528
817
|
)
|
|
529
818
|
|
|
@@ -735,12 +1024,37 @@ export function createServer() {
|
|
|
735
1024
|
knowledgeSource: z.string().optional().describe('Doc libre tipo CLAUDE.md para el agente de QA de esta distribución.'),
|
|
736
1025
|
})
|
|
737
1026
|
|
|
738
|
-
// ═══ Contenido (Fase 1 del rediseño, 11 ago 2026
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
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).
|
|
743
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
|
+
})
|
|
744
1058
|
|
|
745
1059
|
registerRead('list_channels', '/v1/channels',
|
|
746
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.',
|
|
@@ -796,32 +1110,36 @@ export function createServer() {
|
|
|
796
1110
|
'Lista las publicaciones de contenido (posts programados/publicados) de un proyecto.',
|
|
797
1111
|
{}, (args, projectId) => ({ projectId }))
|
|
798
1112
|
|
|
799
|
-
registerCreate('create_content', '/v1/content', 'Crea una
|
|
800
|
-
channelId: z.string().describe('Canal donde se publica -- ver list_channels'),
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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)'),
|
|
805
1120
|
hashtags: z.array(z.string()).optional(),
|
|
806
|
-
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)'),
|
|
807
1122
|
approvalRequired: z.boolean().optional(),
|
|
808
1123
|
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
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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)'),
|
|
819
1136
|
language: z.string().optional().describe('\'en\' si esta pieza es la traducción de otra. Sin valor = idioma original.'),
|
|
820
1137
|
translationOf: z.string().optional().describe('Si esta pieza es una traducción, id del content original'),
|
|
821
1138
|
})
|
|
822
1139
|
|
|
823
|
-
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.', {
|
|
824
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.'),
|
|
825
1143
|
title: z.string().optional(),
|
|
826
1144
|
format: z.string().optional(),
|
|
827
1145
|
status: z.enum(CONTENT_STATUSES).optional(),
|
|
@@ -831,6 +1149,7 @@ export function createServer() {
|
|
|
831
1149
|
approvalRequired: z.boolean().optional(),
|
|
832
1150
|
approverUid: z.string().optional(),
|
|
833
1151
|
changelogVersion: z.string().optional(),
|
|
1152
|
+
seriesName: z.string().optional(),
|
|
834
1153
|
moduleId: z.string().optional(),
|
|
835
1154
|
ideaId: z.string().optional(),
|
|
836
1155
|
campaignId: z.string().optional(),
|
|
@@ -840,6 +1159,7 @@ export function createServer() {
|
|
|
840
1159
|
clicksToSite: z.number().optional(),
|
|
841
1160
|
saves: z.number().optional(),
|
|
842
1161
|
avgReadMinutes: z.number().optional(),
|
|
1162
|
+
imagePrompt: z.string().optional(),
|
|
843
1163
|
language: z.string().optional(),
|
|
844
1164
|
translationOf: z.string().optional(),
|
|
845
1165
|
})
|
|
@@ -871,6 +1191,7 @@ export function createServer() {
|
|
|
871
1191
|
closeStatusId: z.string().optional().describe('Estatus donde se mueven los bugs de este agente al cerrarse solos -- default: primer estatus final'),
|
|
872
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'),
|
|
873
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.'),
|
|
874
1195
|
})
|
|
875
1196
|
|
|
876
1197
|
registerUpdate('update_qa_agent', '/v1/qaAgents', 'Edita un agente de QA existente.', {
|
|
@@ -886,6 +1207,7 @@ export function createServer() {
|
|
|
886
1207
|
closeStatusId: z.string().optional(),
|
|
887
1208
|
testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) para que la revisión con IA inicie sesión'),
|
|
888
1209
|
testUserPassword: z.string().optional(),
|
|
1210
|
+
plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_qa_agent.'),
|
|
889
1211
|
})
|
|
890
1212
|
|
|
891
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.', {
|
|
@@ -975,10 +1297,9 @@ export function createServer() {
|
|
|
975
1297
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
976
1298
|
stageId: z.enum(STAGE_IDS).describe('pre | build | post -- ver get_stage_playbook para el contenido'),
|
|
977
1299
|
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
1300
|
},
|
|
980
1301
|
},
|
|
981
|
-
async ({ projectId, stageId, moduleName
|
|
1302
|
+
async ({ projectId, stageId, moduleName }) => {
|
|
982
1303
|
try {
|
|
983
1304
|
const pid = resolveProjectId(projectId)
|
|
984
1305
|
const playbook = PLAYBOOKS[stageId]
|
|
@@ -991,9 +1312,10 @@ export function createServer() {
|
|
|
991
1312
|
|
|
992
1313
|
const taskIds = []
|
|
993
1314
|
for (const t of playbook.tasks) {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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)
|
|
997
1319
|
const created = await apiPost(config, '/v1/tasks', {
|
|
998
1320
|
projectId: pid,
|
|
999
1321
|
title: t.title,
|