orquestra-mcp 1.38.0 → 1.43.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # orquestra-mcp
2
2
 
3
- Servidor MCP (Model Context Protocol) para Orquestra. Expone **100 tools**
3
+ Servidor MCP (Model Context Protocol) para Orquestra. Expone **135 tools**
4
4
  que llaman a la API de `orquestra-infra`: lectura de contexto/dashboard/
5
5
  workspace/infra/paridad/listados, escritura de tareas/ideas/changelog/
6
6
  notas/docs/módulos/flujo/categorías/infra/proyectos, y corridas de QA —
@@ -133,6 +133,7 @@ crear/editar, sin `delete_*`:
133
133
  | Entidad | Tools | Notas |
134
134
  |---|---|---|
135
135
  | Tareas | `create_task`, `update_task` | `update_task` incluye `scope` — úsalo para resolver desfases de paridad |
136
+ | Verificaciones por plataforma | `list_verifications`, `add_verification`, `update_verification`, `delete_verification`, `verify_with_agent` | Confirmaciones (humanas o de agente) de que la tarea quedó bien en web/iOS/Android/API — NO van en el checklist. La tarea no cierra con verificaciones abiertas y se cierra sola al aprobarse la última; `failed` (con `note`) la regresa a su estatus anterior. También: `verifications` en `create_task`, `waiveVerifications` en `update_task` (solo con confirmación del usuario) y `verificationId` en `create_agent_run` |
136
137
  | Ideas | `create_idea` | |
137
138
  | Changelog | `create_changelog_entry` | Estilo Keep a Changelog |
138
139
  | Notas | `create_note` | |
@@ -195,13 +196,15 @@ create/update sigue siendo criterio del agente y del humano.
195
196
 
196
197
  ## Estado
197
198
 
198
- 100 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
199
+ 135 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
199
200
  sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
200
201
  skills, search) + `list_*` de cada colección + creación/edición de tareas
