orquestra-mcp 1.32.0 → 1.37.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/bin/orquestra-mcp.mjs +14 -0
- package/package.json +4 -1
- package/src/server.mjs +215 -8
- package/src/vaultSync.mjs +249 -0
package/bin/orquestra-mcp.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
3
|
import { createServer } from '../src/server.mjs'
|
|
4
4
|
import { runSetup } from '../src/setup.mjs'
|
|
5
|
+
import { runVaultSync } from '../src/vaultSync.mjs'
|
|
5
6
|
|
|
6
7
|
const HELP_TEXT = `orquestra-mcp -- Servidor MCP de Orquestra (35 tools)
|
|
7
8
|
|
|
@@ -9,6 +10,9 @@ Uso:
|
|
|
9
10
|
npx orquestra-mcp Inicia el servidor MCP por stdio -- lo usa tu cliente
|
|
10
11
|
MCP (Claude Code, Claude Desktop, etc.), no se corre a mano
|
|
11
12
|
npx orquestra-mcp setup Login por navegador + configura tu proyecto default
|
|
13
|
+
npx orquestra-mcp vault sync <carpeta> [--apply]
|
|
14
|
+
Sincroniza la base de conocimiento del workspace con una
|
|
15
|
+
carpeta de tu vault de Obsidian (la 1ra vez solo muestra el plan)
|
|
12
16
|
npx orquestra-mcp --help Muestra esta ayuda
|
|
13
17
|
|
|
14
18
|
Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
|
|
@@ -31,6 +35,16 @@ async function main() {
|
|
|
31
35
|
return
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
if (cmd === 'vault' && process.argv[3] === 'sync') {
|
|
39
|
+
try {
|
|
40
|
+
await runVaultSync(process.argv.slice(4))
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(`[orquestra-mcp] ${err.message}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
let server
|
|
35
49
|
try {
|
|
36
50
|
server = createServer()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 115 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": {
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
29
|
"zod": "^4.6.5"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.mjs"
|
|
30
33
|
}
|
|
31
34
|
}
|
package/src/server.mjs
CHANGED
|
@@ -26,6 +26,10 @@ function textResult(data) {
|
|
|
26
26
|
const PROJECT_ID_DESC = 'ID del proyecto dentro del workspace configurado. Si hay un proyecto default configurado (via "setup" o ORQUESTRA_DEFAULT_PROJECT_ID), este argumento se ignora -- el server queda fijo a ese proyecto y no puede leer/escribir otro.'
|
|
27
27
|
|
|
28
28
|
const TASK_TYPES = ['feature', 'bug', 'improvement', 'tech', 'docs', 'idea', 'design', 'security', 'meeting', 'call', 'followup', 'business']
|
|
29
|
+
// Actividades (ORQ-295): reunión/llamada/seguimiento. 'business' sigue siendo tarea (entregable). Mismas listas que
|
|
30
|
+
// orquestra-infra/functions/api/lib/activities.mjs y schema/enums/ActivityStatus.json.
|
|
31
|
+
const ACTIVITY_TYPES = ['meeting', 'call', 'followup']
|
|
32
|
+
const ACTIVITY_STATUSES = ['scheduled', 'done', 'cancelled']
|
|
29
33
|
const TASK_PRIORITIES = ['high', 'medium', 'low']
|
|
30
34
|
const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
|
31
35
|
// 'postmortem' faltaba acá (gap preexistente, encontrado 12 ago 2026) --
|
|
@@ -95,8 +99,11 @@ Analítica y anuncios en un sitio web: antes de instalar o tocar Google Analytic
|
|
|
95
99
|
|
|
96
100
|
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"):
|
|
97
101
|
- Si te pide una funcionalidad o arreglo real (no una pregunta rápida ni exploración de código sin cambios), revisa con list_tasks/search si ya existe una tarea relacionada; si no, créala con create_task ANTES de empezar a trabajar, en el estatus inicial del proyecto (ver list_statuses). Si no hay un proyecto default configurado para esta carpeta, no te rindas ahí -- usa get_workspace_summary para encontrar el proyecto correcto y pásalo explícito como projectId en cada llamada.
|
|
102
|
+
- Orquestra es el ÚNICO lugar donde se planea y se lleva el avance del trabajo: el plan de pasos va como checklist de la tarea (add_checklist_item) y se marca con update_checklist_item conforme terminas cada uno. No lleves una lista de pendientes paralela en el chat, en archivos temporales ni en la herramienta de tareas de tu propio cliente -- se pierde al cerrar la sesión y el usuario no la ve. Si retomas trabajo en una sesión nueva, parte de la tarea y sus ítems pendientes (list_tasks + list_checklist_items), no de cero. Excepción: un cambio de un solo paso (renombrar algo, responder una pregunta) no necesita tarea.
|
|
98
103
|
- Mientras trabajas, mueve la tarea por los estatus reales según avanzas (no la dejes en el inicial hasta el final). Al terminar, muévela a su estatus final con update_task y registra una entrada de changelog con create_changelog_entry -- así el usuario nunca tiene que documentar esto a mano. Si sabes en qué repo real estás trabajando (podés correr "git remote get-url origin" o ya lo sabes por la carpeta), pásalo SIEMPRE como "repo" ("owner/name") -- encontrado real (17 ago 2026): ni el hook local ni el GitHub Action ni la reconciliación de orquestra-infra lo estaban mandando pese a que el campo existe hace rato, así que ninguna entrada automática sabía de qué repo/plataforma venía. Si además la distribución de ese repo ya está identificada (ver list_distributions), pásala en "distributions" -- sin esto el changelog no puede filtrar/agrupar por plataforma.
|
|
99
104
|
- Todo cambio de statusId vía update_task valida la transición (nextIds) y queda en el historial de la tarea -- pero ese historial no dice nada por sí solo. Manda SIEMPRE el parámetro "comment" cuando cambies statusId, explicando qué hiciste o por qué -- sin esto, el usuario ve que la tarea cambió pero no sabe por qué ni qué se hizo de verdad (encontrado real: quedaba invisible, a diferencia de cuando un humano mueve la tarjeta a mano en el kanban).
|
|
105
|
+
- Conocimiento que sirve más allá de un proyecto (guías, procesos, decisiones transversales) va en la base del workspace: revisa list_knowledge antes de documentarlo de nuevo, créalo con create_knowledge o súbelo con promote_to_knowledge; lo de un solo proyecto sigue en notas/docs.
|
|
106
|
+
- Reuniones, llamadas y seguimientos NO son tareas: son actividades (no se entregan, se programan y se registran). Usa log_activity para agendarlas o registrarlas, update_activity para marcarlas hechas con su resultado (outcome) y siguiente paso (nextStep), y list_activities para ver la agenda/bitácora -- nunca create_task con esos tipos. Si del siguiente paso sale trabajo real, eso sí es una tarea (create_task normal, con módulo y fecha).
|
|
100
107
|
- Antes de crear un módulo/estatus/categoría/servicio/entorno/distribución, revisa con el list_* correspondiente que no exista ya uno con ese nombre.
|
|
101
108
|
- Si una nota que vas a crear (create_note) es claramente sobre un módulo o una tarea específica -- por ejemplo durante un setup guiado donde acabas de crear ambos -- mándale moduleId/taskId para ligarla, en vez de dejarla suelta. No inventes la relación si no es clara.
|
|
102
109
|
- create_changelog_entry SIEMPRE deja la entrada en Unreleased (nunca mandes version salvo que el usuario te haya dado un número real) -- cortar una versión es release_changelog_version, una acción DELIBERADA que solo se llama si el usuario la pide explícitamente ("cortá la versión", "hacé el release de X") o confirma un número/fecha real que vos propusiste. No la dispares solo porque terminaste una tarea o un lote de trabajo grande.
|
|
@@ -214,7 +221,7 @@ export function createServer() {
|
|
|
214
221
|
registerRead(
|
|
215
222
|
'list_tasks',
|
|
216
223
|
'/v1/tasks',
|
|
217
|
-
'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true.',
|
|
224
|
+
'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true. No incluye actividades (reuniones/llamadas/seguimientos) -- esas van con list_activities.',
|
|
218
225
|
{
|
|
219
226
|
statusId: z.string().optional().describe('ID de estatus para filtrar (opcional) -- pedir un estatus puntual (incluso uno cerrado) siempre se respeta tal cual, sin el filtro default de abiertas'),
|
|
220
227
|
moduleId: z.string().optional().describe('ID de módulo para filtrar (opcional)'),
|
|
@@ -239,10 +246,10 @@ export function createServer() {
|
|
|
239
246
|
registerRead(
|
|
240
247
|
'search',
|
|
241
248
|
'/v1/search',
|
|
242
|
-
'Busca por texto en tareas, notas, ideas y docs de un proyecto de Orquestra. Tope de 30 resultados con las tareas primero: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
|
|
249
|
+
'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. Tope de 30 resultados con las tareas primero: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
|
|
243
250
|
{
|
|
244
251
|
q: z.string().describe('Texto a buscar'),
|
|
245
|
-
types: z.array(z.enum(['task', 'note', 'idea', 'doc'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en
|
|
252
|
+
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.'),
|
|
246
253
|
},
|
|
247
254
|
(args, projectId) => ({ projectId, q: args.q, types: args.types?.join(',') })
|
|
248
255
|
)
|
|
@@ -604,7 +611,7 @@ export function createServer() {
|
|
|
604
611
|
registerCreate(
|
|
605
612
|
'create_task',
|
|
606
613
|
'/v1/tasks',
|
|
607
|
-
'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.',
|
|
614
|
+
'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).',
|
|
608
615
|
{
|
|
609
616
|
title: z.string().describe('Título de la tarea'),
|
|
610
617
|
description: z.string().optional(),
|
|
@@ -679,6 +686,200 @@ export function createServer() {
|
|
|
679
686
|
}
|
|
680
687
|
)
|
|
681
688
|
|
|
689
|
+
// ═══ ACTIVIDADES (ORQ-295) ═══════════════════════════════════════════════════
|
|
690
|
+
// Reunión/llamada/seguimiento: misma colección que las tareas en la
|
|
691
|
+
// API (POST/PATCH /v1/tasks con type de actividad), pero sin módulo
|
|
692
|
+
// obligatorio, fuera del kanban y con su propio ciclo (activityStatus). La
|
|
693
|
+
// hora se pasa a scheduledAt ACÁ, en la máquina del usuario, porque la API
|
|
694
|
+
// no conoce su zona horaria (mismo motivo que set_task_reminder).
|
|
695
|
+
const toScheduledAt = (date, time) => {
|
|
696
|
+
if (!time) return undefined
|
|
697
|
+
const ms = new Date(`${date}T${time}:00`).getTime()
|
|
698
|
+
if (Number.isNaN(ms)) throw new Error(`Fecha/hora inválida: ${date} ${time}`)
|
|
699
|
+
return ms
|
|
700
|
+
}
|
|
701
|
+
const ACTIVITY_FIELDS_SCHEMA = {
|
|
702
|
+
location: z.string().optional().describe('Plataforma o lugar ("Zoom", "Oficina del cliente") o contexto libre para un seguimiento.'),
|
|
703
|
+
participants: z.array(z.string()).optional().describe('Nombres libres de quienes participan (cliente, equipo) -- no uids.'),
|
|
704
|
+
description: z.string().optional().describe('Agenda o notas previas.'),
|
|
705
|
+
outcome: z.string().optional().describe('Resultado / minuta corta -- qué se acordó. Llénalo al marcarla hecha.'),
|
|
706
|
+
nextStep: z.string().optional().describe('Siguiente paso acordado. Si es trabajo real, además crea la tarea con create_task y liga su id en nextStepTaskId.'),
|
|
707
|
+
nextStepTaskId: z.string().optional().describe('id de la tarea creada desde nextStep.'),
|
|
708
|
+
moduleId: z.string().optional().describe('Opcional -- a diferencia de las tareas, una actividad no necesita módulo.'),
|
|
709
|
+
assignedTo: z.string().optional().describe('uid del responsable'),
|
|
710
|
+
recurrence: z.object({ freq: z.enum(['daily', 'weekly', 'monthly']) }).nullable().optional()
|
|
711
|
+
.describe('Repetición (ej. junta semanal): al marcarla hecha se agenda sola la siguiente, misma hora y participantes. null la quita.'),
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
server.registerTool(
|
|
715
|
+
'log_activity',
|
|
716
|
+
{
|
|
717
|
+
description: 'Agenda o registra una actividad -- reunión, llamada o seguimiento -- en un proyecto de Orquestra. No es una tarea: no lleva módulo obligatorio ni pasa por el kanban; aparece en la Agenda y en el calendario. Para una que YA pasó, manda activityStatus "done" con outcome (y nextStep si hubo). Si el siguiente paso es trabajo real, crea además la tarea con create_task.',
|
|
718
|
+
inputSchema: {
|
|
719
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
720
|
+
type: z.enum(ACTIVITY_TYPES).describe('meeting = reunión, call = llamada, followup = seguimiento'),
|
|
721
|
+
title: z.string().describe('Título de la actividad'),
|
|
722
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('Día de la actividad (YYYY-MM-DD, hora local del usuario).'),
|
|
723
|
+
time: z.string().regex(/^\d{2}:\d{2}$/, 'Formato esperado: HH:MM').optional().describe('Hora local (HH:MM, 24 h), si se sabe.'),
|
|
724
|
+
activityStatus: z.enum(ACTIVITY_STATUSES).optional().describe('Default "scheduled" (programada). "done" para registrar una que ya pasó.'),
|
|
725
|
+
...ACTIVITY_FIELDS_SCHEMA,
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
async ({ projectId, date, time, ...body }) => {
|
|
729
|
+
try {
|
|
730
|
+
const scheduledAt = toScheduledAt(date, time)
|
|
731
|
+
const data = await apiPost(config, '/v1/tasks', {
|
|
732
|
+
projectId: resolveProjectId(projectId), ...body, dueDate: date, ...(scheduledAt ? { scheduledAt } : {}),
|
|
733
|
+
})
|
|
734
|
+
return textResult(data)
|
|
735
|
+
} catch (err) {
|
|
736
|
+
return errorResult(err)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
server.registerTool(
|
|
742
|
+
'update_activity',
|
|
743
|
+
{
|
|
744
|
+
description: 'Edita una actividad (reunión/llamada/seguimiento) de Orquestra: reprogramarla, marcarla hecha con su resultado (activityStatus "done" + outcome + nextStep) o cancelarla. El server mueve solo el estatus interno; no hay que validar transiciones del kanban.',
|
|
745
|
+
inputSchema: {
|
|
746
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
747
|
+
id: z.string().describe('ID de la actividad (ver list_activities)'),
|
|
748
|
+
title: z.string().optional(),
|
|
749
|
+
type: z.enum(ACTIVITY_TYPES).optional(),
|
|
750
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Nuevo día (YYYY-MM-DD). Si cambias el día y había hora, manda también time.'),
|
|
751
|
+
time: z.string().regex(/^\d{2}:\d{2}$/, 'Formato esperado: HH:MM').optional().describe('Nueva hora local (HH:MM). Requiere date.'),
|
|
752
|
+
activityStatus: z.enum(ACTIVITY_STATUSES).optional(),
|
|
753
|
+
...ACTIVITY_FIELDS_SCHEMA,
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
async ({ projectId, id, date, time, ...body }) => {
|
|
757
|
+
try {
|
|
758
|
+
if (time && !date) throw new Error('Para cambiar la hora manda también date (YYYY-MM-DD).')
|
|
759
|
+
const scheduledAt = date ? toScheduledAt(date, time) : undefined
|
|
760
|
+
const data = await apiPatch(config, '/v1/tasks', {
|
|
761
|
+
projectId: resolveProjectId(projectId), id, ...body,
|
|
762
|
+
...(date ? { dueDate: date } : {}), ...(scheduledAt ? { scheduledAt } : {}),
|
|
763
|
+
})
|
|
764
|
+
return textResult(data)
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return errorResult(err)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
// Base de conocimiento del workspace (ORQ-298) -- ver lib/knowledge.mjs en orquestra-infra.
|
|
772
|
+
registerRead(
|
|
773
|
+
'list_knowledge',
|
|
774
|
+
'/v1/knowledge',
|
|
775
|
+
'Lista la base de conocimiento del WORKSPACE (guías, procesos, decisiones que no son de un solo proyecto). Cuerpo recortado a 300 caracteres salvo full=true. linkedTo=projectId trae solo lo vinculado a ese proyecto. Revísala antes de documentar algo que quizá ya existe.',
|
|
776
|
+
{
|
|
777
|
+
q: z.string().optional().describe('Texto a buscar en título/cuerpo'),
|
|
778
|
+
tag: z.string().optional(),
|
|
779
|
+
linkedTo: z.string().optional().describe('projectId: solo entradas vinculadas a ese proyecto'),
|
|
780
|
+
full: z.boolean().optional().describe('true = cuerpo completo'),
|
|
781
|
+
},
|
|
782
|
+
(args, projectId) => ({ projectId, q: args.q, tag: args.tag, linkedTo: args.linkedTo, full: args.full ? 'true' : undefined })
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
server.registerTool(
|
|
786
|
+
'get_knowledge',
|
|
787
|
+
{
|
|
788
|
+
description: 'Lee una entrada completa de la base de conocimiento del workspace.',
|
|
789
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC), id: z.string() },
|
|
790
|
+
},
|
|
791
|
+
async ({ projectId, id }) => {
|
|
792
|
+
try { return textResult(await apiGet(config, `/v1/knowledge/${id}`, { projectId: resolveProjectId(projectId) })) } catch (err) { return errorResult(err) }
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
registerCreate(
|
|
797
|
+
'create_knowledge',
|
|
798
|
+
'/v1/knowledge',
|
|
799
|
+
'Crea una entrada en la base de conocimiento del WORKSPACE (no de un proyecto). Úsala para lo reutilizable entre proyectos; lo específico de un proyecto va en create_note/create_doc. [[Título]] enlaza otra entrada de la base.',
|
|
800
|
+
{
|
|
801
|
+
title: z.string(),
|
|
802
|
+
body: z.string().optional().describe('Markdown'),
|
|
803
|
+
tags: z.array(z.string()).optional(),
|
|
804
|
+
visibility: z.enum(['workspace', 'private']).optional().describe('Default workspace. private = solo la ve quien la creó.'),
|
|
805
|
+
linkToProject: z.boolean().optional().describe('true = vincularla de una vez al proyecto actual'),
|
|
806
|
+
}
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
registerUpdate('update_knowledge', '/v1/knowledge',
|
|
810
|
+
'Edita una entrada de la base de conocimiento del workspace (solo los campos que mandes).',
|
|
811
|
+
{
|
|
812
|
+
title: z.string().optional(),
|
|
813
|
+
body: z.string().optional(),
|
|
814
|
+
tags: z.array(z.string()).optional().describe('Reemplaza la lista completa'),
|
|
815
|
+
visibility: z.enum(['workspace', 'private']).optional(),
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
server.registerTool(
|
|
820
|
+
'link_knowledge_to_project',
|
|
821
|
+
{
|
|
822
|
+
description: 'Lleva una entrada de la base a un proyecto. mode "link" (default) = referencia viva: aparece en el proyecto y entra al contexto del asistente; "unlink" la quita; "copy" crea una nota o doc independiente en el proyecto (snapshot, ya no se actualiza).',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
825
|
+
id: z.string().describe('id de la entrada (ver list_knowledge)'),
|
|
826
|
+
mode: z.enum(['link', 'unlink', 'copy']).optional(),
|
|
827
|
+
as: z.enum(['note', 'doc']).optional().describe('Solo para copy. Default note.'),
|
|
828
|
+
docType: z.enum(DOC_TYPES).optional().describe('Solo para copy como doc. Default readme.'),
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
async ({ projectId, id, ...body }) => {
|
|
832
|
+
try { return textResult(await apiPost(config, `/v1/knowledge/${id}/link`, { projectId: resolveProjectId(projectId), ...body })) } catch (err) { return errorResult(err) }
|
|
833
|
+
}
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
registerCreate(
|
|
837
|
+
'promote_to_knowledge',
|
|
838
|
+
'/v1/knowledge/promote',
|
|
839
|
+
'Promueve una nota o doc del proyecto a la base de conocimiento del workspace (cuando sirve más allá de este proyecto). El original no se borra: queda con knowledgeId y la entrada queda vinculada a este proyecto. Solo se puede promover una vez.',
|
|
840
|
+
{
|
|
841
|
+
type: z.enum(['note', 'doc']),
|
|
842
|
+
id: z.string(),
|
|
843
|
+
tags: z.array(z.string()).optional(),
|
|
844
|
+
}
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
// Plantillas (ORQ-297): base (código) + propias del workspace.
|
|
848
|
+
registerRead(
|
|
849
|
+
'list_templates',
|
|
850
|
+
'/v1/templates',
|
|
851
|
+
'Plantillas de notas y docs: las base de Orquestra (minuta de reunión, retro, propuesta a cliente, checklist de lanzamiento, ADR, runbook; ids "builtin:*") y las propias del workspace. Úsalas con templateId en create_note/create_doc en vez de inventar la estructura.',
|
|
852
|
+
{ kind: z.enum(['note', 'doc']).optional() },
|
|
853
|
+
(args, projectId) => ({ projectId, kind: args.kind })
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
// Wikilinks (ORQ-296): backlinks calculados del texto en el server (lib/wikilinks.mjs).
|
|
857
|
+
registerRead(
|
|
858
|
+
'get_backlinks',
|
|
859
|
+
'/v1/backlinks',
|
|
860
|
+
'Backlinks ("Mencionado en"): qué notas, docs, tareas e ideas enlazan con [[...]] a la entidad pedida. Útil antes de editar o cerrar algo para ver qué depende de eso.',
|
|
861
|
+
{
|
|
862
|
+
type: z.enum(['note', 'doc', 'task', 'idea']),
|
|
863
|
+
id: z.string().describe('id de la nota/doc/tarea/idea'),
|
|
864
|
+
},
|
|
865
|
+
(args, projectId) => ({ projectId, type: args.type, id: args.id })
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
registerRead(
|
|
869
|
+
'list_activities',
|
|
870
|
+
'/v1/activities',
|
|
871
|
+
'Lista las actividades (reuniones, llamadas, seguimientos) de un proyecto de Orquestra. status "scheduled" = agenda (próximas primero), "done" = bitácora (más recientes primero); sin status trae todas. from/to acotan por día.',
|
|
872
|
+
{
|
|
873
|
+
status: z.enum(ACTIVITY_STATUSES).optional(),
|
|
874
|
+
type: z.enum(ACTIVITY_TYPES).optional(),
|
|
875
|
+
moduleId: z.string().optional(),
|
|
876
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Desde (YYYY-MM-DD, inclusive)'),
|
|
877
|
+
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Hasta (YYYY-MM-DD, inclusive)'),
|
|
878
|
+
limit: z.number().optional().describe('Default 100'),
|
|
879
|
+
},
|
|
880
|
+
(args, projectId) => ({ projectId, status: args.status, type: args.type, moduleId: args.moduleId, from: args.from, to: args.to, limit: args.limit })
|
|
881
|
+
)
|
|
882
|
+
|
|
682
883
|
// Fusionar tareas (20 ago 2026) -- registradas directo con
|
|
683
884
|
// server.registerTool (no registerCreate/registerUpdate) porque son
|
|
684
885
|
// POST a `${path}/${id}/merge|unmerge`, no un CRUD estándar. Fusión
|
|
@@ -1686,7 +1887,7 @@ export function createServer() {
|
|
|
1686
1887
|
registerCreate(
|
|
1687
1888
|
'create_note',
|
|
1688
1889
|
'/v1/notes',
|
|
1689
|
-
'Crea una nota en un proyecto de Orquestra.',
|
|
1890
|
+
'Crea una nota en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
1690
1891
|
{
|
|
1691
1892
|
title: z.string().describe('Título de la nota'),
|
|
1692
1893
|
body: z.string().optional(),
|
|
@@ -1699,12 +1900,15 @@ export function createServer() {
|
|
|
1699
1900
|
visibility: z.enum(NOTE_VISIBILITY).optional().describe('Default: team. "private" restringe la lectura a quien la creó (enforcement real en firestore.rules, no solo la UI).'),
|
|
1700
1901
|
relatedNoteId: z.string().optional().describe('Otra nota relacionada, si es clara la relación (ver search) -- agregado 12 ago 2026. No inventes el vínculo si no es evidente.'),
|
|
1701
1902
|
adrDocId: z.string().optional().describe('Liga esta nota a un doc ADR ya creado con create_doc (type="adr"). No lo mandes junto con publishAsAdr -- ese genera su propio doc.'),
|
|
1903
|
+
templateId: z.string().optional().describe('Plantilla (ver list_templates): rellena el texto si no mandas uno, y el tipo si no lo mandas. Las variables {{fecha}}/{{proyecto}}/{{autor}}/{{titulo}} se resuelven solas.'),
|
|
1904
|
+
templateVars: z.object({ fecha: z.string().optional(), proyecto: z.string().optional(), autor: z.string().optional(), titulo: z.string().optional() }).optional()
|
|
1905
|
+
.describe('Sobrescribe variables de la plantilla -- útil para {{fecha}} en la hora local del usuario.'),
|
|
1702
1906
|
publishAsAdr: z.boolean().optional().describe('Solo para type=\'decision\'. Genera automáticamente el doc ADR correspondiente (numerado "ADR-XXX: {title}", con la plantilla Contexto/Alternativas consideradas/Decisión/Consecuencias a partir de body/alternativesConsidered/wouldRevertIf) y liga esta nota a él -- mismo flujo que el toggle "Publicar como ADR" de NewNoteModal.jsx en la web, en un solo paso.'),
|
|
1703
1907
|
}
|
|
1704
1908
|
)
|
|
1705
1909
|
|
|
1706
1910
|
registerUpdate('update_note', '/v1/notes',
|
|
1707
|
-
'Edita una nota existente. Todos los campos son opcionales -- solo se cambian los que mandes.',
|
|
1911
|
+
'Edita una nota existente. Todos los campos son opcionales -- solo se cambian los que mandes. Si cambias el título, los [[Título viejo]] de todo el proyecto se reescriben solos.',
|
|
1708
1912
|
{
|
|
1709
1913
|
title: z.string().optional(),
|
|
1710
1914
|
body: z.string().optional(),
|
|
@@ -1758,11 +1962,14 @@ export function createServer() {
|
|
|
1758
1962
|
registerCreate(
|
|
1759
1963
|
'create_doc',
|
|
1760
1964
|
'/v1/docs',
|
|
1761
|
-
'Crea un documento en un proyecto de Orquestra.',
|
|
1965
|
+
'Crea un documento en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
1762
1966
|
{
|
|
1763
|
-
type: z.enum(DOC_TYPES).describe('Tipo de documento'),
|
|
1967
|
+
type: z.enum(DOC_TYPES).optional().describe('Tipo de documento -- obligatorio salvo que mandes templateId (la plantilla trae su tipo)'),
|
|
1764
1968
|
title: z.string().describe('Título del documento'),
|
|
1765
1969
|
content: z.string().optional(),
|
|
1970
|
+
templateId: z.string().optional().describe('Plantilla (ver list_templates): rellena el texto si no mandas uno, y el tipo si no lo mandas. Las variables {{fecha}}/{{proyecto}}/{{autor}}/{{titulo}} se resuelven solas.'),
|
|
1971
|
+
templateVars: z.object({ fecha: z.string().optional(), proyecto: z.string().optional(), autor: z.string().optional(), titulo: z.string().optional() }).optional()
|
|
1972
|
+
.describe('Sobrescribe variables de la plantilla -- útil para {{fecha}} en la hora local del usuario.'),
|
|
1766
1973
|
adrStatus: z.enum(ADR_STATUSES).optional().describe('Solo para type=\'adr\'. "aceptado" es lo único que alimenta Material (Contenido) como fuente de ángulos -- ver adrContext.'),
|
|
1767
1974
|
adrContext: z.string().optional().describe('Solo para type=\'adr\'. Qué problema/situación llevó a la decisión, en texto estructurado (no el markdown libre de content).'),
|
|
1768
1975
|
adrConsequences: z.string().optional().describe('Solo para type=\'adr\'. Qué cambia de ahora en adelante.'),
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Sincronización con un vault de Obsidian (ORQ-299): `orquestra-mcp vault sync <carpeta>`.
|
|
2
|
+
//
|
|
3
|
+
// Sincroniza la BASE DE CONOCIMIENTO DEL WORKSPACE (ORQ-298, /v1/knowledge) con
|
|
4
|
+
// una carpeta del vault, en los dos sentidos. Obsidian queda como editor y
|
|
5
|
+
// Orquestra como respaldo, búsqueda y contexto de IA. Cada entrada es un .md
|
|
6
|
+
// cuyo NOMBRE es el título (así [[Título]] resuelve igual en Obsidian y en
|
|
7
|
+
// Orquestra) con frontmatter:
|
|
8
|
+
// orquestraId, orquestraUpdatedAt, orquestraTitle (si el nombre tuvo que
|
|
9
|
+
// sanearse), tags. Cualquier otra clave del frontmatter se conserva.
|
|
10
|
+
// `.orquestra-sync.json` en la carpeta guarda lo último sincronizado de cada
|
|
11
|
+
// lado (updatedAt remoto + hash local) para saber quién cambió.
|
|
12
|
+
// Conflicto (cambiaron los dos): gana el más reciente y el otro lado queda
|
|
13
|
+
// como "<nombre>.conflict.md" junto al archivo. Nada se borra nunca: una
|
|
14
|
+
// entrada borrada en el vault se vuelve a bajar, y un .md con orquestraId que
|
|
15
|
+
// ya no existe en Orquestra solo se reporta.
|
|
16
|
+
// Fuera de alcance v1: adjuntos/imágenes del vault (se reportan, no se suben).
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { promises as fs } from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { loadConfig, apiGet, apiPost, apiPatch } from './api.mjs'
|
|
21
|
+
|
|
22
|
+
export const STATE_FILE = '.orquestra-sync.json'
|
|
23
|
+
const CONFLICT_SUFFIX = '.conflict.md'
|
|
24
|
+
|
|
25
|
+
// ── Formato ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function sanitizeFileName(title) {
|
|
28
|
+
const clean = (title || 'Sin título').replace(/[\\/:*?"<>|#^[\]]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
29
|
+
return clean || 'Sin título'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unquote = v => v.replace(/^["']|["']$/g, '')
|
|
33
|
+
|
|
34
|
+
/** { meta: {orquestraId, orquestraUpdatedAt, orquestraTitle, tags}, extra: [líneas], body } */
|
|
35
|
+
export function parseNote(text) {
|
|
36
|
+
const meta = { tags: [] }
|
|
37
|
+
const extra = []
|
|
38
|
+
let body = text
|
|
39
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
40
|
+
if (m) {
|
|
41
|
+
body = text.slice(m[0].length)
|
|
42
|
+
const lines = m[1].split(/\r?\n/)
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i]
|
|
45
|
+
const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/)
|
|
46
|
+
if (!kv) { extra.push(line); continue }
|
|
47
|
+
const [, key, raw] = kv
|
|
48
|
+
if (key === 'tags') {
|
|
49
|
+
if (raw.startsWith('[')) {
|
|
50
|
+
meta.tags = raw.replace(/^\[|\]$/g, '').split(',').map(t => unquote(t.trim())).filter(Boolean)
|
|
51
|
+
} else if (raw.trim()) {
|
|
52
|
+
meta.tags = raw.split(/[,\s]+/).map(t => unquote(t.trim())).filter(Boolean)
|
|
53
|
+
} else {
|
|
54
|
+
while (lines[i + 1]?.match(/^\s*-\s+/)) meta.tags.push(unquote(lines[++i].replace(/^\s*-\s+/, '').trim()))
|
|
55
|
+
}
|
|
56
|
+
} else if (key === 'orquestraId' || key === 'orquestraTitle') {
|
|
57
|
+
meta[key] = unquote(raw.trim())
|
|
58
|
+
} else if (key === 'orquestraUpdatedAt') {
|
|
59
|
+
meta.orquestraUpdatedAt = Number(raw.trim()) || 0
|
|
60
|
+
} else {
|
|
61
|
+
extra.push(line)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
meta.tags = meta.tags.map(t => t.replace(/^#/, '').toLowerCase())
|
|
66
|
+
return { meta, extra, body }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function serializeNote({ id, updatedAt, title, fileTitle, tags = [], body = '', extra = [] }) {
|
|
70
|
+
const lines = ['---']
|
|
71
|
+
if (id) lines.push(`orquestraId: ${id}`)
|
|
72
|
+
if (updatedAt) lines.push(`orquestraUpdatedAt: ${updatedAt}`)
|
|
73
|
+
if (title && title !== fileTitle) lines.push(`orquestraTitle: ${JSON.stringify(title)}`)
|
|
74
|
+
lines.push(`tags: [${tags.join(', ')}]`)
|
|
75
|
+
lines.push(...extra)
|
|
76
|
+
lines.push('---')
|
|
77
|
+
return `${lines.join('\n')}\n${body}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Título real de un archivo: el de Orquestra si el nombre es solo su versión saneada, si no el nombre (lo renombraron). */
|
|
81
|
+
export function titleOfFile(fileTitle, meta) {
|
|
82
|
+
return meta.orquestraTitle && sanitizeFileName(meta.orquestraTitle) === fileTitle ? meta.orquestraTitle : fileTitle
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function contentHash({ title, body, tags }) {
|
|
86
|
+
return createHash('sha1').update(`${title}\n${[...(tags || [])].sort().join(',')}\n${(body || '').replace(/\r\n/g, '\n').trimEnd()}`).digest('hex')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Plan (puro, sin red ni disco) ───────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* remote: [{id, title, body, tags, updatedAt}]
|
|
93
|
+
* local: [{file, title, body, tags, id, mtimeMs, hash, extra}]
|
|
94
|
+
* state: {entries: {[id]: {file, remoteUpdatedAt, localHash}}}
|
|
95
|
+
* → [{op, ...}] con op: pull | push | create | conflict | orphan
|
|
96
|
+
*/
|
|
97
|
+
export function planSync({ remote, local, state }) {
|
|
98
|
+
const entries = state?.entries || {}
|
|
99
|
+
const localById = new Map(local.filter(l => l.id).map(l => [l.id, l]))
|
|
100
|
+
const remoteIds = new Set(remote.map(r => r.id))
|
|
101
|
+
const plan = []
|
|
102
|
+
|
|
103
|
+
for (const r of remote) {
|
|
104
|
+
const l = localById.get(r.id)
|
|
105
|
+
const prev = entries[r.id]
|
|
106
|
+
if (!l) { plan.push({ op: 'pull', remote: r, reason: prev ? 'borrada en el vault, se restaura' : 'nueva en Orquestra' }); continue }
|
|
107
|
+
if (!prev) {
|
|
108
|
+
// Vinculada pero sin historial (state perdido): si difieren, conflicto; si no, nada.
|
|
109
|
+
if (contentHash(r) !== l.hash) plan.push({ op: 'conflict', remote: r, local: l, winner: (l.mtimeMs || 0) > (r.updatedAt || 0) ? 'local' : 'remote' })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const remoteChanged = (r.updatedAt || 0) > (prev.remoteUpdatedAt || 0)
|
|
113
|
+
const localChanged = l.hash !== prev.localHash
|
|
114
|
+
if (remoteChanged && localChanged) {
|
|
115
|
+
plan.push({ op: 'conflict', remote: r, local: l, winner: (l.mtimeMs || 0) > (r.updatedAt || 0) ? 'local' : 'remote' })
|
|
116
|
+
} else if (remoteChanged) {
|
|
117
|
+
plan.push({ op: 'pull', remote: r, local: l, reason: 'cambió en Orquestra' })
|
|
118
|
+
} else if (localChanged) {
|
|
119
|
+
plan.push({ op: 'push', remote: r, local: l })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const l of local) {
|
|
123
|
+
if (!l.id) plan.push({ op: 'create', local: l })
|
|
124
|
+
else if (!remoteIds.has(l.id)) plan.push({ op: 'orphan', local: l })
|
|
125
|
+
}
|
|
126
|
+
return plan
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Disco ────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async function scanVault(dir, rel = '') {
|
|
132
|
+
const out = []
|
|
133
|
+
for (const ent of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
134
|
+
if (ent.name.startsWith('.')) continue
|
|
135
|
+
const relPath = path.join(rel, ent.name)
|
|
136
|
+
if (ent.isDirectory()) out.push(...await scanVault(dir, relPath))
|
|
137
|
+
else if (ent.name.endsWith('.md') && !ent.name.endsWith(CONFLICT_SUFFIX)) out.push(relPath)
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readLocal(dir) {
|
|
143
|
+
const files = await scanVault(dir)
|
|
144
|
+
const notes = []
|
|
145
|
+
const attachments = []
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const full = path.join(dir, file)
|
|
148
|
+
const [text, stat] = await Promise.all([fs.readFile(full, 'utf8'), fs.stat(full)])
|
|
149
|
+
const { meta, extra, body } = parseNote(text)
|
|
150
|
+
const fileTitle = path.basename(file, '.md')
|
|
151
|
+
const title = titleOfFile(fileTitle, meta)
|
|
152
|
+
const note = { file, title, body, tags: meta.tags, id: meta.orquestraId || null, mtimeMs: stat.mtimeMs, extra }
|
|
153
|
+
note.hash = contentHash(note)
|
|
154
|
+
notes.push(note)
|
|
155
|
+
for (const emb of body.matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:)([^)]+)\)/g)) attachments.push({ file, ref: emb[1] || emb[2] })
|
|
156
|
+
}
|
|
157
|
+
return { notes, attachments }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readState(dir) {
|
|
161
|
+
try { return JSON.parse(await fs.readFile(path.join(dir, STATE_FILE), 'utf8')) } catch { return null }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function uniqueFile(title, taken) {
|
|
165
|
+
const base = sanitizeFileName(title)
|
|
166
|
+
let name = `${base}.md`
|
|
167
|
+
for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base} (${n}).md`
|
|
168
|
+
taken.add(name.toLowerCase())
|
|
169
|
+
return name
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Comando ──────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const LABEL = { pull: '↓ bajar', push: '↑ subir', create: '↑ crear', conflict: '⚠ conflicto', orphan: '? sin entrada en Orquestra' }
|
|
175
|
+
|
|
176
|
+
export async function runVaultSync(argv) {
|
|
177
|
+
const dirArg = argv.find(a => !a.startsWith('--'))
|
|
178
|
+
if (!dirArg) {
|
|
179
|
+
console.log('Uso: orquestra-mcp vault sync <carpeta-del-vault> [--apply | --dry-run]\n' +
|
|
180
|
+
'Sincroniza la base de conocimiento del workspace con esa carpeta. Córrelo desde la carpeta de un\n' +
|
|
181
|
+
'proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const dir = path.resolve(dirArg)
|
|
185
|
+
await fs.mkdir(dir, { recursive: true })
|
|
186
|
+
const config = loadConfig()
|
|
187
|
+
const projectId = config.defaultProjectId
|
|
188
|
+
if (!projectId) throw new Error('Esta carpeta no está conectada a un proyecto de Orquestra -- corre "npx orquestra-mcp setup" primero.')
|
|
189
|
+
|
|
190
|
+
const state = await readState(dir)
|
|
191
|
+
if (state && state.workspaceId !== config.workspaceId) {
|
|
192
|
+
throw new Error(`Esta carpeta ya se sincroniza con otro workspace (${state.workspaceId}). Usa otra carpeta.`)
|
|
193
|
+
}
|
|
194
|
+
// Primera vez: solo muestra el plan, salvo --apply explícito.
|
|
195
|
+
const apply = argv.includes('--apply') || (!!state && !argv.includes('--dry-run'))
|
|
196
|
+
|
|
197
|
+
const { items: remote } = await apiGet(config, '/v1/knowledge', { projectId, full: 'true' })
|
|
198
|
+
const { notes: local, attachments } = await readLocal(dir)
|
|
199
|
+
const plan = planSync({ remote, local, state })
|
|
200
|
+
|
|
201
|
+
console.log(`Vault: ${dir}\nOrquestra: ${remote.length} entradas · Vault: ${local.length} notas · ${plan.length} cambios${apply ? '' : ' (vista previa, no se escribe nada)'}`)
|
|
202
|
+
for (const p of plan) console.log(` ${LABEL[p.op]} ${p.local?.file || p.remote?.title}${p.reason ? ` (${p.reason})` : ''}${p.op === 'conflict' ? ` -- gana ${p.winner === 'local' ? 'el vault' : 'Orquestra'}` : ''}`)
|
|
203
|
+
if (attachments.length) console.log(` ${attachments.length} imagen(es)/adjunto(s) del vault no se sincronizan en esta versión (solo texto).`)
|
|
204
|
+
if (!apply) {
|
|
205
|
+
if (plan.length) console.log('\nPara aplicar: orquestra-mcp vault sync ' + dirArg + ' --apply')
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const entries = { ...(state?.entries || {}) }
|
|
210
|
+
const taken = new Set(local.map(l => path.basename(l.file).toLowerCase()))
|
|
211
|
+
const writeRemote = async (r, file, extra = []) => {
|
|
212
|
+
const fileTitle = path.basename(file, '.md')
|
|
213
|
+
await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true })
|
|
214
|
+
await fs.writeFile(path.join(dir, file), serializeNote({ id: r.id, updatedAt: r.updatedAt, title: r.title, fileTitle, tags: r.tags || [], body: r.body || '', extra }))
|
|
215
|
+
entries[r.id] = { file, remoteUpdatedAt: r.updatedAt || 0, localHash: contentHash({ title: r.title, body: r.body, tags: r.tags || [] }) }
|
|
216
|
+
}
|
|
217
|
+
const push = async (l, id) => {
|
|
218
|
+
const body = { projectId, title: l.title, body: l.body, tags: l.tags }
|
|
219
|
+
const saved = id ? await apiPatch(config, '/v1/knowledge', { id, ...body }) : await apiPost(config, '/v1/knowledge', body)
|
|
220
|
+
// Reescribe el frontmatter con el id/updatedAt nuevos, sin tocar el texto.
|
|
221
|
+
await writeRemote({ ...saved, title: l.title, body: l.body, tags: saved.tags || l.tags }, l.file, l.extra)
|
|
222
|
+
}
|
|
223
|
+
const saveConflictCopy = async (file, text) => {
|
|
224
|
+
await fs.writeFile(path.join(dir, file.replace(/\.md$/, CONFLICT_SUFFIX)), text)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let failed = 0
|
|
228
|
+
for (const p of plan) {
|
|
229
|
+
try {
|
|
230
|
+
if (p.op === 'pull') await writeRemote(p.remote, p.local?.file || entries[p.remote.id]?.file || uniqueFile(p.remote.title, taken), p.local?.extra)
|
|
231
|
+
else if (p.op === 'push') await push(p.local, p.remote.id)
|
|
232
|
+
else if (p.op === 'create') await push(p.local, null)
|
|
233
|
+
else if (p.op === 'conflict') {
|
|
234
|
+
if (p.winner === 'local') {
|
|
235
|
+
await saveConflictCopy(p.local.file, serializeNote({ id: p.remote.id, title: p.remote.title, fileTitle: '', tags: p.remote.tags || [], body: p.remote.body || '' }))
|
|
236
|
+
await push(p.local, p.remote.id)
|
|
237
|
+
} else {
|
|
238
|
+
await saveConflictCopy(p.local.file, await fs.readFile(path.join(dir, p.local.file), 'utf8'))
|
|
239
|
+
await writeRemote(p.remote, p.local.file, p.local.extra)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
failed++
|
|
244
|
+
console.error(` ✗ ${p.local?.file || p.remote?.title}: ${err.message}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries }, null, 2))
|
|
248
|
+
console.log(failed ? `\nListo con ${failed} error(es) -- vuelve a correrlo para reintentar.` : '\nListo.')
|
|
249
|
+
}
|