orquestra-mcp 1.50.0 → 1.52.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.
@@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { createServer, createSetupNeededServer } from '../src/server.mjs'
4
4
  import { runSetup } from '../src/setup.mjs'
5
5
  import { runVaultSync } from '../src/vaultSync.mjs'
6
+ import { startUpdateCheck } from '../src/updateCheck.mjs'
6
7
 
7
8
  const HELP_TEXT = `orquestra-mcp -- Servidor MCP de Orquestra (132 tools)
8
9
 
@@ -10,6 +11,9 @@ Uso:
10
11
  npx orquestra-mcp Inicia el servidor MCP por stdio -- lo usa tu cliente
11
12
  MCP (Claude Code, Claude Desktop, etc.), no se corre a mano
12
13
  npx orquestra-mcp setup Login por navegador + conecta esta carpeta a un proyecto
14
+ npx orquestra-mcp setup --agent codex
15
+ Lo mismo para una herramienta puntual (claude-code, codex,
16
+ cursor, gemini-cli...): cada una firma con su propio agente
13
17
  npx orquestra-mcp vault sync <carpeta> [--apply]
14
18
  Sincroniza la base de conocimiento del workspace con una
15
19
  carpeta de tu vault de Obsidian (la 1ra vez solo muestra el plan)
@@ -17,7 +21,8 @@ Uso:
17
21
 
18
22
  Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
19
23
  Alternativa manual (variables de entorno): ORQUESTRA_API_TOKEN, ORQUESTRA_WORKSPACE_ID,
20
- ORQUESTRA_DEFAULT_PROJECT_ID
24
+ ORQUESTRA_DEFAULT_PROJECT_ID. ORQUESTRA_AGENT elige el token de una herramienta
25
+ (ej. codex) cuando la carpeta tiene varias conectadas.
21
26
 
22
27
  Docs completas: https://orquestra.me/mcp
23
28
  `
@@ -31,7 +36,8 @@ async function main() {
31
36
  }
32
37
 
33
38
  if (cmd === 'setup') {
34
- await runSetup()
39
+ const agentIdx = process.argv.indexOf('--agent')
40
+ await runSetup({ agent: agentIdx > 0 ? process.argv[agentIdx + 1] : process.env.ORQUESTRA_AGENT })
35
41
  return
36
42
  }
37
43
 
@@ -45,6 +51,9 @@ async function main() {
45
51
  return
46
52
  }
47
53
 
54
+ // Sin await: el aviso de versión nueva no debe atrasar el arranque.
55
+ startUpdateCheck()
56
+
48
57
  let server
49
58
  try {
50
59
  server = createServer()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
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": {
package/src/api.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readConfigFile, resolveProjectForCwd, CONFIG_PATH } from './configFile.mjs'
1
+ import { readConfigFile, resolveProjectForCwd, entryForAgent, agentKey, CONFIG_PATH } from './configFile.mjs'
2
2
  import { request } from './http.mjs'
3
3
 
4
4
  const DEFAULT_API_BASE = 'https://api.orquestra.me'
@@ -8,7 +8,19 @@ export function loadConfig() {
8
8
  // Todo sale de la MISMA carpeta mapeada (ver resolveProjectForCwd) --
9
9
  // apiToken/workspaceId/projectId siempre son de una sola cuenta, nunca
10
10
  // el token de una carpeta con el proyecto de otra.
11
- const folderConfig = resolveProjectForCwd(fileConfig?.projects, process.cwd())
11
+ const folderEntry = resolveProjectForCwd(fileConfig?.projects, process.cwd())
12
+ // ORQUESTRA_AGENT (ORQ-415) elige el token de esa herramienta en la
13
+ // carpeta. Si no está conectada falla explícito: caer al token default
14
+ // haría que sus cambios queden firmados por otra herramienta.
15
+ const agent = agentKey(process.env.ORQUESTRA_AGENT)
16
+ const folderConfig = agent ? entryForAgent(folderEntry, agent) : folderEntry
17
+ if (agent && folderEntry && !folderConfig && !process.env.ORQUESTRA_API_TOKEN) {
18
+ throw new Error(
19
+ `La herramienta "${process.env.ORQUESTRA_AGENT}" (ORQUESTRA_AGENT) no está conectada en esta carpeta (${process.cwd()}), ` +
20
+ `solo otras. Para que sus cambios queden a su nombre y no al de otra herramienta, abre una terminal aquí, ` +
21
+ `corre "npx orquestra-mcp@latest setup" y elige esa herramienta; después reconecta el MCP.`
22
+ )
23
+ }
12
24
 
13
25
  const apiBase = process.env.ORQUESTRA_API_BASE || folderConfig?.apiBase || DEFAULT_API_BASE
14
26
  const apiToken = process.env.ORQUESTRA_API_TOKEN || folderConfig?.apiToken
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
- import { homedir } from 'os'
2
+ import { homedir, hostname } from 'os'
3
+ import { randomBytes } from 'crypto'
3
4
  import { join, dirname, win32, posix } from 'path'
4
5
 
5
6
  export const CONFIG_PATH = join(homedir(), '.orquestra', 'mcp-config.json')
@@ -45,19 +46,104 @@ export function writeConfigFile(data) {
45
46
  writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), { mode: 0o600 })