201
- (con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
202
+ (con checklist, verificaciones por plataforma, tiempo, adjuntos, merge), ideas (con comentarios), notas,
202
203
  docs, changelog (con corte de versión), módulos, flujo, categorías,
203
204
  infraestructura, proyecto, contenido (canales, campañas, contenido,
204
- Postproxy), agentes, skills y sugerencias + compuestas (init_project,
205
+ Postproxy), agentes, skills, sugerencias y vínculos entre ítems
206
+ (`link_items`/`unlink_items`/`list_relations`) + compuestas (init_project,
205
207
  apply_stage_playbook, apply_platform_playbook, install_skill). Sin
206
- `delete_*` salvo adjuntos e ítems de checklist. Publicado en npm como
208
+ `delete_*` salvo adjuntos, ítems de checklist, verificaciones y vínculos
209
+ (`unlink_items`). Publicado en npm como
207
210
  `orquestra-mcp` (unscoped) -- `npx orquestra-mcp setup`.
@@ -4,7 +4,7 @@ import { createServer } from '../src/server.mjs'
4
4
  import { runSetup } from '../src/setup.mjs'
5
5
  import { runVaultSync } from '../src/vaultSync.mjs'
6
6
 
7
- const HELP_TEXT = `orquestra-mcp -- Servidor MCP de Orquestra (127 tools)
7
+ const HELP_TEXT = `orquestra-mcp -- Servidor MCP de Orquestra (132 tools)
8
8
 
9
9
  Uso:
10
10
  npx orquestra-mcp Inicia el servidor MCP por stdio -- lo usa tu cliente
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.38.0",
4
- "description": "Servidor MCP de Orquestra -- 127 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
3
+ "version": "1.43.0",
4
+ "description": "Servidor MCP de Orquestra -- 135 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
@@ -1,4 +1,5 @@
1
1
  import { readConfigFile, resolveProjectForCwd, CONFIG_PATH } from './configFile.mjs'
2
+ import { request } from './http.mjs'
2
3
 
3
4
  const DEFAULT_API_BASE = 'https://api.orquestra.me'
4
5
 
@@ -38,12 +39,7 @@ export function loadConfig() {
38
39
  return { apiBase, apiToken, workspaceId, defaultProjectId }
39
40
  }
40
41
 
41
- export class ApiError extends Error {
42
- constructor(status, message) {
43
- super(message)
44
- this.status = status
45
- }
46
- }
42
+ export { ApiError } from './http.mjs'
47
43
 
48
44
  function buildUrl(config, path, params) {
49
45
  const url = new URL(path, config.apiBase)
@@ -54,57 +50,29 @@ function buildUrl(config, path, params) {
54
50
  return url
55
51
  }
56
52
 
57
- async function parseResponse(res) {
58
- const text = await res.text()
59
- let body
60
- try { body = text ? JSON.parse(text) : null } catch { body = text }
61
-
62
- if (!res.ok) {
63
- const message = (body && body.error) || (typeof body === 'string' ? body : `HTTP ${res.status}`)
64
- throw new ApiError(res.status, message)
65
- }
66
- return body
67
- }
53
+ // Todas pasan por request() (http.mjs, ORQ-343): plazo por intento, cancelación de la tool y
54
+ // reintentos acotados -- las lecturas ante fallas transitorias, las escrituras solo ante el
55
+ // throttling del API Gateway (no llegó a la Lambda, no puede duplicar nada).
56
+ const auth = config => ({ Authorization: `Bearer ${config.apiToken}` })
57
+ const jsonHeaders = config => ({ ...auth(config), 'Content-Type': 'application/json' })
68
58
 
69
59
  export async function apiGet(config, path, params = {}) {
70
- const url = buildUrl(config, path, params)
71
- const res = await fetch(url, {
72
- headers: { Authorization: `Bearer ${config.apiToken}` },
73
- })
74
- return parseResponse(res)
60
+ return request(buildUrl(config, path, params), { headers: auth(config) })
75
61
  }
76
62
 
77
63
  export async function apiPost(config, path, { projectId, ...body }) {
78
- const url = buildUrl(config, path, { projectId })
79
- const res = await fetch(url, {
80
- method: 'POST',
81
- headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
82
- body: JSON.stringify(body),
83
- })
84
- return parseResponse(res)
64
+ return request(buildUrl(config, path, { projectId }), { method: 'POST', headers: jsonHeaders(config), body: JSON.stringify(body) })
85
65
  }
86
66
 
87
67
  export async function apiPatch(config, path, { projectId, id, ...body }) {
88
- const url = buildUrl(config, `${path}/${id}`, { projectId })
89
- const res = await fetch(url, {
90
- method: 'PATCH',
91
- headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
92
- body: JSON.stringify(body),
93
- })
94
- return parseResponse(res)
68
+ return request(buildUrl(config, `${path}/${id}`, { projectId }), { method: 'PATCH', headers: jsonHeaders(config), body: JSON.stringify(body) })
95
69
  }
96
70
 
97
71
  // Mismo método que apiPatch, pero para un doc singleton SIN id en la ruta
98
72
  // (ej. /v1/content-config) -- set_hourly_rate (8 oct 2026, ORQ-247) es el
99
73
  // primer caso real.
100
74
  export async function apiPatchPath(config, path, { projectId, ...body }) {
101
- const url = buildUrl(config, path, { projectId })
102
- const res = await fetch(url, {
103
- method: 'PATCH',
104
- headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
105
- body: JSON.stringify(body),
106
- })
107
- return parseResponse(res)
75
+ return request(buildUrl(config, path, { projectId }), { method: 'PATCH', headers: jsonHeaders(config), body: JSON.stringify(body) })
108
76
  }
109
77
 
110
78
  // Único método DELETE del cliente (21 ago 2026, para delete_checklist_item
@@ -112,23 +80,25 @@ export async function apiPatchPath(config, path, { projectId, ...body }) {
112
80
  // apiPatch (arma la URL como `${path}/${id}`), sin body -- ningún delete de
113
81
  // esta API necesita mandar datos, solo identificar el recurso.
114
82
  export async function apiDelete(config, path, { projectId, id }) {
115
- const url = buildUrl(config, `${path}/${id}`, { projectId })
116
- const res = await fetch(url, {
117
- method: 'DELETE',
118
- headers: { Authorization: `Bearer ${config.apiToken}` },
119
- })
120
- return parseResponse(res)
83
+ return request(buildUrl(config, `${path}/${id}`, { projectId }), { method: 'DELETE', headers: auth(config) })
84
+ }
85
+
86
+ // DELETE sobre un recurso singleton SIN id en la ruta (ej. /v1/client-portal,
87
+ // ORQ-332) -- mismo criterio que apiPatchPath.
88
+ export async function apiDeletePath(config, path, { projectId }) {
89
+ return request(buildUrl(config, path, { projectId }), { method: 'DELETE', headers: auth(config) })
121
90
  }
122
91
 
123
92
  // PUT crudo a una presigned URL de S3 (createAttachmentUploadUrl en
124
93
  // orquestra-infra) -- a diferencia de apiGet/apiPost/etc, este request NO
125
94
  // va contra config.apiBase ni lleva el Bearer del MCP: la URL firmada YA
126
- // es la autorización completa.
95
+ // es la autorización completa. Plazo más largo (sube bytes); un PUT a la
96
+ // misma key es idempotente, pero igual no se reintenta para no duplicar
97
+ // tiempos largos de subida.
127
98
  export async function putToPresignedUrl(uploadUrl, buffer, contentType) {
128
- const res = await fetch(uploadUrl, {
129
- method: 'PUT',
130
- headers: { 'Content-Type': contentType },
131
- body: buffer,
132
- })
133
- if (!res.ok) throw new Error(`Falló la subida a S3: HTTP ${res.status}`)
99
+ try {
100
+ await request(uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: buffer }, { timeoutMs: 120_000, retries: 0 })
101
+ } catch (err) {
102
+ throw new Error(`Falló la subida a S3: ${err.status ? `HTTP ${err.status}` : err.message}`)
103
+ }
134
104
  }
package/src/http.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // fetch con plazo, cancelación y reintentos acotados (ORQ-343). Antes cada llamada era un fetch
2
+ // sin signal: una API colgada dejaba la tool esperando para siempre y un 503 pasajero se le
3
+ // devolvía al agente como error definitivo.
4
+ //
5
+ // Reglas de reintento (máx. RETRIES extra, espera progresiva con jitter, respeta Retry-After):
6
+ // - Lecturas (GET): error de red, plazo vencido, 429 de throttling, 502/503/504.
7
+ // - Escrituras (POST/PATCH/DELETE): SOLO el 429 de throttling del API Gateway, que garantiza que
8
+ // la petición no llegó a la Lambda. Un plazo vencido o un 5xx en una escritura NO se reintenta:
9
+ // la entidad pudo crearse y reintentar la duplicaría (la API no tiene idempotencia general).
10
+ // - El 429 de la propia API (créditos agotados, tope de regeneraciones...) trae `error` en el
11
+ // body y es definitivo: no se reintenta.
12
+ import { AsyncLocalStorage } from 'node:async_hooks'
13
+
14
+ export const TIMEOUT_MS = 30_000
15
+ export const RETRIES = 2
16
+ const BASE_DELAY_MS = 500
17
+ const MAX_DELAY_MS = 5_000
18
+ const RETRY_STATUS_READ = new Set([502, 503, 504])
19
+
20
+ // Signal de cancelación de la tool en curso (lo pone server.mjs por llamada) -- así no hay que
21
+ // pasarlo a mano por las ~140 tools.
22
+ export const cancelContext = new AsyncLocalStorage()
23
+
24
+ export class ApiError extends Error {
25
+ constructor(status, message, { code } = {}) {
26
+ super(message)
27
+ this.status = status
28
+ if (code) this.code = code
29
+ }
30
+ }
31
+
32
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
33
+ if (signal?.aborted) return reject(signal.reason)
34
+ const t = setTimeout(resolve, ms)
35
+ signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason) }, { once: true })
36
+ })
37
+
38
+ // AbortSignal.any no existe en Node 18 (engines >=18).
39
+ function anySignal(signals) {
40
+ const ctrl = new AbortController()
41
+ for (const s of signals.filter(Boolean)) {
42
+ if (s.aborted) { ctrl.abort(s.reason); break }
43
+ s.addEventListener('abort', () => ctrl.abort(s.reason), { once: true })
44
+ }
45
+ return ctrl.signal
46
+ }
47
+
48
+ function backoff(attempt, retryAfter) {
49
+ const secs = Number(retryAfter)
50
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, MAX_DELAY_MS)
51
+ return Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS) * (0.75 + Math.random() * 0.5)
52
+ }
53
+
54
+ async function readBody(res) {
55
+ const text = await res.text()
56
+ try { return text ? JSON.parse(text) : null } catch { return text }
57
+ }
58
+
59
+ const isGatewayThrottle = (status, body) => status === 429 && !(body && typeof body === 'object' && body.error)
60
+
61
+ /**
62
+ * Hace la petición y devuelve el body parseado (o lanza ApiError). `timeoutMs` es por intento.
63
+ * `signal` (opcional) se suma al de la tool en curso.
64
+ */
65
+ export async function request(url, init = {}, { timeoutMs = TIMEOUT_MS, retries = RETRIES, signal } = {}) {
66
+ const method = (init.method || 'GET').toUpperCase()
67
+ const isRead = method === 'GET' || method === 'HEAD'
68
+ const cancel = anySignal([signal, cancelContext.getStore()?.signal])
69
+
70
+ for (let attempt = 0; ; attempt++) {
71
+ const last = attempt >= retries
72
+ let res
73
+ try {
74
+ res = await fetch(url, { ...init, signal: anySignal([cancel, AbortSignal.timeout(timeoutMs)]) })
75
+ } catch (err) {
76
+ if (cancel.aborted) throw new ApiError(0, 'Cancelado', { code: 'cancelled' })
77
+ const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
78
+ if (isRead && !last) { await sleep(backoff(attempt), cancel); continue }
79
+ if (timedOut) {
80
+ throw new ApiError(0, isRead
81
+ ? `La API no respondió en ${timeoutMs / 1000} s (${retries + 1} intentos)`
82
+ : `La API no respondió en ${timeoutMs / 1000} s. La escritura pudo haberse aplicado: revisa con la tool list_*/get_* correspondiente antes de reintentar, para no duplicarla.`,
83
+ { code: 'timeout' })
84
+ }
85
+ throw new ApiError(0, isRead
86
+ ? `No se pudo conectar con la API: ${err?.cause?.code || err?.message}`
87
+ : `Se perdió la conexión con la API (${err?.cause?.code || err?.message}). La escritura pudo haberse aplicado: revisa antes de reintentar.`,
88
+ { code: 'network' })
89
+ }
90
+
91
+ const body = await readBody(res)
92
+ if (res.ok) return body
93
+
94
+ const retryable = isGatewayThrottle(res.status, body) || (isRead && RETRY_STATUS_READ.has(res.status))
95
+ if (retryable && !last) {
96
+ await sleep(backoff(attempt, res.headers.get('retry-after')), cancel)
97
+ continue
98
+ }
99
+ const message = (body && body.error) || (body && body.message) || (typeof body === 'string' && body) || `HTTP ${res.status}`
100
+ throw new ApiError(res.status, message)
101
+ }
102
+ }
package/src/server.mjs CHANGED
@@ -3,7 +3,8 @@ import path from 'node:path'
3
3
  import os from 'node:os'
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { z } from 'zod'
6
- import { loadConfig, apiGet, apiPost, apiPatch, apiPatchPath, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
6
+ import { loadConfig, apiGet, apiPost, apiPatch, apiPatchPath, apiDelete, apiDeletePath, putToPresignedUrl, ApiError } from './api.mjs'
7
+ import { cancelContext } from './http.mjs'
7
8
  import { STAGES, PLAYBOOKS } from './stages.mjs'
8
9
  import { FOUNDATIONS } from './foundations.mjs'
9
10
  import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
@@ -31,6 +32,22 @@ const TASK_TYPES = ['feature', 'bug', 'improvement', 'tech', 'docs', 'idea', 'de
31
32
  const ACTIVITY_TYPES = ['meeting', 'call', 'followup']
32
33
  const ACTIVITY_STATUSES = ['scheduled', 'done', 'cancelled']
33
34
  const TASK_PRIORITIES = ['high', 'medium', 'low']
35
+ // Relaciones (ORQ-326, ORQ-347) -- misma lista que orquestra-infra/schema/enums/RelationItemType.json.
36
+ const RELATION_TYPES = ['task', 'activity', 'note', 'doc', 'idea', 'risk', 'content', 'changelog', 'distribution', 'service']
37
+ // `relatedTo` en los create_* (ORQ-347): de dónde sale lo que se crea. El server valida el destino
38
+ // antes de crear y deja el vínculo hecho (lo ve list_relations desde los dos lados).
39
+ const RELATED_TO = z.object({
40
+ type: z.enum(RELATION_TYPES).describe('"task" o "activity" (reunión/llamada/seguimiento) normalmente'),
41
+ id: z.string().describe('id del ítem de origen'),
42
+ }).optional().describe('De dónde sale esto (ej. el riesgo/nota/reunión/idea que surgió trabajando en una tarea: {type:"task", id}). Queda vinculado en "Relacionado" de los dos lados -- úsalo en vez de escribir "sale de ORQ-123" en el texto.')
43
+ // Verificaciones por plataforma de una tarea (ORQ-319) -- ver
44
+ // orquestra-infra/functions/api/lib/verifications.mjs para las reglas reales.
45
+ const VERIFICATION_PLATFORMS = ['web', 'ios', 'android', 'api', 'other']
46
+ const VERIFICATION_MODES = ['human', 'agent', 'either']
47
+ const VERIFICATION_STATUSES = ['pending', 'passed', 'failed', 'waived']
48
+ const VERIFICATION_PLATFORM_DESC = 'Plataforma donde se confirma: "web" | "ios" | "android" | "api" | "other". Una verificación por plataforma.'
49
+ const VERIFICATION_INSTRUCTIONS_DESC = 'Pasos CONCRETOS para confirmar que quedó bien (qué abrir, qué hacer, qué se espera ver) -- ej. "En un iPhone físico con la build de TestFlight: abre Tareas, desliza una tarea a la izquierda y confirma que aparece Archivar". Nada genérico tipo "probar que funcione".'
50
+ const VERIFICATION_MODE_DESC = '"human" (default: solo una persona puede confirmarla, ej. dispositivo físico), "agent" (la puede confirmar un agente con revisión con IA) o "either" (cualquiera de los dos).'
34
51
  const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
35
52
  // 'postmortem' faltaba acá (gap preexistente, encontrado 12 ago 2026) --
36
53
  // mismo fix del lado de write.mjs en orquestra-infra.
@@ -100,10 +117,13 @@ Analítica y anuncios en un sitio web: antes de instalar o tocar Google Analytic
100
117
  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"):
101
118
  - 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.
102
119
  - Orquestra es el ÚNICO lugar donde se planea y se lleva el avance del trabajo: el plan de pasos va como checklist de la tarea (add_checklist_item) y se marca con update_checklist_item conforme terminas cada uno. No lleves una lista de pendientes paralela en el chat, en archivos temporales ni en la herramienta de tareas de tu propio cliente -- se pierde al cerrar la sesión y el usuario no la ve. Si retomas trabajo en una sesión nueva, parte de la tarea y sus ítems pendientes (list_tasks + list_checklist_items), no de cero. Excepción: un cambio de un solo paso (renombrar algo, responder una pregunta) no necesita tarea.
103
- - 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.
120
+ - Verificaciones por plataforma (distintas del checklist): cuando terminas código que necesita confirmación humana o en otra plataforma (probar en un iPhone/Android físico, revisar en la web en producción, pegarle al endpoint real desplegado...), NO lo agregues como ítem de checklist ni dejes la tarea abierta sin más. Registra UNA verificación por plataforma con instrucciones concretas (qué abrir, qué hacer, qué se espera ver) -- con add_verification, o con verifications en create_task si ya lo sabes al crearla -- y mueve la tarea a "En revisión" con un comment que diga qué falta confirmar. La tarea no se puede cerrar con verificaciones abiertas y se cierra SOLA cuando todas quedan aprobadas (update_verification status "passed"; si una falla, "failed" con note y la tarea regresa a su estatus anterior). El checklist es para trabajo; las verificaciones, para confirmar. Si la verificación tiene mode "agent"/"either" y hay un agente con revisión con IA, puedes encolarla con verify_with_agent. Nunca marques passed sin evidencia real de que se probó, y no uses waiveVerifications (ni status "waived") salvo que el usuario lo confirme explícito.
121
+ - 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 (o a "En revisión" si quedaron verificaciones por plataforma abiertas, ver arriba) 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.
104
122
  - 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).
105
123
  - Conocimiento que sirve más allá de un proyecto (guías, procesos, decisiones transversales) va en la base del workspace: revisa list_knowledge antes de documentarlo de nuevo, créalo con create_knowledge o súbelo con promote_to_knowledge; lo de un solo proyecto sigue en notas/docs.
106
124
  - Reuniones, llamadas y seguimientos NO son tareas: son actividades (no se entregan, se programan y se registran). Usa log_activity para agendarlas o registrarlas, update_activity para marcarlas hechas con su resultado (outcome) y siguiente paso (nextStep), y list_activities para ver la agenda/bitácora -- nunca create_task con esos tipos. Si del siguiente paso sale trabajo real, eso sí es una tarea (create_task normal, con módulo y fecha).
125
+ - Para dejar constancia de que una nota, doc, idea, tarea, riesgo, pieza de contenido, entrada de changelog, distribución o servicio es DE otra cosa del proyecto -- "esta nota es de la reunión X", "esta tarea sale de este doc" --, vincúlalas con link_items en vez de meter la referencia como texto en la descripción/cuerpo; list_relations muestra todo lo relacionado de un ítem.
126
+ - Si mientras trabajas en una tarea creas algo que sale de ella (un riesgo que detectaste, una reunión, una nota, una idea, una entrada de changelog...), manda relatedTo: {type:"task", id} en el create_* para que quede ligado desde el principio. Si un paso del checklist depende de otra cosa (otra tarea, un doc, un riesgo), vincúlalo desde ese ítem con link_items + checklistItemId; un folio suelto en el texto del ítem ("requiere ORQ-75") el server lo vincula solo.
107
127
  - 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.
108
128
  - 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.
109
129
  - 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.
@@ -137,12 +157,23 @@ const SCOPE_EVIDENCE = {
137
157
  doneSource: z.enum(['manual', 'github', 'sibling']).optional(),
138
158
  commitSha: z.string().optional().describe('commit que respalda que ya está hecho en esa plataforma'),
139
159
  commitUrl: z.string().optional(),
160
+ notApplicable: z.boolean().optional().describe('true = esta plataforma NO aplica para la tarea (cuenta como resuelta en paridad). Solo con el motivo y cuando el usuario lo decidió -- nunca para tapar trabajo pendiente.'),
161
+ notApplicableReason: z.string().optional().describe('Motivo de "no aplica" (obligatorio si notApplicable es true)'),
140
162
  }
141
163
 
142
164
  export function createServer() {
143
165
  const config = loadConfig()
144
166
  const server = new McpServer({ name: 'orquestra-mcp', version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS })
145
167
 
168
+ // Cancelación (ORQ-343): el SDK pasa `extra.signal` (último argumento del handler) y lo aborta
169
+ // si el cliente cancela la tool. Se publica en cancelContext para que request() lo use sin
170
+ // tener que pasarlo a mano por cada tool.
171
+ const registerTool = server.registerTool.bind(server)
172
+ server.registerTool = (name, def, handler) => registerTool(name, def, (...args) => {
173
+ const extra = args[args.length - 1]
174
+ return cancelContext.run({ signal: extra?.signal }, () => handler(...args))
175
+ })
176
+
146
177
  function resolveProjectId(projectId) {
147
178
  if (config.defaultProjectId) return config.defaultProjectId
148
179
  if (!projectId) {
@@ -246,7 +277,7 @@ export function createServer() {
246
277
  registerRead(
247
278
  'search',
248
279
  '/v1/search',
249
- 'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. Tope de 30 resultados con las tareas primero: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
280
+ 'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. También por folio ("ORQ-123", la exacta sale primero) y sin importar acentos. Primero lo que coincide en el folio o el título, después en el cuerpo; tope de 30 resultados: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
250
281
  {
251
282
  q: z.string().describe('Texto a buscar'),
252
283
  types: z.array(z.enum(['task', 'activity', 'note', 'idea', 'doc', 'knowledge'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en todos. knowledge = solo la base vinculada a este proyecto.'),
@@ -613,6 +644,7 @@ export function createServer() {
613
644
  '/v1/tasks',
614
645
  '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. Una reunión, llamada o seguimiento NO es tarea: usa log_activity (type "business" sí es tarea: entregable no-código).',
615
646
  {
647
+ relatedTo: RELATED_TO,
616
648
  title: z.string().describe('Título de la tarea'),
617
649
  description: z.string().optional(),
618
650
  type: z.enum(TASK_TYPES).optional().describe('Default: feature'),
@@ -644,13 +676,18 @@ export function createServer() {
644
676
  resolvedCommitMessage: z.string().optional(),
645
677
  resolvedCommitUrl: z.string().optional(),
646
678
  resolvedCommitAt: z.number().optional().describe('Timestamp en ms del commit resolutor.'),
679
+ verifications: z.array(z.object({
680
+ platform: z.enum(VERIFICATION_PLATFORMS).describe(VERIFICATION_PLATFORM_DESC),
681
+ instructions: z.string().min(1).describe(VERIFICATION_INSTRUCTIONS_DESC),
682
+ mode: z.enum(VERIFICATION_MODES).optional().describe(VERIFICATION_MODE_DESC),
683
+ })).optional().describe('Verificaciones por plataforma desde la creación (mismo efecto que add_verification después). Son confirmaciones humanas o en otra plataforma ("probar en iPhone físico", "revisar en la web en producción"), NO pasos de trabajo -- esos van en el checklist. La tarea no se puede cerrar mientras alguna siga abierta, y se cierra sola cuando todas quedan aprobadas.'),
647
684
  }
648
685
  )
649
686
 
650
687
  registerUpdate(
651
688
  'update_task',
652
689
  '/v1/tasks',
653
- 'Edita una tarea existente (incluido su scope multiplataforma, para resolver desfases de paridad). Si cambias statusId, el server valida que la transición sea válida (nextIds del estatus actual -- ver list_statuses) y deja rastro en el historial de la tarea (quién/cuándo/de qué a qué) -- pásale SIEMPRE `comment` cuando cambies statusId para que ese rastro también diga QUÉ hiciste o POR QUÉ, no solo el cambio de id. No deja pasar a un estatus final (isFinal) con ítems del checklist sin terminar (400 "Quedan X de Y..."): marca primero los ítems que de verdad hiciste con update_checklist_item; usa completeChecklist:true solo si el usuario confirma que lo pendiente ya no aplica o ya está hecho.',
690
+ 'Edita una tarea existente (incluido su scope multiplataforma, para resolver desfases de paridad). Si cambias statusId, el server valida que la transición sea válida (nextIds del estatus actual -- ver list_statuses) y deja rastro en el historial de la tarea (quién/cuándo/de qué a qué) -- pásale SIEMPRE `comment` cuando cambies statusId para que ese rastro también diga QUÉ hiciste o POR QUÉ, no solo el cambio de id. No deja pasar a un estatus final (isFinal) con ítems del checklist sin terminar (400 "Quedan X de Y..."): marca primero los ítems que de verdad hiciste con update_checklist_item; usa completeChecklist:true solo si el usuario confirma que lo pendiente ya no aplica o ya está hecho. Tampoco deja cerrar con verificaciones por plataforma abiertas (pending/failed, ver list_verifications): no cierres a mano -- deja la tarea en "En revisión" y se cierra sola cuando todas quedan aprobadas; waiveVerifications:true solo con confirmación explícita del usuario.',
654
691
  {
655
692
  title: z.string().optional(),
656
693
  description: z.string().optional(),
@@ -658,6 +695,7 @@ export function createServer() {
658
695
  priority: z.enum(TASK_PRIORITIES).optional(),
659
696
  statusId: z.string().optional(),
660
697
  completeChecklist: z.boolean().optional().describe('Solo junto con un statusId final: marca como hechos los ítems pendientes del checklist y cierra (queda comentado en la tarea). Sin esto, cerrar con checklist pendiente responde 400.'),
698
+ waiveVerifications: z.boolean().optional().describe('Solo junto con un statusId final: marca como omitidas ("waived", nota "Omitida al cerrar la tarea") las verificaciones abiertas y cierra (queda comentado en la tarea). Sin esto, cerrar con verificaciones pendientes o fallidas responde 400. NO lo uses salvo que el usuario confirme explícito que esas verificaciones ya no aplican -- lo normal es dejar la tarea en "En revisión" y que se cierre sola al aprobarse.'),
661
699
  categoryId: z.string().optional(),
662
700
  moduleId: z.string().optional(),
663
701
  dueDate: z.string().optional().describe('YYYY-MM-DD'),
@@ -724,6 +762,7 @@ export function createServer() {
724
762
  time: z.string().regex(/^\d{2}:\d{2}$/, 'Formato esperado: HH:MM').optional().describe('Hora local (HH:MM, 24 h), si se sabe.'),
725
763
  activityStatus: z.enum(ACTIVITY_STATUSES).optional().describe('Default "scheduled" (programada). "done" para registrar una que ya pasó.'),
726
764
  ...ACTIVITY_FIELDS_SCHEMA,
765
+ relatedTo: RELATED_TO,
727
766
  },
728
767
  },
729
768
  async ({ projectId, date, time, ...body }) => {
@@ -881,6 +920,99 @@ export function createServer() {
881
920
  (args, projectId) => ({ projectId, status: args.status, type: args.type, moduleId: args.moduleId, from: args.from, to: args.to, limit: args.limit })
882
921
  )
883
922
 
923
+ // Relaciones (ORQ-326): vínculo manual NO dirigido entre dos cosas del proyecto. El server ordena el par y
924
+ // el id es determinístico (vincular dos veces no duplica). Ver orquestra-infra/functions/api/lib/relations.mjs.
925
+ registerRead(
926
+ 'list_relations',
927
+ '/v1/relations',
928
+ 'Sección "Relacionado" de una tarea, actividad (reunión/llamada/seguimiento), nota, doc, idea, riesgo, contenido, entrada de changelog, distribución o servicio: junta los vínculos manuales (link_items, source "manual", con relationId para unlink_items; source "checklist" + checklistItemId si sale de un ítem del checklist de la tarea) Y los que ya salen de campos existentes (nota de la tarea/minuta de la reunión, nota relacionada, ADR publicado, tarea que proviene de una nota, siguiente paso de una reunión, tareas ligadas a una idea -- source = esa etiqueta, relationId null, no se pueden quitar desde acá). Sin duplicados (el manual gana) y sin extremos borrados. No incluye menciones [[...]] -- esas van en get_backlinks.',
929
+ {
930
+ type: z.enum(RELATION_TYPES).describe('"activity" para reunión/llamada/seguimiento, "task" para cualquier otra tarea -- el server rechaza el tipo equivocado'),
931
+ id: z.string().describe('id del ítem (de list_tasks/list_activities/list_notes/list_docs/list_ideas)'),
932
+ },
933
+ (args, projectId) => ({ projectId, type: args.type, id: args.id })
934
+ )
935
+
936
+ registerCreate(
937
+ 'link_items',
938
+ '/v1/relations',
939
+ 'Vincula a mano dos cosas del mismo proyecto (tarea, actividad, nota, doc, idea, riesgo, contenido, entrada de changelog, distribución o servicio) -- ej. "esta nota es de la reunión X", "esta tarea sale de este doc", "este riesgo es del backend de prod". Con checklistItemId + taskId el vínculo sale de un ÍTEM del checklist de esa tarea (ej. "este paso requiere ORQ-75"): se ve bajo el ítem y se borra con él. El vínculo no tiene dirección (el orden a/b da igual, el server lo ordena) y es idempotente: vincular dos veces lo mismo no duplica (responde alreadyLinked). Saca los ids de list_tasks, list_activities, list_notes, list_docs, list_ideas, list_risks, list_content, list_changelog, list_distributions y list_services; una reunión/llamada/seguimiento es "activity", no "task" (el server valida el tipo real y que ambos existan). No se puede vincular algo consigo mismo. Úsalo en vez de escribir "ver reunión X" en el texto.',
940
+ {
941
+ aType: z.enum(RELATION_TYPES).describe('Tipo del primer ítem'),
942
+ aId: z.string().describe('id del primer ítem'),
943
+ bType: z.enum(RELATION_TYPES).describe('Tipo del segundo ítem'),
944
+ bId: z.string().describe('id del segundo ítem'),
945
+ checklistItemId: z.string().optional().describe('id del ítem del checklist (list_checklist_items) del que sale el vínculo -- requiere taskId'),
946
+ taskId: z.string().optional().describe('Con checklistItemId: la tarea dueña del ítem, que tiene que ser uno de los dos extremos (aType "task")'),
947
+ }
948
+ )
949
+
950
+ server.registerTool(
951
+ 'unlink_items',
952
+ {
953
+ description: 'Quita un vínculo manual entre dos ítems (creado con link_items). relationId sale de list_relations (solo los de source "manual" tienen uno; los derivados de campos existentes no se quitan acá). No borra ninguno de los dos ítems. 404 si el vínculo ya no existe.',
954
+ inputSchema: {
955
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
956
+ relationId: z.string().describe('relationId de list_relations, ej. "activity_abc__note_xyz"'),
957
+ },
958
+ },
959
+ async ({ projectId, relationId }) => {
960
+ try {
961
+ return textResult(await apiDelete(config, '/v1/relations', { projectId: resolveProjectId(projectId), id: relationId }))
962
+ } catch (err) {
963
+ return errorResult(err)
964
+ }
965
+ }
966
+ )
967
+
968
+ // Portal del cliente (ORQ-332, 17 oct 2026): UN link por proyecto, sin cuenta,
969
+ // que junta avance, aprobaciones de Contenido, docs públicos y novedades. Ver
970
+ // orquestra-infra/functions/api/lib/clientPortal.mjs. Ruta singleton sin id.
971
+ server.registerTool(
972
+ 'get_client_portal_link',
973
+ {
974
+ description: 'Devuelve el link del portal del cliente del proyecto (una página pública, sin cuenta, para el cliente de un freelancer: avance, piezas de Contenido en revisión para aprobar o pedir cambios, lo ya aprobado, docs con visibilidad pública y novedades). `link: null` si no hay uno activo. El link es una credencial: compártelo solo con el cliente del proyecto.',
975
+ inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
976
+ },
977
+ async ({ projectId }) => {
978
+ try {
979
+ return textResult(await apiGet(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
980
+ } catch (err) {
981
+ return errorResult(err)
982
+ }
983
+ }
984
+ )
985
+
986
+ server.registerTool(
987
+ 'create_client_portal_link',
988
+ {
989
+ description: 'Genera el link del portal del cliente. Si ya había uno, lo REEMPLAZA: el anterior deja de funcionar para quien lo tenga -- confirma con el usuario antes de regenerar uno existente (revisa get_client_portal_link). Qué ve el cliente: avance, Contenido en estatus "review" (puede aprobar o pedir cambios), últimas 10 aprobadas, docs con visibility "public" y novedades del changelog. Nunca tareas, miembros ni notas.',
990
+ inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
991
+ },
992
+ async ({ projectId }) => {
993
+ try {
994
+ return textResult(await apiPost(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
995
+ } catch (err) {
996
+ return errorResult(err)
997
+ }
998
+ }
999
+ )
1000
+
1001
+ server.registerTool(
1002
+ 'revoke_client_portal_link',
1003
+ {
1004
+ description: 'Revoca el link del portal del cliente: el cliente deja de poder abrirlo (por ejemplo, al terminar el proyecto o si el link se filtró). No borra nada del proyecto. Para dar acceso de nuevo, create_client_portal_link genera uno distinto.',
1005
+ inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
1006
+ },
1007
+ async ({ projectId }) => {
1008
+ try {
1009
+ return textResult(await apiDeletePath(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
1010
+ } catch (err) {
1011
+ return errorResult(err)
1012
+ }
1013
+ }
1014
+ )
1015
+
884
1016
  // Fusionar tareas (20 ago 2026) -- registradas directo con
885
1017
  // server.registerTool (no registerCreate/registerUpdate) porque son
886
1018
  // POST a `${path}/${id}/merge|unmerge`, no un CRUD estándar. Fusión
@@ -1404,6 +1536,112 @@ export function createServer() {
1404
1536
  }
1405
1537
  )
1406
1538
 
1539
+ // Verificaciones por plataforma de una tarea (ORQ-319). Distintas del
1540
+ // checklist: el checklist es TRABAJO de la tarea; una verificación es la
1541
+ // CONFIRMACIÓN (humana o de un agente) de que quedó bien en una plataforma.
1542
+ // Mismo criterio que el checklist -- dos ids dinámicos en la ruta, así que
1543
+ // server.registerTool directo. Reglas reales (gate de cierre, cierre
1544
+ // automático, regreso de estatus al fallar) viven en
1545
+ // orquestra-infra/functions/api/lib/verifications.mjs.
1546
+ registerRead(
1547
+ 'list_verifications',
1548
+ '/v1/verifications',
1549
+ 'Lista las verificaciones por plataforma del proyecto (con taskId/shortCode/taskTitle/taskStatusId/assignedTo de su tarea). Por default solo las abiertas (pending + failed) de tareas que no están cerradas -- úsala para ver qué falta confirmar en cada plataforma (ej. "qué tengo que probar en iOS").',
1550
+ {
1551
+ status: z.enum(['open', 'pending', 'failed', 'passed', 'waived', 'all']).optional().describe('Default "open" (pending + failed, excluye tareas en estatus final/cancelado/archivado). "all" trae todo, incluidas tareas cerradas.'),
1552
+ platform: z.enum(VERIFICATION_PLATFORMS).optional().describe('Filtra por plataforma'),
1553
+ },
1554
+ (args, projectId) => ({ projectId, status: args.status, platform: args.platform })
1555
+ )
1556
+
1557
+ server.registerTool(
1558
+ 'add_verification',
1559
+ {
1560
+ description: 'Registra una verificación por plataforma en una tarea: algo que una persona (o un agente) tiene que CONFIRMAR después de que el código ya está hecho -- probar en un iPhone/Android físico, revisar en la web en producción, pegarle al endpoint real, etc. NO es un ítem de checklist (el checklist es trabajo por hacer). Una por plataforma, con instrucciones concretas. Después mueve la tarea a "En revisión": no se puede cerrar mientras haya verificaciones abiertas, y se cierra sola cuando todas quedan aprobadas.',
1561
+ inputSchema: {
1562
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
1563
+ taskId: z.string().describe('ID de la tarea'),
1564
+ platform: z.enum(VERIFICATION_PLATFORMS).describe(VERIFICATION_PLATFORM_DESC),
1565
+ instructions: z.string().min(1).describe(VERIFICATION_INSTRUCTIONS_DESC),
1566
+ mode: z.enum(VERIFICATION_MODES).optional().describe(VERIFICATION_MODE_DESC),
1567
+ },
1568
+ },
1569
+ async ({ projectId, taskId, ...body }) => {
1570
+ try {
1571
+ const data = await apiPost(config, `/v1/tasks/${taskId}/verifications`, { projectId: resolveProjectId(projectId), ...body })
1572
+ return textResult(data)
1573
+ } catch (err) {
1574
+ return errorResult(err)
1575
+ }
1576
+ }
1577
+ )
1578
+
1579
+ server.registerTool(
1580
+ 'update_verification',
1581
+ {
1582
+ description: 'Edita o resuelve una verificación de una tarea. status: "passed" (aprobada), "failed" (falló -- exige note con lo que se vio), "waived" (omitida -- exige note con el motivo; solo si el usuario confirma que ya no aplica) o "pending" (re-verificar: limpia nota y resolución). Al resolver (passed/waived) la ÚLTIMA abierta, el server cierra la tarea solo si su estatus actual tiene un estatus final en sus transiciones y el checklist no tiene pendientes (la respuesta trae autoClosed:true); si no puede, comenta y avisa al responsable. Al marcar "failed", la tarea regresa a su estatus anterior (típico: En revisión → En progreso; ver movedBackTo en la respuesta) y se notifica al responsable. Marca passed solo con evidencia real de que se probó, nunca por suponer que funciona.',
1583
+ inputSchema: {
1584
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
1585
+ taskId: z.string().describe('ID de la tarea'),
1586
+ verificationId: z.string().describe('ID de la verificación (ver list_verifications o la tarea)'),
1587
+ status: z.enum(VERIFICATION_STATUSES).optional().describe('"pending" | "passed" | "failed" | "waived". failed y waived exigen note.'),
1588
+ note: z.string().optional().describe('Obligatoria con status "failed" (qué falló) o "waived" (por qué se omite).'),
1589
+ instructions: z.string().optional().describe(VERIFICATION_INSTRUCTIONS_DESC),
1590
+ platform: z.enum(VERIFICATION_PLATFORMS).optional(),
1591
+ mode: z.enum(VERIFICATION_MODES).optional().describe(VERIFICATION_MODE_DESC),
1592
+ },
1593
+ },
1594
+ async ({ projectId, taskId, verificationId, ...body }) => {
1595
+ try {
1596
+ const data = await apiPatch(config, `/v1/tasks/${taskId}/verifications`, { projectId: resolveProjectId(projectId), id: verificationId, ...body })
1597
+ return textResult(data)
1598
+ } catch (err) {
1599
+ return errorResult(err)
1600
+ }
1601
+ }
1602
+ )
1603
+
1604
+ server.registerTool(
1605
+ 'delete_verification',
1606
+ {
1607
+ description: 'Borra una verificación de una tarea (ej. se registró por error o en la plataforma equivocada). Si la verificación sigue aplicando pero no se va a hacer, no la borres: márcala "waived" con update_verification y una nota, para que quede rastro.',
1608
+ inputSchema: {
1609
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
1610
+ taskId: z.string().describe('ID de la tarea'),
1611
+ verificationId: z.string().describe('ID de la verificación a borrar'),
1612
+ },
1613
+ },
1614
+ async ({ projectId, taskId, verificationId }) => {
1615
+ try {
1616
+ const data = await apiDelete(config, `/v1/tasks/${taskId}/verifications`, { projectId: resolveProjectId(projectId), id: verificationId })
1617
+ return textResult(data)
1618
+ } catch (err) {
1619
+ return errorResult(err)
1620
+ }
1621
+ }
1622
+ )
1623
+
1624
+ server.registerTool(
1625
+ 'verify_with_agent',
1626
+ {
1627
+ description: 'Encola una corrida de agente que confirma una verificación siguiendo sus instrucciones. Solo para verificaciones con mode "agent" o "either", y requiere un agente con revisión con IA activada (aiReviewEnabled). Sin agentId, el server elige el agente por la plataforma (web→"web", ios→"ios", android→"android", api→"node"). La corrida queda "queued": al terminar marca la verificación aprobada/fallida sola (inconclusa la deja pending con un comentario) -- nunca la des por aprobada sin confirmar con get_agent_run.',
1628
+ inputSchema: {
1629
+ projectId: z.string().optional().describe(PROJECT_ID_DESC),
1630
+ taskId: z.string().describe('ID de la tarea'),
1631
+ verificationId: z.string().describe('ID de la verificación a confirmar'),
1632
+ agentId: z.string().optional().describe('Agente específico (ver list_agents) -- si no se manda, se resuelve por la plataforma de la verificación'),
1633
+ },
1634
+ },
1635
+ async ({ projectId, taskId, verificationId, ...body }) => {
1636
+ try {
1637
+ const data = await apiPost(config, `/v1/tasks/${taskId}/verifications/${verificationId}/agent-run`, { projectId: resolveProjectId(projectId), ...body })
1638
+ return textResult(data)
1639
+ } catch (err) {
1640
+ return errorResult(err)
1641
+ }
1642
+ }
1643
+ )
1644
+
1407
1645
  // Adjuntos (imágenes + código) en tasks/notes/ideas/docs -- mismo criterio
1408
1646
  // que checklist: entityType/entityId van en el path (dos segmentos
1409
1647
  // dinámicos), así que server.registerTool directo. Ver
@@ -1642,6 +1880,7 @@ export function createServer() {
1642
1880
  '/v1/ideas',
1643
1881
  'Crea una idea en un proyecto de Orquestra.',
1644
1882
  {
1883
+ relatedTo: RELATED_TO,
1645
1884
  title: z.string().describe('Título de la idea'),
1646
1885
  description: z.string().optional(),
1647
1886
  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).'),
@@ -1840,6 +2079,7 @@ export function createServer() {
1840
2079
  '/v1/changelog',
1841
2080
  'Crea una entrada de changelog en un proyecto de Orquestra (estilo Keep a Changelog).',
1842
2081
  {
2082
+ relatedTo: RELATED_TO,
1843
2083
  section: z.enum(CHANGELOG_SECTIONS).describe('Sección del changelog'),
1844
2084
  text: z.string().describe('Descripción de la entrada'),
1845
2085
  version: z.string().optional(),
@@ -1890,6 +2130,7 @@ export function createServer() {
1890
2130
  '/v1/notes',
1891
2131
  'Crea una nota en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
1892
2132
  {
2133
+ relatedTo: RELATED_TO,
1893
2134
  title: z.string().describe('Título de la nota'),
1894
2135
  body: z.string().optional(),
1895
2136
  pinned: z.boolean().optional(),
@@ -1965,6 +2206,7 @@ export function createServer() {
1965
2206
  '/v1/docs',
1966
2207
  'Crea un documento en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
1967
2208
  {
2209
+ relatedTo: RELATED_TO,
1968
2210
  type: z.enum(DOC_TYPES).optional().describe('Tipo de documento -- obligatorio salvo que mandes templateId (la plantilla trae su tipo)'),
1969
2211
  title: z.string().describe('Título del documento'),
1970
2212
  content: z.string().optional(),
@@ -2085,6 +2327,7 @@ export function createServer() {
2085
2327
  }
2086
2328
 
2087
2329
  registerCreate('create_service', '/v1/services', 'Crea un servicio compartido de infraestructura (backend, base de datos, auth, etc.) en un proyecto. Además de status/notes, acepta los campos específicos de cada tipo (ej. apiUrl/region para backend, engine/provider para database) -- manda solo los que apliquen a `type`.', {
2330
+ relatedTo: RELATED_TO,
2088
2331
  type: z.enum(SERVICE_TYPES).describe('Tipo de servicio'),
2089
2332
  name: z.string().describe('Nombre del servicio'),
2090
2333
  status: z.enum(INFRA_STATUSES).optional().describe('Default: operational'),
@@ -2150,6 +2393,7 @@ export function createServer() {
2150
2393
  })
2151
2394
 
2152
2395
  registerCreate('create_distribution', '/v1/distributions', 'Crea una distribución/plataforma (iOS, Android, Web, etc.) en un proyecto.', {
2396
+ relatedTo: RELATED_TO,
2153
2397
  type: z.enum(PLATFORM_TYPES).describe('Tipo de plataforma'),
2154
2398
  label: z.string().describe('Nombre a mostrar por default'),
2155
2399
  name: z.string().optional().describe('Nombre editable -- si no se manda, la UI usa label'),
@@ -2287,6 +2531,7 @@ export function createServer() {
2287
2531
  {}, (args, projectId) => ({ projectId }))
2288
2532
 
2289
2533
  registerCreate('create_risk', '/v1/risks', 'Registra un riesgo/bloqueo a nivel de proyecto -- algo que puede hacer fallar o atrasar el proyecto en general, no el bloqueo puntual de una tarea (para eso usa relatedTaskId en update_task).', {
2534
+ relatedTo: RELATED_TO,
2290
2535
  title: z.string(),
2291
2536
  description: z.string().optional(),
2292
2537
  probability: z.enum(RISK_LEVELS).optional().describe('Default: medium'),
@@ -2313,6 +2558,7 @@ export function createServer() {
2313
2558
  {}, (args, projectId) => ({ projectId }))
2314
2559
 
2315
2560
  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.', {
2561
+ relatedTo: RELATED_TO,
2316
2562
  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.'),
2317
2563
  channels: z.array(CONTENT_VARIANT_SCHEMA).optional().describe('Pieza multi-canal -- una entrada por canal. No mandes channelId si usás esto.'),
2318
2564
  title: z.string().describe('Referencia interna, no es lo que se publica -- compartida entre todos los canales si es multi-canal'),
@@ -2442,6 +2688,7 @@ export function createServer() {
2442
2688
  runnerId: z.string().optional().describe('Máquina específica -- si se manda, gana sobre la del agente resuelto'),
2443
2689
  taskId: z.string().optional().describe('Acota la revisión con IA (si el agente la tiene activada) a esta tarea en vez de "revisa todo el proyecto" -- útil para pedir "corre QA de esto que acabo de hacer". Sin efecto en los pasos de script (lint/test/build corren igual siempre). Para kind="screenshots" es además OBLIGATORIO en la práctica: es la tarea a la que se van a adjuntar solas las capturas al terminar la corrida -- sin taskId, las capturas se suben pero nadie las liga a nada. No combinar con moduleId.'),
2444
2690
  moduleId: z.string().optional().describe('Igual que taskId pero acota a un módulo completo. No combinar con taskId.'),
2691
+ verificationId: z.string().optional().describe('Verificación de la tarea (requiere taskId) que esta corrida debe confirmar siguiendo sus instrucciones -- al terminar, el resultado la marca aprobada/fallida solo. Solo para verificaciones con mode "agent" o "either" y un agente con revisión con IA activada. Preferí verify_with_agent, que resuelve el agente por la plataforma.'),
2445
2692
  })
2446
2693
 
2447
2694
  server.registerTool(