orquestra-mcp 1.10.0 → 1.14.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 +1 -1
- package/package.json +3 -3
- package/src/api.mjs +13 -0
- package/src/server.mjs +259 -30
- package/src/stages.mjs +3 -2
package/README.md
CHANGED
|
@@ -189,7 +189,7 @@ create/update sigue siendo criterio del agente y del humano.
|
|
|
189
189
|
38 tools: 9 de lectura (contexto/tasks/search + dashboard/workspace/infra/
|
|
190
190
|
paridad/setup guide/stage playbook) + 6 `list_*` + 19 de creación/edición
|
|
191
191
|
(tasks/ideas/changelog/notes/docs + módulos/flujo/categorías/servicios/
|
|
192
|
-
entornos/distribuciones/proyecto) + 2 de
|
|
192
|
+
entornos/distribuciones/proyecto) + 2 de Agentes (create_agent_run/get_agent_run) +
|
|
193
193
|
2 compuestas (init_project, apply_stage_playbook). No hay `delete_*`, ni
|
|
194
194
|
`update_idea`/`update_note`/`update_doc`/`update_changelog_entry`
|
|
195
195
|
todavía. Publicado en npm como `orquestra-mcp` (unscoped) -- `npx
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Servidor MCP de Orquestra --
|
|
3
|
+
"version": "1.14.0",
|
|
4
|
+
"description": "Servidor MCP de Orquestra -- 78 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
|
-
"orquestra-mcp": "
|
|
7
|
+
"orquestra-mcp": "bin/orquestra-mcp.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
package/src/api.mjs
CHANGED
|
@@ -102,3 +102,16 @@ export async function apiDelete(config, path, { projectId, id }) {
|
|
|
102
102
|
})
|
|
103
103
|
return parseResponse(res)
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
// PUT crudo a una presigned URL de S3 (createAttachmentUploadUrl en
|
|
107
|
+
// orquestra-infra) -- a diferencia de apiGet/apiPost/etc, este request NO
|
|
108
|
+
// va contra config.apiBase ni lleva el Bearer del MCP: la URL firmada YA
|
|
109
|
+
// es la autorización completa.
|
|
110
|
+
export async function putToPresignedUrl(uploadUrl, buffer, contentType) {
|
|
111
|
+
const res = await fetch(uploadUrl, {
|
|
112
|
+
method: 'PUT',
|
|
113
|
+
headers: { 'Content-Type': contentType },
|
|
114
|
+
body: buffer,
|
|
115
|
+
})
|
|
116
|
+
if (!res.ok) throw new Error(`Falló la subida a S3: HTTP ${res.status}`)
|
|
117
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
1
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
4
|
import { z } from 'zod'
|
|
3
|
-
import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, ApiError } from './api.mjs'
|
|
5
|
+
import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
|
|
4
6
|
import { STAGES, PLAYBOOKS } from './stages.mjs'
|
|
5
7
|
import { FOUNDATIONS } from './foundations.mjs'
|
|
6
8
|
|
|
@@ -36,6 +38,15 @@ const PLATFORM_TYPES = ['android', 'ios', 'web', 'desktop-mac', 'desktop-win', '
|
|
|
36
38
|
const INFRA_STATUSES = ['operational', 'degraded', 'down']
|
|
37
39
|
const STAGE_IDS = STAGES.map(s => s.id)
|
|
38
40
|
|
|
41
|
+
// Adjuntos -- entityType en la tool es singular (task/note/idea/doc, más
|
|
42
|
+
// natural para el modelo), la API usa el plural de la colección de
|
|
43
|
+
// Firestore (tasks/notes/ideas/docs) -- este mapa traduce entre los dos.
|
|
44
|
+
const ATTACHMENT_ENTITY_PATH = { task: 'tasks', note: 'notes', idea: 'ideas', doc: 'docs' }
|
|
45
|
+
const ATTACHMENT_ENTITY_TYPES = Object.keys(ATTACHMENT_ENTITY_PATH)
|
|
46
|
+
const ATTACHMENT_IMAGE_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
47
|
+
const MAX_ATTACHMENT_IMAGE_BYTES = 15 * 1024 * 1024
|
|
48
|
+
const MAX_ATTACHMENT_CODE_CHARS = 200_000
|
|
49
|
+
|
|
39
50
|
const SETUP_GUIDE = {
|
|
40
51
|
intro: 'Para configurar o sincronizar un proyecto completo: revisa primero con la tool de "check" de cada paso; si ya existe algo que coincide por name/type/title, edítalo con la tool de "update"; si no existe, créalo con la de "create". Sigue el orden -- los pasos 4 en adelante referencian ids de los pasos 1-3 (statusId, categoryId, moduleId, distId).',
|
|
41
52
|
steps: [
|
|
@@ -66,6 +77,8 @@ Si get_project_context devuelve un proyecto vacío (sin módulos ni tareas), es
|
|
|
66
77
|
- Si es una idea nueva: llama a get_setup_guide y ofrece un playbook de etapa "pre" (get_stage_playbook + apply_stage_playbook) -- ahí sí basta con eso, el proyecto de verdad no tiene nada más que sincronizar todavía.
|
|
67
78
|
- Si ya existe y está avanzado: NO te limites a update_project ni a las distributions -- eso es solo el primer paso, no el trabajo completo. Llama a get_setup_guide y recorre sus 9 pasos en orden real (estatus/flujo, categorías, infraestructura completa -- servicios Y entornos, no solo distribuciones --, módulos, tareas, ideas, notas, changelog, docs), usando list_* de cada uno antes de crear para no duplicar. No asumas ni dejes vacío lo que no puedas inferir del código -- pregúntale al usuario los servicios/entornos reales, los módulos o áreas de trabajo, y las tareas pendientes reales que tenga en mente. Ofrece el playbook de etapa "build" (no "pre") solo como plantilla de módulo+tareas inicial, no como sustituto de sincronizar el resto.
|
|
68
79
|
|
|
80
|
+
Al aplicar un playbook con apply_stage_playbook -- sobre todo el de etapa "post" (marketing/contenido) -- nunca asumas herramientas o canales que este proyecto no tiene registrados en Orquestra. Antes de crear tareas de contenido/publicación, llama a list_channels: si viene vacío, la plantilla ya incluye una tarea para conectar canales reales primero -- no la saltees ni generes tareas del tipo "publica en Instagram/LinkedIn/TikTok" para un canal que el proyecto no tiene. Si list_channels SÍ trae canales reales, no te quedes solo con la tarea recordatorio de "programa tu contenido" -- además creá piezas REALES con create_content (un plan de lanzamiento concreto: teaser, anuncio, demo del caso de uso, historia de por qué se construyó, 1-2 testimonios, repartidos entre esos canales) usando siempre un channelId de list_channels, nunca un canal inventado. Mismo criterio para cualquier otra tarea del playbook que dependa de una integración -- si el hueco es real (ej. no hay servicios/distribuciones configurados), la tarea correcta es configurar eso primero, no asumir que ya existe.
|
|
81
|
+
|
|
69
82
|
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"):
|
|
70
83
|
- 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.
|
|
71
84
|
- 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.
|
|
@@ -76,7 +89,8 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
|
|
|
76
89
|
- Si tienes acceso al repo real de una distribución (ej. puedes correr "git remote get-url origin" en ese código), llena repoUrl en create_distribution/update_distribution sin preguntar -- es solo informativo, no requiere credenciales. Esto es distinto de conectar el repo con PAT para el changelog automático (Workspace → Conexiones en orquestra-web): eso sí es un paso deliberado del usuario, con una credencial real -- nunca lo hagas tú solo ni sugieras que ya está conectado por haber llenado repoUrl.
|
|
77
90
|
- Al crear o editar una tarea (o un módulo), llena TODO campo para el que tengas un dato real, no solo title/moduleId/dueDate/comment: type (no lo dejes en el default "feature" si en realidad es un bug/mejora/doc/etc.), tags, stack (tecnologías que genuinamente tocaste), scope (paridad multiplataforma, si aplica), y los campos de código (branch/prNumber/prUrl/prStatus/commitCount/resolvedCommitSha/resolvedCommitMessage/resolvedCommitUrl/resolvedCommitAt) cuando de verdad sepas esos valores (el branch en el que estás trabajando, el PR que abriste, el commit que resolvió la tarea). No dejes un campo en blanco solo porque no es obligatorio -- pero esto no es licencia para inventar: si no tenés el dato real (ej. no hay branch/PR porque el proyecto commitea directo a main), dejalo vacío en vez de rellenarlo con algo plausible pero falso -- mismo criterio que ya se pedía para resolvedCommitSha.
|
|
78
91
|
- Si una tarea tiene más de una actividad o paso concreto, no los amontones como texto corrido en description -- usa el checklist (add_checklist_item, un ítem por actividad, opcionalmente con responsable) para que cada paso se pueda marcar independientemente. description queda para el contexto/propósito general de la tarea (el qué y el por qué), no como lista de pasos. Si estás editando una tarea existente cuya description ya creció así, migra esos puntos a checklist en vez de seguir agregando ahí.
|
|
79
|
-
-
|
|
92
|
+
- Al cerrar una tarea en la que trabajaste de verdad (o al final de una sesión de trabajo real sobre ella), registrá las horas con log_time -- el tiempo REAL que llevó ese trabajo en esta sesión (aproximalo con honestidad: minutos de trabajo real, no la duración del chat ni la estimación de la tarea), con una nota corta de qué se hizo. Ese registro alimenta "Horas del equipo" en Inversión de Contenido y es la única forma de que el costo real del proyecto no dependa de que el usuario lo cargue a mano. No lo inventes ni lo infles; si genuinamente no podés estimar el tiempo de la sesión, no registres nada.
|
|
93
|
+
- No todas las tareas necesitan una corrida de QA -- clasifica antes de disparar una: cambios que afectan comportamiento real (features, bugs, UI, lógica de negocio, endpoints/API) sí la necesitan; cambios que no alteran comportamiento (docs, comentarios, refactors sin cambio funcional, config/tooling interno, notas/reuniones/ideas) no. Si decides que sí, revisa primero con list_agents que exista un agente (kind="qa") para el stack que tocaste (si no hay ninguno, créalo con create_agent -- necesita una máquina ya emparejada, ver Workspace → Máquinas) y llama a create_agent_run en la rama real de ese trabajo (manda taskId si el trabajo era de una tarea puntual -- si el agente tiene la revisión con IA activada, la acota a eso en vez de "revisa todo el proyecto"), dejando la tarea en "En revisión" -- NUNCA la muevas a "Listo" ni digas que "QA lo revisó" sin haber confirmado con get_agent_run un estatus terminal "passed" de verdad (las corridas son asíncronas, tardan minutos en otra máquina). Si tarda, dile al usuario que quedó en cola (con el runId) y que confirmas cuando termine -- no lo des por hecho nunca.
|
|
80
94
|
|
|
81
95
|
Si el usuario pide "sincronizar"/"revisar que todo esté al día"/"verificar el proyecto" (o algo equivalente -- una auditoría manual, no el trabajo normal de arriba), hacé una "revisión de sincronización" completa, de cero, no un vistazo rápido:
|
|
82
96
|
1. Llamá a get_sync_report -- devuelve lo objetivo (tareas sin moduleId, tareas activas sin dueDate, referencias huérfanas a un módulo/categoría/tarea/estatus que ya no existe) y activeTasksSnapshot (TODAS las tareas activas, no solo las incompletas).
|
|
@@ -93,7 +107,7 @@ Si estás iniciando una sesión de trabajo real en un proyecto que YA tiene cód
|
|
|
93
107
|
|
|
94
108
|
export function createServer() {
|
|
95
109
|
const config = loadConfig()
|
|
96
|
-
const server = new McpServer({ name: 'orquestra-mcp', version: '1.
|
|
110
|
+
const server = new McpServer({ name: 'orquestra-mcp', version: '1.14.0' }, { instructions: SERVER_INSTRUCTIONS })
|
|
97
111
|
|
|
98
112
|
function resolveProjectId(projectId) {
|
|
99
113
|
if (config.defaultProjectId) return config.defaultProjectId
|
|
@@ -568,6 +582,127 @@ export function createServer() {
|
|
|
568
582
|
}
|
|
569
583
|
)
|
|
570
584
|
|
|
585
|
+
// Adjuntos (imágenes + código) en tasks/notes/ideas/docs -- mismo criterio
|
|
586
|
+
// que checklist: entityType/entityId van en el path (dos segmentos
|
|
587
|
+
// dinámicos), así que server.registerTool directo. Ver
|
|
588
|
+
// orquestra-infra/functions/api/lib/attachments.mjs.
|
|
589
|
+
server.registerTool(
|
|
590
|
+
'list_attachments',
|
|
591
|
+
{
|
|
592
|
+
description: 'Lista los adjuntos (imágenes + código) de una task/note/idea/doc de Orquestra.',
|
|
593
|
+
inputSchema: {
|
|
594
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
595
|
+
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña de los adjuntos'),
|
|
596
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
async ({ projectId, entityType, entityId }) => {
|
|
600
|
+
try {
|
|
601
|
+
const plural = ATTACHMENT_ENTITY_PATH[entityType]
|
|
602
|
+
const data = await apiGet(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolveProjectId(projectId) })
|
|
603
|
+
return textResult(data)
|
|
604
|
+
} catch (err) {
|
|
605
|
+
return errorResult(err)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
// filePath vs content son alternativas -- filePath es lo normal para
|
|
611
|
+
// evidencia real (una captura, o un archivo de código ya en el repo);
|
|
612
|
+
// content sirve para pegar un snippet generado en la sesión sin haberlo
|
|
613
|
+
// guardado a un archivo. NUNCA se acepta base64 en el input: para una
|
|
614
|
+
// imagen, este server lee los bytes él mismo (tiene filesystem real) y
|
|
615
|
+
// los sube directo a S3 -- mandarlos por el input de la tool los metería
|
|
616
|
+
// en el contexto del modelo, carísimo e innecesario.
|
|
617
|
+
server.registerTool(
|
|
618
|
+
'add_attachment',
|
|
619
|
+
{
|
|
620
|
+
description: 'Adjunta evidencia (una imagen o un pedazo de código) a una task/note/idea/doc de Orquestra. Manda EXACTAMENTE uno de filePath (ruta local -- una imagen png/jpg/jpeg/gif/webp se sube a S3, cualquier otra extensión se lee como texto/código) o content (texto/código directo, sin tocar el filesystem). Nunca mandes bytes de imagen en base64 dentro de content -- usa filePath para que este server los suba él mismo.',
|
|
621
|
+
inputSchema: {
|
|
622
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
623
|
+
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad a la que se adjunta'),
|
|
624
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
625
|
+
filePath: z.string().optional().describe('Ruta local a un archivo -- imagen (png/jpg/jpeg/gif/webp) o código/texto.'),
|
|
626
|
+
content: z.string().optional().describe('Texto/código a adjuntar directo, sin leer un archivo.'),
|
|
627
|
+
language: z.string().optional().describe('Label libre del lenguaje del snippet (ej. "python", "diff") -- solo informativo, no tokeniza (Orquestra no resalta sintaxis).'),
|
|
628
|
+
fileName: z.string().optional().describe('Nombre a mostrar -- si no se manda, sale de filePath o de un default genérico.'),
|
|
629
|
+
caption: z.string().optional().describe('Descripción corta de la evidencia (ej. "output del test que falló").'),
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
async ({ projectId, entityType, entityId, filePath, content, language, fileName, caption }) => {
|
|
633
|
+
try {
|
|
634
|
+
if (!filePath && !content) throw new Error('Manda filePath o content')
|
|
635
|
+
if (filePath && content) throw new Error('Manda solo uno de filePath o content -- no los dos')
|
|
636
|
+
const resolvedProjectId = resolveProjectId(projectId)
|
|
637
|
+
const plural = ATTACHMENT_ENTITY_PATH[entityType]
|
|
638
|
+
|
|
639
|
+
let body
|
|
640
|
+
if (content) {
|
|
641
|
+
if (content.length > MAX_ATTACHMENT_CODE_CHARS) {
|
|
642
|
+
throw new Error(`El snippet supera el máximo de ${MAX_ATTACHMENT_CODE_CHARS} caracteres`)
|
|
643
|
+
}
|
|
644
|
+
body = { kind: 'code', content, language, fileName: fileName || 'snippet.txt', caption }
|
|
645
|
+
} else {
|
|
646
|
+
const stat = statSync(filePath)
|
|
647
|
+
const ext = path.extname(filePath).slice(1).toLowerCase()
|
|
648
|
+
const mime = ATTACHMENT_IMAGE_MIME_BY_EXT[ext]
|
|
649
|
+
const resolvedFileName = fileName || path.basename(filePath)
|
|
650
|
+
if (mime) {
|
|
651
|
+
if (stat.size > MAX_ATTACHMENT_IMAGE_BYTES) {
|
|
652
|
+
throw new Error(`La imagen supera el máximo de ${MAX_ATTACHMENT_IMAGE_BYTES / 1024 / 1024}MB`)
|
|
653
|
+
}
|
|
654
|
+
const buffer = readFileSync(filePath)
|
|
655
|
+
const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
|
|
656
|
+
projectId: resolvedProjectId, entityType: plural, entityId,
|
|
657
|
+
fileName: resolvedFileName, fileType: mime, fileSize: stat.size,
|
|
658
|
+
})
|
|
659
|
+
await putToPresignedUrl(uploadUrl, buffer, mime)
|
|
660
|
+
body = { kind: 'image', key, fileName: resolvedFileName, fileType: mime, fileSize: stat.size, caption }
|
|
661
|
+
} else {
|
|
662
|
+
const text = readFileSync(filePath, 'utf8')
|
|
663
|
+
if (text.length > MAX_ATTACHMENT_CODE_CHARS) {
|
|
664
|
+
throw new Error(`El archivo supera el máximo de ${MAX_ATTACHMENT_CODE_CHARS} caracteres`)
|
|
665
|
+
}
|
|
666
|
+
body = { kind: 'code', content: text, language, fileName: resolvedFileName, caption }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const data = await apiPost(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolvedProjectId, ...body })
|
|
671
|
+
return textResult(data)
|
|
672
|
+
} catch (err) {
|
|
673
|
+
return errorResult(err)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
// Segunda excepción real a "creación + edición, nunca borrado" (junto a
|
|
679
|
+
// delete_checklist_item, ver CLAUDE.md) -- decisión consciente de José:
|
|
680
|
+
// un agente puede subir evidencia equivocada (screenshot que no era,
|
|
681
|
+
// snippet viejo) y conviene que la pueda limpiar sin depender de un
|
|
682
|
+
// humano. Borra también el objeto en S3 si el adjunto era una imagen
|
|
683
|
+
// (ver deleteAttachment en attachments.mjs de orquestra-infra).
|
|
684
|
+
server.registerTool(
|
|
685
|
+
'delete_attachment',
|
|
686
|
+
{
|
|
687
|
+
description: 'Borra un adjunto (imagen o código) de una task/note/idea/doc de Orquestra -- limpia también el objeto en S3 si era una imagen. Segunda excepción a "nunca borrado" (junto a delete_checklist_item) -- usalo para limpiar evidencia subida por error, no como reemplazo de mantener historial.',
|
|
688
|
+
inputSchema: {
|
|
689
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
690
|
+
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña del adjunto'),
|
|
691
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
692
|
+
attachmentId: z.string().describe('ID del adjunto a borrar (ver list_attachments)'),
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
async ({ projectId, entityType, entityId, attachmentId }) => {
|
|
696
|
+
try {
|
|
697
|
+
const plural = ATTACHMENT_ENTITY_PATH[entityType]
|
|
698
|
+
const data = await apiDelete(config, `/v1/${plural}/${entityId}/attachments`, { projectId: resolveProjectId(projectId), id: attachmentId })
|
|
699
|
+
return textResult(data)
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return errorResult(err)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
)
|
|
705
|
+
|
|
571
706
|
server.registerTool(
|
|
572
707
|
'convert_checklist_item_to_task',
|
|
573
708
|
{
|
|
@@ -592,6 +727,51 @@ export function createServer() {
|
|
|
592
727
|
}
|
|
593
728
|
)
|
|
594
729
|
|
|
730
|
+
// Horas de una tarea (22 ago 2026, tarea "Time-tracking real") -- mismo
|
|
731
|
+
// criterio de registro directo que las tools de checklist (taskId en el
|
|
732
|
+
// path). Solo listar + registrar: corregir/borrar una entrada es de la
|
|
733
|
+
// web, este canal no borra (ver gotcha "nunca borrado" en CLAUDE.md).
|
|
734
|
+
server.registerTool(
|
|
735
|
+
'list_time_entries',
|
|
736
|
+
{
|
|
737
|
+
description: 'Lista los registros de horas trabajadas de una tarea de Orquestra (horas, fecha, nota, quién). El total ya vive desnormalizado en la tarea (hoursLogged) -- usa esta tool solo si necesitás el detalle entrada por entrada.',
|
|
738
|
+
inputSchema: {
|
|
739
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
740
|
+
taskId: z.string().describe('ID de la tarea'),
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
async ({ projectId, taskId }) => {
|
|
744
|
+
try {
|
|
745
|
+
const data = await apiGet(config, `/v1/tasks/${taskId}/time`, { projectId: resolveProjectId(projectId) })
|
|
746
|
+
return textResult(data)
|
|
747
|
+
} catch (err) {
|
|
748
|
+
return errorResult(err)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
server.registerTool(
|
|
754
|
+
'log_time',
|
|
755
|
+
{
|
|
756
|
+
description: 'Registra horas trabajadas en una tarea de Orquestra -- el time-tracking honesto que alimenta "Horas del equipo" en Inversión de Contenido (horas del mes × tarifa del proyecto). Registrá el tiempo REAL de trabajo en la tarea (el de esta sesión, o el que el usuario te diga), nunca un número inventado ni la estimación de la tarea (estimatedDays es otra cosa). La entrada queda atribuida al miembro del token (para un agente, "agent_<slug>").',
|
|
757
|
+
inputSchema: {
|
|
758
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
759
|
+
taskId: z.string().describe('ID de la tarea en la que se trabajó'),
|
|
760
|
+
hours: z.number().positive().describe('Horas trabajadas, > 0 -- admite fracciones (0.5 = media hora)'),
|
|
761
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('El día en que se TRABAJÓ (no cuándo se registra) -- default hoy. Es el corte mensual de Inversión.'),
|
|
762
|
+
note: z.string().optional().describe('Qué se hizo en esas horas, texto corto'),
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
async ({ projectId, taskId, ...body }) => {
|
|
766
|
+
try {
|
|
767
|
+
const data = await apiPost(config, `/v1/tasks/${taskId}/time`, { projectId: resolveProjectId(projectId), ...body })
|
|
768
|
+
return textResult(data)
|
|
769
|
+
} catch (err) {
|
|
770
|
+
return errorResult(err)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
)
|
|
774
|
+
|
|
595
775
|
registerCreate(
|
|
596
776
|
'create_idea',
|
|
597
777
|
'/v1/ideas',
|
|
@@ -876,6 +1056,40 @@ export function createServer() {
|
|
|
876
1056
|
}
|
|
877
1057
|
)
|
|
878
1058
|
|
|
1059
|
+
// Convertir nota → tarea (23 ago 2026) -- mismo patrón que
|
|
1060
|
+
// convert_idea_to_task. Compuesta: POST /v1/notes/{id}/convert-to-task en
|
|
1061
|
+
// orquestra-infra crea la tarea (reusando createTask tal cual, mismas
|
|
1062
|
+
// reglas que create_task) Y en el mismo paso liga los dos lados: la nota
|
|
1063
|
+
// queda con taskId (ya existía, antes solo se llenaba a mano) y la tarea
|
|
1064
|
+
// nueva queda con sourceNoteId (campo nuevo en tasks.json) apuntando de
|
|
1065
|
+
// vuelta a la nota que la originó. La nota NUNCA se borra -- queda
|
|
1066
|
+
// trazable. No se puede convertir una nota que ya tiene taskId.
|
|
1067
|
+
server.registerTool(
|
|
1068
|
+
'convert_note_to_task',
|
|
1069
|
+
{
|
|
1070
|
+
description: 'Convierte una nota en una tarea real -- crea la tarea (mismas reglas que create_task: moduleId y dueDate son obligatorios, de la nota o de este llamado) y liga los dos lados: la nota queda con taskId ("Se convirtió en la tarea X") y la tarea nueva queda con sourceNoteId ("Proviene de la nota X"). La nota nunca se borra. No se puede convertir una nota que ya fue convertida antes.',
|
|
1071
|
+
inputSchema: {
|
|
1072
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1073
|
+
noteId: z.string().describe('Nota a convertir -- ver list_notes/search para encontrar su id.'),
|
|
1074
|
+
title: z.string().optional().describe('Default: el título de la nota.'),
|
|
1075
|
+
description: z.string().optional().describe('Default: el body de la nota.'),
|
|
1076
|
+
moduleId: z.string().optional().describe('Solo si la nota no tiene moduleId propio -- si no existe uno relacionado, revisá list_modules/create_module primero.'),
|
|
1077
|
+
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('OBLIGATORIO -- las notas no tienen dueDate propio, calculala con criterio real (prioridad/tamaño del trabajo), nunca un valor arbitrario.'),
|
|
1078
|
+
priority: z.enum(TASK_PRIORITIES).optional().describe('Default: medium -- las notas no tienen priority propia.'),
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
async ({ projectId, noteId, title, description, moduleId, dueDate, priority }) => {
|
|
1082
|
+
try {
|
|
1083
|
+
const data = await apiPost(config, `/v1/notes/${noteId}/convert-to-task`, {
|
|
1084
|
+
projectId: resolveProjectId(projectId), title, description, moduleId, dueDate, priority,
|
|
1085
|
+
})
|
|
1086
|
+
return textResult(data)
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
return errorResult(err)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
)
|
|
1092
|
+
|
|
879
1093
|
registerCreate(
|
|
880
1094
|
'create_doc',
|
|
881
1095
|
'/v1/docs',
|
|
@@ -1224,39 +1438,50 @@ export function createServer() {
|
|
|
1224
1438
|
translationOf: z.string().optional(),
|
|
1225
1439
|
})
|
|
1226
1440
|
|
|
1227
|
-
// ═══
|
|
1441
|
+
// ═══ AGENTES (módulo de runners, generalizado 22 ago 2026 desde "QA" --
|
|
1442
|
+
// ver orquestra-qa-agent) ═══════════════════════════════════════════════════
|
|
1228
1443
|
// Asíncronas: una corrida puede tardar minutos en otra máquina. Ver
|
|
1229
1444
|
// SERVER_INSTRUCTIONS para la regla de no asumir "passed" sin confirmar.
|
|
1230
1445
|
//
|
|
1231
|
-
// Un agente
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1446
|
+
// Un agente tiene un `kind` atado a una máquina (runnerId); los 3 kinds de
|
|
1447
|
+
// hoy (23 ago 2026: qa/security/performance) exigen stack+localPath (un
|
|
1448
|
+
// checkout real en esa máquina), y performance además perfUrl (URL viva
|
|
1449
|
+
// contra la que corre Lighthouse). Los 3 solo tienen lógica real para
|
|
1450
|
+
// stack="web" por ahora. "orquestra-qa-agent setup" crea/actualiza solo
|
|
1451
|
+
// agentes kind="qa" -- security/performance se crean a mano acá o desde
|
|
1452
|
+
// la web.
|
|
1453
|
+
const AGENT_KINDS = ['qa', 'security', 'performance']
|
|
1454
|
+
const AGENT_STACKS = ['web', 'ios', 'android', 'node', 'nestjs']
|
|
1455
|
+
|
|
1456
|
+
registerRead('list_agents', '/v1/agents',
|
|
1457
|
+
'Lista los agentes (kind+máquina+stack+ruta local) configurados en un proyecto. Úsala antes de create_agent o de create_agent_run con stack, para saber si ya existe uno.',
|
|
1458
|
+
{ kind: z.enum(AGENT_KINDS).optional().describe('Filtra por kind: "qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse)') },
|
|
1459
|
+
(args, projectId) => ({ projectId }))
|
|
1460
|
+
|
|
1461
|
+
registerCreate('create_agent', '/v1/agents', 'Crea un agente: asocia un kind ("qa" | "security" | "performance") a una máquina (runner, ver Workspace → Máquinas), un stack de este proyecto y una ruta de checkout en ESA máquina. kind="performance" además necesita perfUrl.', {
|
|
1242
1462
|
name: z.string().describe('Nombre a mostrar, ej. "Agente web"'),
|
|
1243
|
-
|
|
1244
|
-
runnerId: z.string().describe('ID de la máquina emparejada -- ver
|
|
1245
|
-
|
|
1463
|
+
kind: z.enum(AGENT_KINDS).default('qa').describe('"qa" (lint/test/build/E2E), "security" (npm audit + gitleaks) o "performance" (build size + Lighthouse) -- default "qa"'),
|
|
1464
|
+
runnerId: z.string().describe('ID de la máquina emparejada -- ver list_agents o Workspace → Máquinas'),
|
|
1465
|
+
stack: z.enum(AGENT_STACKS).optional().describe('Requerido para los 3 kinds -- security/performance solo tienen lógica real para "web" hoy'),
|
|
1466
|
+
localPath: z.string().optional().describe('Requerido para los 3 kinds -- ruta absoluta del checkout en esa máquina'),
|
|
1246
1467
|
branch: z.string().optional().describe('Rama de este agente -- default "main" si no se manda'),
|
|
1247
1468
|
distributionId: z.string().optional().describe('Distribución vinculada (repo/fuente de conocimiento)'),
|
|
1248
1469
|
enabled: z.boolean().optional(),
|
|
1249
1470
|
createTasks: z.boolean().optional().describe('Si los fallos de este agente generan tareas automáticas -- default true'),
|
|
1250
1471
|
openStatusId: z.string().optional().describe('Estatus donde se abren los bugs de este agente -- default: estatus inicial del proyecto'),
|
|
1251
1472
|
closeStatusId: z.string().optional().describe('Estatus donde se mueven los bugs de este agente al cerrarse solos -- default: primer estatus final'),
|
|
1252
|
-
testUserEmail: z.string().optional().describe('
|
|
1473
|
+
testUserEmail: z.string().optional().describe('Específico de kind="qa" -- cuenta de prueba DEDICADA (no una credencial real) que la revisión con IA puede usar para iniciar sesión y revisar pantallas autenticadas'),
|
|
1253
1474
|
testUserPassword: z.string().optional().describe('Password de testUserEmail'),
|
|
1254
|
-
plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- recordatorio manual de "próxima corrida planeada".
|
|
1475
|
+
plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- recordatorio manual de "próxima corrida planeada". Ningún kind tiene scheduling real (todo trigger es manual/push): esto NO dispara ninguna corrida sola, solo alimenta el calendario global.'),
|
|
1476
|
+
perfUrl: z.string().optional().describe('Requerido si kind="performance" -- URL viva (ej. staging/prod) contra la que corre Lighthouse'),
|
|
1477
|
+
maxBundleSizeKb: z.number().optional().describe('Específico de kind="performance" -- umbral en KB para el paso de tamaño de build (default 5000)'),
|
|
1478
|
+
minPerfScore: z.number().optional().describe('Específico de kind="performance" -- score mínimo de Lighthouse (0-100) para pasar (default 50)'),
|
|
1255
1479
|
})
|
|
1256
1480
|
|
|
1257
|
-
registerUpdate('
|
|
1481
|
+
registerUpdate('update_agent', '/v1/agents', 'Edita un agente existente.', {
|
|
1258
1482
|
name: z.string().optional(),
|
|
1259
|
-
|
|
1483
|
+
kind: z.enum(AGENT_KINDS).optional(),
|
|
1484
|
+
stack: z.enum(AGENT_STACKS).optional(),
|
|
1260
1485
|
runnerId: z.string().optional(),
|
|
1261
1486
|
localPath: z.string().optional(),
|
|
1262
1487
|
branch: z.string().optional(),
|
|
@@ -1267,32 +1492,36 @@ export function createServer() {
|
|
|
1267
1492
|
closeStatusId: z.string().optional(),
|
|
1268
1493
|
testUserEmail: z.string().optional().describe('Cuenta de prueba DEDICADA (no una credencial real) para que la revisión con IA inicie sesión'),
|
|
1269
1494
|
testUserPassword: z.string().optional(),
|
|
1270
|
-
plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver
|
|
1495
|
+
plannedRunAt: z.string().optional().describe('YYYY-MM-DD -- ver create_agent.'),
|
|
1496
|
+
perfUrl: z.string().optional().describe('Ver create_agent -- específico de kind="performance"'),
|
|
1497
|
+
maxBundleSizeKb: z.number().optional(),
|
|
1498
|
+
minPerfScore: z.number().optional(),
|
|
1271
1499
|
})
|
|
1272
1500
|
|
|
1273
|
-
registerCreate('
|
|
1274
|
-
agentId: z.string().optional().describe('Agente específico -- si se manda, gana sobre stack'),
|
|
1501
|
+
registerCreate('create_agent_run', '/v1/agent-runs', 'Dispara una corrida de agente (qa: lint/tests/build/E2E; security: npm audit + gitleaks; performance: build size + Lighthouse) en la rama indicada. Necesita al menos un agente activo para el stack (o falla explícito -- ver list_agents/create_agent). La corrida queda "queued" -- nunca asumas que pasó, confirma con get_agent_run.', {
|
|
1502
|
+
agentId: z.string().optional().describe('Agente específico -- si se manda, gana sobre stack/kind'),
|
|
1275
1503
|
branch: z.string().optional().describe('Rama a probar -- default: la del agente, o "main"'),
|
|
1276
1504
|
commit: z.string().optional(),
|
|
1277
1505
|
trigger: z.enum(['manual', 'push', 'schedule']).optional().describe('Default: manual'),
|
|
1278
|
-
stack: z.string().optional().describe('Stack a probar (ej. "web", "ios", "android") -- resuelve contra los agentes activos de ese stack. Falla si hay 0 (créalo con
|
|
1506
|
+
stack: z.string().optional().describe('Stack a probar (ej. "web", "ios", "android") -- resuelve contra los agentes activos de ese stack. Falla si hay 0 (créalo con create_agent) o más de 1 (usa agentId o suma kind para desambiguar).'),
|
|
1507
|
+
kind: z.enum(AGENT_KINDS).optional().describe('Desambigua junto con stack cuando el proyecto tiene más de un agente para el mismo stack (ej. un "qa" y un "security", ambos "web") -- sin esto, resuelve por stack solo y falla explícito si hay más de uno.'),
|
|
1279
1508
|
runnerId: z.string().optional().describe('Máquina específica -- si se manda, gana sobre la del agente resuelto'),
|
|
1280
1509
|
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). No combinar con moduleId.'),
|
|
1281
1510
|
moduleId: z.string().optional().describe('Igual que taskId pero acota a un módulo completo. No combinar con taskId.'),
|
|
1282
1511
|
})
|
|
1283
1512
|
|
|
1284
1513
|
server.registerTool(
|
|
1285
|
-
'
|
|
1514
|
+
'get_agent_run',
|
|
1286
1515
|
{
|
|
1287
|
-
description: 'Consulta el estatus real de una corrida de
|
|
1516
|
+
description: 'Consulta el estatus real de una corrida de agente por id (queued|running|passed|failed|error|cancelled, con steps y summary -- steps no trae el log completo de cada paso, solo status/seconds). Única forma válida de confirmar un resultado -- nunca lo des por hecho.',
|
|
1288
1517
|
inputSchema: {
|
|
1289
|
-
runId: z.string().describe('ID de la corrida, devuelto por
|
|
1518
|
+
runId: z.string().describe('ID de la corrida, devuelto por create_agent_run'),
|
|
1290
1519
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1291
1520
|
},
|
|
1292
1521
|
},
|
|
1293
1522
|
async ({ runId, projectId }) => {
|
|
1294
1523
|
try {
|
|
1295
|
-
const data = await apiGet(config, `/v1/
|
|
1524
|
+
const data = await apiGet(config, `/v1/agent-runs/${runId}`, { projectId: resolveProjectId(projectId) })
|
|
1296
1525
|
return textResult(data)
|
|
1297
1526
|
} catch (err) {
|
|
1298
1527
|
return errorResult(err)
|
package/src/stages.mjs
CHANGED
|
@@ -71,12 +71,13 @@ export const PLAYBOOKS = {
|
|
|
71
71
|
{ title: 'Preparar assets de tienda (ASO): capturas, descripción, keywords', description: '5-8 capturas que cuenten una historia (no pantallas sueltas), descripción con el pitch arriba, y keywords que tu público realmente busca.', type: 'business', priority: 'high', tags: ['marketing', 'aso'], offsetDays: 5 },
|
|
72
72
|
{ title: 'Publicar landing con captura de correos', description: 'Una página: pitch, 3 beneficios, capturas y un CTA (descargar o dejar correo). Los correos capturados son tu canal propio para siempre.', type: 'feature', priority: 'high', tags: ['marketing', 'landing'], offsetDays: 7 },
|
|
73
73
|
{ title: 'Configurar analytics de adquisición', description: 'Fuentes de tráfico, conversión de visita→registro→activación. Necesitas saber DE DÓNDE llegan los usuarios que sí se quedan.', type: 'tech', priority: 'medium', tags: ['analytics', 'adquisición'], offsetDays: 8 },
|
|
74
|
-
{ title: '
|
|
74
|
+
{ title: 'Conectar tus canales reales de contenido', description: 'Antes de programar nada: registra en Contenido → Cuentas conectadas los canales donde de verdad vas a publicar (redes, blog, newsletter). El plan de contenido de abajo solo sirve con canales reales, no uno genérico — si personalizas este playbook con IA y ya tienes canales conectados, te genera piezas reales directo.', type: 'tech', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 9 },
|
|
75
|
+
{ title: 'Plan de contenido de lanzamiento (2 semanas)', description: 'Con tus canales ya conectados (tarea anterior), programa en Contenido: teaser, anuncio, demo del caso de uso principal, historia de por qué lo construiste, testimonios de beta testers — uno por canal real, no una lista genérica.', type: 'business', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 10 },
|
|
75
76
|
{ title: 'Lanzar en canales: Product Hunt, comunidades y redes', description: 'Elige 3 canales donde vive tu público (PH, subreddits, grupos de FB/Discord, foros del nicho). Un post honesto de creador funciona mejor que uno de venta.', type: 'business', priority: 'high', tags: ['marketing', 'lanzamiento'], offsetDays: 14 },
|
|
76
77
|
{ title: 'Contactar 10 personas con audiencia en el nicho', description: 'Creators, newsletters, podcasts o prensa pequeña del tema. Mensaje corto y personalizado + acceso gratis. Con que respondan 2, ganaste.', type: 'followup', priority: 'medium', tags: ['marketing', 'outreach'], offsetDays: 16 },
|
|
77
78
|
{ title: 'Activar loop de feedback de usuarios reales', description: 'Un canal permanente: encuesta in-app, correo de bienvenida que pregunta "¿qué te trajo aquí?", o botón de sugerencias. Todo aterriza en Ideas.', type: 'improvement', priority: 'medium', tags: ['feedback'], offsetDays: 18 },
|
|
78
79
|
{ title: 'Definir y medir activación y retención (D1 / D7 / D30)', description: 'Activación: % que llega al momento de valor. Retención: % que regresa al día 1, 7 y 30. Estos números te dicen si tienes producto o solo descargas.', type: 'business', priority: 'medium', tags: ['métricas'], offsetDays: 21 },
|
|
79
|
-
{ title: 'Ritmo de contenido post-lanzamiento (3 publicaciones/semana)', description: 'El lanzamiento es un día; el crecimiento es constancia. Deja programadas en Contenido las siguientes 2 semanas: tips de uso, casos, mejoras del changelog.', type: 'business', priority: 'medium', tags: ['marketing', 'contenido'], offsetDays: 24 },
|
|
80
|
+
{ title: 'Ritmo de contenido post-lanzamiento (3 publicaciones/semana)', description: 'El lanzamiento es un día; el crecimiento es constancia. Deja programadas en Contenido las siguientes 2 semanas: tips de uso, casos, mejoras del changelog — repartidas entre tus canales reales conectados.', type: 'business', priority: 'medium', tags: ['marketing', 'contenido'], offsetDays: 24 },
|
|
80
81
|
{ title: 'Publicar release notes / changelog público v1', description: 'Convierte tu Changelog en comunicación: qué hay de nuevo, en lenguaje de usuario. Cada release es una excusa para volver a aparecer.', type: 'docs', priority: 'low', tags: ['changelog'], offsetDays: 26 },
|
|
81
82
|
{ title: 'Revisión semanal de métricas y próximos pasos', description: 'Reunión recurrente (aunque seas solo tú): adquisición, activación, retención, top feedback. Decide UNA mejora prioritaria por semana.', type: 'meeting', priority: 'medium', tags: ['métricas', 'ritmo'], offsetDays: 28 },
|
|
82
83
|
],
|