46
47
  }
47
48
 
49
+ // Identidad por herramienta (ORQ-415): cada herramienta (Claude Code, Codex,
50
+ // Cursor...) tiene su propio token -- su propio agente en Orquestra -- aunque
51
+ // trabajen en la misma carpeta. La entrada de la carpeta guarda la conexión
52
+ // "default" arriba (la que se usa sin ORQUESTRA_AGENT) con `agent` = su
53
+ // herramienta, y todas en `agents[clave]`. Una entrada de antes de ORQ-415 no
54
+ // trae `agent`: su token es el de "Claude Code", el default histórico.
55
+ // Mismo slug que agentSlug() de bootstrap.mjs en orquestra-infra.
56
+ export function agentKey(label) {
57
+ return String(label || '')
58
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
59
+ .toLowerCase().trim()
60
+ .replace(/[^a-z0-9]+/g, '-')
61
+ .replace(/^-+|-+$/g, '')
62
+ }
63
+
64
+ const LEGACY_AGENT = 'claude-code'
65
+ const defaultAgentOf = entry => entry?.agent || (entry?.apiToken ? LEGACY_AGENT : null)
66
+ const connectionOf = ({ apiBase, apiToken, workspaceId, projectId }) => ({ apiBase, apiToken, workspaceId, projectId })
67
+
68
+ // La conexión de UNA herramienta dentro de la entrada de una carpeta, o null
69
+ // si esa herramienta nunca se conectó ahí (nunca cae al token de otra: eso
70
+ // es justo lo que hacía firmar a Codex como Claude Code).
71
+ export function entryForAgent(entry, key) {
72
+ if (!entry || !key) return entry
73
+ if (entry.agents?.[key]) return entry.agents[key]
74
+ return defaultAgentOf(entry) === key ? connectionOf(entry) : null
75
+ }
76
+
48
77
  // Guarda/actualiza la cuenta+proyecto de UNA carpeta sin pisar las demás --
49
78
  // setup se puede correr varias veces, una por cada carpeta contenedora de
50
79
  // repos (ver resolveProjectForCwd), y cada corrida solo debe tocar su
51
80
  // propia entrada -- incluida la cuenta (apiToken/workspaceId), para poder
52
81
  // tener una carpeta en una cuenta de Orquestra y otra carpeta en otra
