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 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 QA (create_qa_run/get_qa_run) +
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.10.0",
4
- "description": "Servidor MCP de Orquestra -- 72 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
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": "./bin/orquestra-mcp.mjs"
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
- - No todas las tareas necesitan una corrida de QA -- clasifica antes de disparar una: cambios que afectan comportamiento real (features, bugs, UI, lógica de negocio, endpoints/API) sí la necesitan; cambios que no alteran comportamiento (docs, comentarios, refactors sin cambio funcional, config/tooling interno, notas/reuniones/ideas) no. Si decides que sí, revisa primero con list_qa_agents que exista un agente para el stack que tocaste (si no hay ninguno, créalo con create_qa_agent -- necesita una máquina ya emparejada, ver Workspace → QA) y llama a create_qa_run en la rama real de ese trabajo (manda taskId si el trabajo era de una tarea puntual -- si el agente tiene la revisión con IA activada, la acota a eso en vez de "revisa todo el proyecto"), dejando la tarea en "En revisión" -- NUNCA la muevas a "Listo" ni digas que "QA lo revisó" sin haber confirmado con get_qa_run un estatus terminal "passed" de verdad (las corridas son asíncronas, tardan minutos en otra máquina). Si tarda, dile al usuario que quedó en cola (con el runId) y que confirmas cuando termine -- no lo des por hecho nunca.
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.0.0' }, { instructions: SERVER_INSTRUCTIONS })
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
- // ═══ QA (módulo de runners -- ver orquestra-qa-agent) ═══════════════════════
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 = un stack de este proyecto (web/ios/android/node/nestjs)
1232
- // atado a una máquina (runnerId) y una ruta de checkout en ella
1233
- // (localPath) -- "orquestra-qa-agent setup" crea/actualiza el suyo solo;
1234
- // estas tools existen para revisarlos/crearlos desde acá también.
1235
- const QA_AGENT_STACKS = ['web', 'ios', 'android', 'node', 'nestjs']
1236
-
1237
- registerRead('list_qa_agents', '/v1/qaAgents',
1238
- 'Lista los agentes de QA (stack+máquina+ruta local) configurados en un proyecto. Úsala antes de create_qa_agent o de create_qa_run con stack, para saber si ya existe uno.',
1239
- {}, (args, projectId) => ({ projectId }))
1240
-
1241
- registerCreate('create_qa_agent', '/v1/qaAgents', 'Crea un agente de QA: asocia un stack de este proyecto a una máquina (runner, ver Workspace → QA) y una ruta de checkout en ESA máquina.', {
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
- stack: z.enum(QA_AGENT_STACKS),
1244
- runnerId: z.string().describe('ID de la máquina emparejada -- ver list_qa_agents o Workspace → QA'),
1245
- localPath: z.string().describe('Ruta absoluta del checkout en esa máquina'),
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('Cuenta de prueba DEDICADA (no una credencial real) que la revisión con IA puede usar para iniciar sesión y revisar pantallas autenticadas'),
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". QA no tiene scheduling real (todo trigger es manual/push): esto NO dispara ninguna corrida sola, solo alimenta el calendario global.'),
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('update_qa_agent', '/v1/qaAgents', 'Edita un agente de QA existente.', {
1481
+ registerUpdate('update_agent', '/v1/agents', 'Edita un agente existente.', {
1258
1482
  name: z.string().optional(),
1259
- stack: z.enum(QA_AGENT_STACKS).optional(),
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 create_qa_agent.'),
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('create_qa_run', '/v1/qa/runs', 'Dispara una corrida de QA (lint/tests/build/E2E según el stack) en la rama indicada. Necesita al menos un agente de QA activo para el stack (o falla explícito -- ver list_qa_agents/create_qa_agent). La corrida queda "queued" -- nunca asumas que pasó, confirma con get_qa_run.', {
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 create_qa_agent) o más de 1 (usa agentId).'),
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
- 'get_qa_run',
1514
+ 'get_agent_run',
1286
1515
  {
1287
- description: 'Consulta el estatus real de una corrida de QA por id (queued|running|passed|failed|error|cancelled, con steps y summary). Única forma válida de confirmar un resultado -- nunca lo des por hecho.',
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 create_qa_run'),
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/qa/runs/${runId}`, { projectId: resolveProjectId(projectId) })
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: 'Plan de contenido de lanzamiento (2 semanas)', description: 'Programa las publicaciones en la sección Contenido: teaser, anuncio, demo del caso de uso principal, historia de por qué lo construiste, testimonios de beta testers.', type: 'business', priority: 'high', tags: ['marketing', 'contenido'], offsetDays: 10 },
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
  ],