53
- // cuenta distinta al mismo tiempo.
54
- export function upsertProjectMapping({ apiBase, apiToken, workspaceId, folderPath, projectId }) {
82
+ // cuenta distinta al mismo tiempo. Con `agent` solo toca la conexión de esa
83
+ // herramienta: la default de la carpeta cambia únicamente si era la misma
84
+ // herramienta (o la carpeta no tenía ninguna).
85
+ export function upsertProjectMapping({ apiBase, apiToken, workspaceId, folderPath, projectId, agent }) {
55
86
  const existing = readConfigFile() || { projects: {} }
56
87
  const projects = { ...(existing.projects || {}) }
57
- projects[folderPath] = { apiBase, apiToken, workspaceId, projectId }
88
+ const conn = { apiBase, apiToken, workspaceId, projectId }
89
+ if (!agent) {
90
+ projects[folderPath] = conn
91
+ } else {
92
+ const prev = projects[folderPath]
93
+ const agents = { ...(prev?.agents || {}), [agent]: conn }
94
+ const keepDefault = prev && defaultAgentOf(prev) && defaultAgentOf(prev) !== agent
95
+ projects[folderPath] = keepDefault
96
+ ? { ...connectionOf(prev), agent: defaultAgentOf(prev), agents }
97
+ : { ...conn, agent, agents }
98
+ }
58
99
  writeConfigFile({ projects })
59
100
  }
60
101
 
102
+ // El setup de una herramienta ya conocida en esta máquina revoca su token
103
+ // anterior (bootstrap en orquestra-infra): hay que ponerle el nuevo a TODAS
104
+ // las carpetas donde esa herramienta usa el mismo workspace, no solo a la del
105
+ // setup, o las demás quedarían con un token muerto. Devuelve las carpetas.
106
+ export function replaceAgentToken({ workspaceId, agent, apiToken }) {
107
+ const existing = readConfigFile()
108
+ if (!existing?.projects) return []
109
+ const updated = []
110
+ for (const [folderPath, entry] of Object.entries(existing.projects)) {
111
+ let touched = false
112
+ if (defaultAgentOf(entry) === agent && entry.workspaceId === workspaceId) { entry.apiToken = apiToken; touched = true }
113
+ const own = entry.agents?.[agent]
114
+ if (own && own.workspaceId === workspaceId) { own.apiToken = apiToken; touched = true }
115
+ if (touched) updated.push(folderPath)
116
+ }
117
+ if (updated.length) writeConfigFile(existing)
118
+ return updated
119
+ }
120
+
121
+ // Herramientas ya conectadas en esta máquina (en cualquier carpeta).
122
+ export function connectedAgents() {
123
+ const keys = new Set()
124
+ for (const entry of Object.values(readConfigFile()?.projects || {})) {
125
+ if (defaultAgentOf(entry)) keys.add(defaultAgentOf(entry))
126
+ for (const key of Object.keys(entry.agents || {})) keys.add(key)
127
+ }
128
+ return keys
129
+ }
130
+
131
+ // Identidad de la máquina (ORQ-415): un id aleatorio, no el serial ni la MAC
132
+ // -- solo distingue esta instalación ("Codex · MacBook-Pro" vs "Codex ·
133
+ // Mac-mini"). Se crea una vez y se reusa en cada setup.
134
+ export const MACHINE_PATH = join(homedir(), '.orquestra', 'machine.json')
135
+
136
+ export function readOrCreateMachine() {
137
+ try {
138
+ const saved = JSON.parse(readFileSync(MACHINE_PATH, 'utf8'))
139
+ if (/^[a-f0-9]{16,64}$/.test(saved.id)) return { id: saved.id, name: saved.name || hostname().replace(/\.local$/, '') }
140
+ } catch {}
141
+ const machine = { id: randomBytes(8).toString('hex'), name: hostname().replace(/\.local$/, '') }
142
+ mkdirSync(dirname(MACHINE_PATH), { recursive: true })
143
+ writeFileSync(MACHINE_PATH, JSON.stringify(machine, null, 2), { mode: 0o600 })
144
+ return machine
145
+ }
146
+
61
147
  // Busca, subiendo desde cwd hacia la raíz, la carpeta mapeada más específica
62
148
  // -- mismo criterio que .editorconfig/.gitconfig: el ancestro más cercano
63
149
  // gana, así que un repo individual con su propio mapeo (y su propia cuenta)
package/src/server.mjs CHANGED
@@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { z } from 'zod'
6
6
  import { loadConfig, apiGet, apiPost, apiPatch, apiPatchPath, apiDelete, apiDeletePath, putToPresignedUrl, ApiError } from './api.mjs'
7
7
  import { cancelContext } from './http.mjs'
8
+ import { updateNotice } from './updateCheck.mjs'
8
9
  import { STAGES, PLAYBOOKS } from './stages.mjs'
9
10
  import { FOUNDATIONS } from './foundations.mjs'
10
11
  import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
@@ -104,7 +105,7 @@ const SETUP_GUIDE = {
104
105
  // preguntar en vez de adivinar.
105
106
  const SERVER_INSTRUCTIONS = `Este server conecta con Orquestra, la app de gestión de proyectos del usuario -- úsalo para mantener su proyecto sincronizado con el trabajo real, sin que tenga que pedírtelo cada vez.
106
107
 
107
- Al empezar una sesión de trabajo en este proyecto, llama una vez a get_project_context para saber en qué fase está, qué tareas hay abiertas/vencidas/bloqueadas y qué se hizo recientemente. Úsalo como contexto de fondo, no lo repitas en cada mensaje.
108
+ Al empezar una sesión de trabajo en este proyecto, llama una vez a get_project_context para saber en qué fase está, qué tareas hay abiertas/vencidas/bloqueadas y qué se hizo recientemente. Úsalo como contexto de fondo, no lo repitas en cada mensaje. Si trae orquestraMcpUpdate, avísale al usuario UNA sola vez en la sesión que hay una versión nueva de este MCP y cómo actualizar (usa su campo message), sin interrumpir lo que estaba pidiendo.
108
109
 
109
110
  Si get_project_context devuelve un proyecto vacío (sin módulos ni tareas), es la primera vez que se usa este MCP aquí -- pero "vacío en Orquestra" NO significa "recién empezado en la realidad". Antes de ofrecer nada, pregúntale al usuario: ¿este proyecto es una idea nueva, o ya existe y solo lo estás conectando por primera vez? Según la respuesta:
110
111
  - 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.
@@ -133,6 +134,7 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
133
134
  - Paridad por producto: las distribuciones que son el mismo producto en distintas plataformas (típicamente Web/iOS/Android) comparten un productKey (list_distributions lo muestra). Si al crear una tarea con scope en una de ellas la respuesta trae parityAddedDistIds, el server sumó las hermanas como pendientes -- dícelo al usuario ("también quedó pendiente en iOS y Android") y no lo deshagas salvo que el trabajo sea genuinamente de una sola plataforma (entonces skipParity:true, o quítalas con update_task/scope explicando por qué). Al cerrar una tarea con scope multiplataforma, marca done:true SOLO en la plataforma donde de verdad se hizo (update_task/scope) -- el estatus "Listo" no mueve esos flags solo; cuando un commit o PR del repo de otra plataforma menciona el código de la tarea (ej. "(ORQ-123)"), el servidor propone solo una sugerencia scope_done_proposal (list_suggestions) para marcar esa plataforma como hecha: si la ves, confirma con el código real y acéptala con accept_suggestion en vez de editar el scope a mano. Si el trabajo de otra plataforma ya vive en OTRA tarea (una tarea gemela, ej. "[iOS] ..." y "[Android] ..."), no dupliques scope: liga la hermana con update_task parityOf=<id de la tarea origen> y en paridad cuentan como un solo item; al cerrar la hermana su plataforma queda hecha sola. Dejar las hermanas en done:false es exactamente lo que hace que el desfase aparezca en get_parity_summary y en la revisión de sincronización (tasksClosedWithParityPending), que es el punto: que nunca se olvide el trabajo del otro lado. Si el proyecto tiene 2+ plataformas y ninguna tiene productKey (get_parity_summary → productGroups.configured=false), pregúntale al usuario cuáles son el mismo producto y configúralo con update_distribution -- nunca lo infieras solo del tipo de plataforma.
134
135
  - 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.
135
136
  - 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í.
137
+ - Una tarea = UN entregable; un ítem del checklist = UN paso corto de ese entregable. Si el título junta varias cosas con comas o "y" ("X, Y y Z"), o un ítem del checklist tendría su propio deploy, commits, pruebas o alertas (es una integración/función entera, no un paso), son tareas separadas: créalas aparte en el mismo módulo (si son muchas del mismo tema, ese tema es un módulo, no una tarea con 14 ítems). Lo que depende de algo externo o futuro (un contrato, un proveedor, una versión que todavía no sale, la respuesta de una tienda) va en su propia tarea -- en Bloqueado o con reviewDate --, nunca dentro de una tarea que ya está lista, porque la deja abierta indefinidamente. El texto de un ítem es la acción ("Configurar cron diario de la CSL"), no la bitácora: commits, "probado en vivo", tiempos y tamaños van en un comment de la tarea, en resolvedCommitSha o en el changelog. Algo que ya no aplica no se deja como ítem pendiente con "ya no hace falta" en el texto: díselo al usuario y bórralo con delete_checklist_item (con un comment en la tarea explicando por qué). No reorganices tareas que ya existen así salvo que el usuario lo pida -- la regla es para lo nuevo.
136
138
  - 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.
137
139
  - 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.
138
140
 
@@ -175,7 +177,10 @@ export function createSetupNeededServer(reason) {
175
177
  server.registerTool('diagnostico_conexion', {
176
178
  description: 'Explica por qué Orquestra no está conectado en esta carpeta y cómo arreglarlo.',
177
179
  inputSchema: {},
178
- }, async () => ({ content: [{ type: 'text', text: `orquestra-mcp ${PKG_VERSION}: ${reason}` }] }))
180
+ }, async () => {
181
+ const update = updateNotice(PKG_VERSION)
182
+ return { content: [{ type: 'text', text: `orquestra-mcp ${PKG_VERSION}: ${reason}${update ? `\n\n${update.message}` : ''}` }] }
183
+ })
179
184
  return server
180
185
  }
181
186
 
@@ -205,14 +210,14 @@ export function createServer() {
205
210
  return projectId
206
211
  }
207
212
 
208
- function registerRead(name, path, description, extraSchema, buildParams) {
213
+ function registerRead(name, path, description, extraSchema, buildParams, decorate = data => data) {
209
214
  server.registerTool(
210
215
  name,
211
216
  { description, inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC), ...extraSchema } },
212
217
  async (args) => {
213
218
  try {
214
219
  const data = await apiGet(config, path, buildParams(args, resolveProjectId(args.projectId)))
215
- return textResult(data)
220
+ return textResult(decorate(data))
216
221
  } catch (err) {
217
222
  return errorResult(err)
218
223
  }
@@ -264,7 +269,11 @@ export function createServer() {
264
269
  '/v1/context',
265
270
  'Resumen compacto del estado de un proyecto de Orquestra: fase, plataformas, tareas abiertas/vencidas/bloqueadas, módulos, ideas y changelog reciente.',
266
271
  {},
267
- (args, projectId) => ({ projectId })
272
+ (args, projectId) => ({ projectId }),
273
+ data => {
274
+ const update = updateNotice(PKG_VERSION)
275
+ return update ? { ...data, orquestraMcpUpdate: update } : data
276
+ }
268
277
  )
269
278
 
270
279
  registerRead(
@@ -295,10 +304,10 @@ export function createServer() {
295
304
  registerRead(
296
305
  'search',
297
306
  '/v1/search',
298
- 'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. También por folio ("ORQ-123") o por clave de módulo ("AUTH-3") -- 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.',
307
+ 'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas, docs, riesgos, módulos, contenido y distribuciones de un proyecto de Orquestra -- en los mismos campos que pueden tener [[wikilinks]] (descripción, mitigación, criterios de cierre, puntos, verificaciones...). También por folio ("ORQ-123") o por clave de módulo ("AUTH-3") -- 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.',
299
308
  {
300
309
  q: z.string().describe('Texto a buscar'),
301
- 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.'),
310
+ types: z.array(z.enum(['task', 'activity', 'note', 'idea', 'doc', 'knowledge', 'risk', 'module', 'content', 'distribution'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en todos. knowledge = solo la base vinculada a este proyecto.'),
302
311
  },
303
312
  (args, projectId) => ({ projectId, q: args.q, types: args.types?.join(',') })
304
313
  )
@@ -751,7 +760,7 @@ export function createServer() {
751
760
  registerCreate(
752
761
  'create_task',
753
762
  '/v1/tasks',
754
- '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).',
763
+ '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). Una tarea = UN entregable: si el título junta varias cosas con comas o "y", crea una tarea por cada una (mismo módulo); lo que espera algo externo o futuro va en su propia tarea, no dentro de otra.',
755
764
  {
756
765
  relatedTo: RELATED_TO,
757
766
  title: z.string().describe('Título de la tarea'),
@@ -1580,7 +1589,7 @@ export function createServer() {
1580
1589
  server.registerTool(
1581
1590
  'add_checklist_item',
1582
1591
  {
1583
- description: 'Agrega un ítem al checklist de una tarea de Orquestra, opcionalmente con un responsable.',
1592
+ description: 'Agrega un ítem al checklist de una tarea de Orquestra, opcionalmente con un responsable. Un ítem es UN paso corto del entregable de la tarea, escrito como acción. Si lleva su propio deploy, commits, pruebas o alertas, o depende de algo externo (contrato, proveedor, versión futura), es una tarea aparte (create_task), no un ítem. Commits y "probado en vivo" van en un comment de la tarea, no en el texto del ítem.',
1584
1593
  inputSchema: {
1585
1594
  projectId: z.string().optional().describe(PROJECT_ID_DESC),
1586
1595
  taskId: z.string().describe('ID de la tarea'),
package/src/setup.mjs CHANGED
@@ -4,7 +4,7 @@ import { exec } from 'child_process'
4
4
  import { createInterface } from 'readline'
5
5
  import { homedir } from 'os'
6
6
  import { resolve } from 'path'
7
- import { upsertProjectMapping, CONFIG_PATH } from './configFile.mjs'
7
+ import { upsertProjectMapping, replaceAgentToken, connectedAgents, readOrCreateMachine, agentKey, CONFIG_PATH } from './configFile.mjs'
8
8
 
9
9
  const DEFAULT_API_BASE = 'https://api.orquestra.me'
10
10
  const DEFAULT_WEB_URL = 'https://app.orquestra.me'
@@ -15,12 +15,16 @@ function openBrowser(url) {
15
15
  exec(`${cmd} "${url}"`, () => {})
16
16
  }
17
17
 
18
+ // Herramientas conocidas (ORQ-415): cada una queda como su propio agente en
19
+ // Orquestra ("Codex · MacBook-Pro") y con su propio token en la carpeta.
20
+ const TOOLS = ['Claude Code', 'Codex', 'Cursor', 'Gemini CLI', 'Claude Desktop']
21
+
18
22
  function prompt(question) {
19
23
  const rl = createInterface({ input: process.stdin, output: process.stdout })
20
24
  return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer) }))
21
25
  }
22
26
 
23
- export async function runSetup() {
27
+ export async function runSetup({ agent: presetAgent } = {}) {
24
28
  const apiBase = process.env.ORQUESTRA_API_BASE || DEFAULT_API_BASE
25
29
  const webUrl = process.env.ORQUESTRA_WEB_URL || DEFAULT_WEB_URL
26
30
  const state = randomBytes(16).toString('hex')
@@ -75,8 +79,9 @@ export async function runSetup() {
75
79
  const idToken = await idTokenPromise
76
80
  console.log('Login recibido, configurando...')
77
81
 
78
- const agentLabelAnswer = await prompt('¿Cómo se llama este agente/herramienta? [Claude Code]: ')
79
- const agentLabel = agentLabelAnswer.trim() || 'Claude Code'
82
+ const machine = readOrCreateMachine()
83
+ const agentLabel = await chooseTool(presetAgent, machine)
84
+ const agent = agentKey(agentLabel)
80
85
 
81
86
  // askIfMissing: si no hay workspace la API no crea nada solo -- avisa
82
87
  // (needsWorkspace) y aquí se le pide el nombre al usuario.
@@ -84,7 +89,7 @@ export async function runSetup() {
84
89
  const res = await fetch(`${apiBase}/v1/bootstrap`, {
85
90
  method: 'POST',
86
91
  headers: { Authorization: `Bearer ${idToken}`, 'Content-Type': 'application/json' },
87
- body: JSON.stringify({ agentLabel, askIfMissing: true, ...extra }),
92
+ body: JSON.stringify({ agentLabel, machineId: machine.id, machineName: machine.name, askIfMissing: true, ...extra }),
88
93
  })
89
94
  const body = await res.json()
90
95
  if (!res.ok) throw new Error(body?.error || `HTTP ${res.status}`)
@@ -123,6 +128,14 @@ export async function runSetup() {
123
128
  }
124
129
 
125
130
  const { workspaceId, token, projects } = body
131
+ // La API anterior a ORQ-415 no manda `agent`.
132
+ // El token anterior ya quedó revocado en el servidor: se reemplaza en todas
133
+ // las carpetas de esta herramienta AHORA, antes de las preguntas, para que
134
+ // cancelar el setup a la mitad no deje carpetas con un token muerto.
135
+ const renewedFolders = body.agent?.known ? replaceAgentToken({ workspaceId, agent, apiToken: token }) : []
136
+ if (body.agent?.known) console.log(`\nYa te conocía: "${body.agent.name}". Renové su token (el anterior quedó revocado).`)
137
+ else if (body.agent?.adopted) console.log(`\nTu agente "${agentLabel}" de antes quedó ligado a esta máquina como "${body.agent.name}".`)
138
+ else if (body.agent) console.log(`\nNuevo agente en tu equipo de Orquestra: "${body.agent.name}".`)
126
139
  // La API de producción anterior a ORQ-388 no manda workspaceName.
127
140
  const wsLabel = body.workspaceName ? ` del workspace "${body.workspaceName}"` : ''
128
141
  let defaultProjectId
@@ -173,20 +186,64 @@ export async function runSetup() {
173
186
  : folderAnswer.trim()
174
187
  const folderPath = expanded ? resolve(expanded) : process.cwd()
175
188
 
176
- upsertProjectMapping({ apiBase, apiToken: token, workspaceId, folderPath, projectId: defaultProjectId })
177
- console.log(`\nListo. "${folderPath}" (y sus subcarpetas) ya apunta a este proyecto -- guardado en ${CONFIG_PATH}.`)
178
- // En Windows Claude Code necesita `cmd /c` para lanzar npx, y el config de
179
- // Claude Desktop vive en %APPDATA%. `@latest` evita que npx reuse una
180
- // versión vieja de su caché.
181
- const isWindows = process.platform === 'win32'
182
- console.log('\nSiguiente paso -- conéctalo a tu agente de IA:\n')
183
- console.log(' Claude Code:')
184
- console.log(` claude mcp add orquestra -- ${isWindows ? 'cmd /c ' : ''}npx -y orquestra-mcp@latest`)
185
- if (isWindows) console.log(' (si "claude" no se reconoce: npm install -g @anthropic-ai/claude-code y abre otra terminal)')
186
- console.log(' Después reinicia Claude Code o reconéctalo con /mcp.\n')
187
- console.log(` Claude Desktop -- agrega esto a ${isWindows ? '%APPDATA%\\Claude\\' : '~/Library/Application Support/Claude/'}claude_desktop_config.json:`)
188
- console.log(' { "mcpServers": { "orquestra": { "command": "npx", "args": ["-y", "orquestra-mcp@latest"] } } }\n')
189
- console.log(' ¿Usas Cursor, ChatGPT/Codex o Gemini CLI? La config de cada uno está en:')
190
- console.log(' https://www.orquestra.me/mcp#instalacion\n')
189
+ upsertProjectMapping({ apiBase, apiToken: token, workspaceId, folderPath, projectId: defaultProjectId, agent })
190
+ const others = renewedFolders.filter(f => f !== folderPath)
191
+ console.log(`\nListo. "${folderPath}" (y sus subcarpetas) ya apunta a este proyecto con ${agentLabel} -- guardado en ${CONFIG_PATH}.`)
192
+ if (others.length) console.log(`También actualicé el token de ${agentLabel} en: ${others.join(', ')}.`)
193
+ console.log('\nSiguiente paso -- conéctalo a tu herramienta (si ya estaba conectada, solo reconecta el MCP):\n')
194
+ console.log(connectionHelp(agent, agentLabel))
195
+ console.log('\n¿Otra herramienta en esta misma carpeta? Corre de nuevo "npx orquestra-mcp@latest setup" y elígela:')
196
+ console.log('cada una firma con su propio agente.')
191
197
  console.log('Docs completas y todas las tools disponibles: https://www.orquestra.me/mcp')
192
198
  }
199
+
200
+ // Sin preguntar "¿cómo se llama?" cada vez: se elige de la lista (o viene con
201
+ // --agent) y se marca lo que ya está conectado en esta máquina.
202
+ async function chooseTool(preset, machine) {
203
+ if (preset?.trim()) return TOOLS.find(t => agentKey(t) === agentKey(preset)) || preset.trim()
204
+ const connected = connectedAgents()
205
+ const already = TOOLS.filter(t => connected.has(agentKey(t)))
206
+ console.log(`\nEsta máquina: ${machine.name}${already.length ? ` (ya conectadas: ${already.join(', ')})` : ''}`)
207
+ console.log('¿Para qué herramienta es esta conexión?')
208
+ TOOLS.forEach((t, i) => console.log(` ${i + 1}. ${t}${connected.has(agentKey(t)) ? ' (ya conectada: renueva su token)' : ''}`))
209
+ console.log(` ${TOOLS.length + 1}. Otra`)
210
+ for (;;) {
211
+ const answer = (await prompt('Número [Enter para Claude Code]: ')).trim()
212
+ const idx = answer === '' ? 1 : parseInt(answer, 10)
213
+ if (idx >= 1 && idx <= TOOLS.length) return TOOLS[idx - 1]
214
+ if (idx === TOOLS.length + 1) {
215
+ const name = (await prompt('Nombre de la herramienta: ')).trim()
216
+ if (name) return name
217
+ }
218
+ console.log('Selección inválida.')
219
+ }
220
+ }
221
+
222
+ // La config de cada herramienta lleva ORQUESTRA_AGENT para que use SU token
223
+ // (sin eso usaría el default de la carpeta y firmaría como otra).
224
+ // En Windows Claude Code necesita `cmd /c` para lanzar npx, y el config de
225
+ // Claude Desktop vive en %APPDATA%. `@latest` evita que npx reuse una
226
+ // versión vieja de su caché.
227
+ function connectionHelp(agent, agentLabel) {
228
+ const isWindows = process.platform === 'win32'
229
+ const json = file => ` ${agentLabel} -- agrega esto a ${file}:\n` +
230
+ ` { "mcpServers": { "orquestra": { "command": "npx", "args": ["-y", "orquestra-mcp@latest"], "env": { "ORQUESTRA_AGENT": "${agent}" } } } }`
231
+ if (agent === 'claude-code') {
232
+ return ' Claude Code:\n' +
233
+ ` claude mcp add orquestra -e ORQUESTRA_AGENT=${agent} -- ${isWindows ? 'cmd /c ' : ''}npx -y orquestra-mcp@latest\n` +
234
+ (isWindows ? ' (si "claude" no se reconoce: npm install -g @anthropic-ai/claude-code y abre otra terminal)\n' : '') +
235
+ ' (si ya estaba agregado: claude mcp remove orquestra y vuelve a agregarlo)\n' +
236
+ ' Después reinicia Claude Code o reconéctalo con /mcp.'
237
+ }
238
+ if (agent === 'codex') {
239
+ return ' Codex -- en ~/.codex/config.toml:\n' +
240
+ ' [mcp_servers.orquestra]\n' +
241
+ ' command = "npx"\n' +
242
+ ' args = ["-y", "orquestra-mcp@latest"]\n' +
243
+ ` env = { ORQUESTRA_AGENT = "${agent}" }`
244
+ }
245
+ if (agent === 'cursor') return json('~/.cursor/mcp.json')
246
+ if (agent === 'gemini-cli') return json('~/.gemini/settings.json')
247
+ if (agent === 'claude-desktop') return json(`${isWindows ? '%APPDATA%\\Claude\\' : '~/Library/Application Support/Claude/'}claude_desktop_config.json`)
248
+ return json('la config de MCP de tu herramienta') + '\n Guía por herramienta: https://www.orquestra.me/mcp#instalacion'
249
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ // Aviso de versión nueva (ORQ-415): al arrancar, el server pregunta a npm cuál
6
+ // es la última versión publicada (caché de 12 h en ~/.orquestra, así casi
7
+ // ningún arranque sale a la red) y get_project_context la devuelve en
8
+ // `orquestraMcpUpdate` para que el agente le diga al usuario cómo actualizar.
9
+ // Nunca bloquea el arranque ni falla: sin red simplemente no hay aviso.
10
+ const CACHE_PATH = join(homedir(), '.orquestra', 'update-check.json')
11
+ const CACHE_TTL_MS = 12 * 60 * 60 * 1000
12
+ const REGISTRY_URL = 'https://registry.npmjs.org/orquestra-mcp/latest'
13
+
14
+ let latestKnown = null
15
+
16
+ export function isNewer(latest, current) {
17
+ const parts = v => String(v).split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
18
+ const [a, b] = [parts(latest), parts(current)]
19
+ for (let i = 0; i < 3; i++) {
20
+ if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0)
21
+ }
22
+ return false
23
+ }
24
+
25
+ export async function startUpdateCheck({ fetchImpl = fetch, now = Date.now() } = {}) {
26
+ if (process.env.ORQUESTRA_NO_UPDATE_CHECK) return null
27
+ try {
28
+ const cached = JSON.parse(readFileSync(CACHE_PATH, 'utf8'))
29
+ if (cached.latest && now - cached.checkedAt < CACHE_TTL_MS) return (latestKnown = cached.latest)
30
+ } catch {}
31
+ try {
32
+ const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(3000) })
33
+ if (!res.ok) return null
34
+ const { version } = await res.json()
35
+ if (!version) return null
36
+ latestKnown = version
37
+ mkdirSync(dirname(CACHE_PATH), { recursive: true })
38
+ writeFileSync(CACHE_PATH, JSON.stringify({ latest: version, checkedAt: now }))
39
+ return version
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ export function updateNotice(current) {
46
+ if (!latestKnown || !isNewer(latestKnown, current)) return null
47
+ return {
48
+ current,
49
+ latest: latestKnown,
50
+ message:
51
+ `Hay una versión nueva de orquestra-mcp (${latestKnown}; esta sesión usa ${current}). ` +
52
+ `Para actualizar, reinicia tu herramienta o reconecta el MCP (en Claude Code: /mcp → orquestra → Reconnect). ` +
53
+ `Si sigue la versión vieja, en la config del MCP cambia "orquestra-mcp" por "orquestra-mcp@latest" ` +
54
+ `(sin @latest, npx reusa la versión que tiene en caché). Novedades: https://www.orquestra.me/mcp`,
55
+ }
56
+ }