orquestra-mcp 1.74.0 → 1.76.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/package.json +1 -1
- package/src/designFlow.mjs +296 -0
- package/src/designSync.mjs +148 -0
- package/src/server.mjs +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.76.0",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 161 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": {
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// ORQ-564: flujo guiado para llenar el módulo Diseño / UI. Datos estáticos, sin red
|
|
2
|
+
// (get_design_flow) + lógica pura de estado (computeDesignStatus, usada por get_design_status).
|
|
3
|
+
// El contenido largo vive aquí, en el VALOR que devuelve la tool, no en su descripción.
|
|
4
|
+
|
|
5
|
+
export const DESIGN_PLATFORMS = ['web', 'ios', 'android']
|
|
6
|
+
|
|
7
|
+
const PRINCIPLES = [
|
|
8
|
+
'La web es la referencia: se carga primero y las demás plataformas reutilizan sus keys (list_design_nav platform=web).',
|
|
9
|
+
'No inventes: registra solo lo que está en el código real de la plataforma (router, tab bar, temas, componentes).',
|
|
10
|
+
'Un agente por plataforma a la vez. Evita cargas simultáneas del mismo nombre/key en el mismo catálogo (tokens, componentes): se pisan.',
|
|
11
|
+
'Lotes compactos: tokens ≤100, nodos de navegación ≤40, componentes ≤200 por llamada; deja compact=true.',
|
|
12
|
+
'Los nodos no se borran: se actualizan (upsert). Borrar es la excepción y solo con confirmación del usuario.',
|
|
13
|
+
'Revisa siempre los `warnings` de la respuesta (tokens o usedIn inexistentes) y corrígelos antes de seguir.',
|
|
14
|
+
'upsert_design_nav y upsert_design_components MEZCLAN actions/states/platforms por key; update_design_nav_node y update_design_component REEMPLAZAN el array completo.',
|
|
15
|
+
'No crees tareas (create_parity_tasks, create_task) sin confirmar antes con el usuario.',
|
|
16
|
+
'Cada paso termina cuando get_design_status lo confirma (el campo `next` avanza), no cuando tú lo sientes terminado.',
|
|
17
|
+
'Antes de crear cualquier cosa llama a list_* para no duplicar.',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const GLOSSARY = {
|
|
21
|
+
actions: {
|
|
22
|
+
convention: 'entidad.verbo (task.create, project.archive). La misma operación lleva la MISMA key en web, iOS y Android.',
|
|
23
|
+
verbs: ['create', 'edit', 'delete', 'filter', 'search', 'sort', 'export', 'share', 'assign', 'comment', 'convert', 'archive', 'restore', 'open-detail', 'reorder', 'import', 'change-view'],
|
|
24
|
+
},
|
|
25
|
+
states: {
|
|
26
|
+
note: 'Registra solo los que existan en el código de esa pantalla.',
|
|
27
|
+
list: ['loading', 'empty', 'error', 'readonly', 'no-access', 'offline', 'refreshing', 'search-empty'],
|
|
28
|
+
},
|
|
29
|
+
blocks: [
|
|
30
|
+
'Encabezado', 'Búsqueda', 'Filtros', 'Pestañas', 'Contenido de pestaña', 'Lista', 'Tablero', 'Tarjeta de elemento',
|
|
31
|
+
'Estado vacío', 'Estado de error', 'Aviso', 'Acciones', 'Formulario', 'KPIs / Métricas', 'Resumen', 'Actividad reciente',
|
|
32
|
+
'Navegación', 'Primeros pasos', 'Vista por rol', 'Asistente IA', 'Módulos', 'Plataformas / Infraestructura', 'Miembros',
|
|
33
|
+
'Cuenta y sesión', 'Ajustes',
|
|
34
|
+
],
|
|
35
|
+
components: [
|
|
36
|
+
'Button', 'Card', 'Modal', 'Drawer', 'Input', 'Select', 'Checkbox', 'Toggle', 'Badge', 'Chip', 'Tabs', 'SegmentedControl',
|
|
37
|
+
'ListRow', 'EmptyState', 'Toast', 'Banner', 'Avatar', 'DropdownMenu', 'ProgressBar', 'Tooltip', 'Topbar', 'Sidebar',
|
|
38
|
+
'SearchBar', 'ConfirmDialog', 'DatePicker',
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STEPS = [
|
|
43
|
+
{
|
|
44
|
+
order: 1, id: 'check', title: 'Revisar qué ya existe',
|
|
45
|
+
goal: 'Saber qué hay cargado en Diseño / UI para no duplicar ni pisar nada.',
|
|
46
|
+
tools: ['get_design_status', 'list_design_tokens', 'list_design_nav', 'list_design_components'],
|
|
47
|
+
how: [
|
|
48
|
+
'Llama get_design_status: da conteos por plataforma y el primer paso pendiente (`next`).',
|
|
49
|
+
'Si vas a trabajar una plataforma con datos previos, haz list_design_tokens / list_design_nav / list_design_components con ese platform antes de crear.',
|
|
50
|
+
'Decide la plataforma de esta pasada (una por agente) y empieza por el `next` que indique el estado.',
|
|
51
|
+
],
|
|
52
|
+
doneWhen: 'Sabes qué plataforma vas a trabajar y cuál es su primer paso pendiente.',
|
|
53
|
+
pitfalls: ['Crear sin listar primero genera duplicados.', 'Empezar por iOS/Android sin que la web tenga navegación cargada.'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
order: 2, id: 'identity', title: 'Identidad: design tokens',
|
|
57
|
+
goal: 'Registrar colores, tipografía, radios, sombras, espaciado, iconos y logo de la plataforma.',
|
|
58
|
+
tools: ['list_design_tokens', 'upsert_design_tokens', 'create_design_token', 'update_design_token'],
|
|
59
|
+
how: [
|
|
60
|
+
'Lee los archivos que DEFINEN el tema (web: theme/tokens.css/variables.css; iOS: Colors.swift/Theme; Android: Color.kt/Theme.kt), no los que lo usan.',
|
|
61
|
+
'Usa nombres compartidos entre plataformas: brand, surface, page-bg, border, text-primary, text-secondary, text-tertiary, text-disabled, danger, success, warning, ai, radius-sm, radius-md, radius-lg, radius-pill, font-sans.',
|
|
62
|
+
'Cada token: kind (color|typography|radius|shadow|spacing|icon-set|logo), value (modo claro) y valueDark si existe, platform, group y sourceRef (archivo y símbolo).',
|
|
63
|
+
'Carga con upsert_design_tokens en lotes de hasta 100.',
|
|
64
|
+
'Verifica con get_design_status (tokens > 0 para la plataforma).',
|
|
65
|
+
],
|
|
66
|
+
doneWhen: 'get_design_status muestra tokens > 0 para la plataforma y los nombres coinciden con los de la web.',
|
|
67
|
+
pitfalls: ['Registrar valores hex que no están en el archivo del tema.', 'Nombres distintos por plataforma para lo mismo (impide comparar paridad).', 'Olvidar valueDark cuando el tema tiene modo oscuro.'],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
order: 3, id: 'navigation', title: 'Navegación: árbol de pantallas',
|
|
71
|
+
goal: 'Registrar el árbol de menús, secciones, pantallas, pestañas, acciones y overlays con keys estables entre plataformas.',
|
|
72
|
+
tools: ['list_design_nav', 'upsert_design_nav', 'update_design_nav_node', 'reorder_design_nav'],
|
|
73
|
+
how: [
|
|
74
|
+
'Lee el router (web), el tab bar / NavigationStack (iOS) o el NavHost / bottom bar (Android) y el drawer o sidebar si existe.',
|
|
75
|
+
'Cada nodo: kind (menu|section|screen|tab|action|overlay), name, key estable, parentKey, route.',
|
|
76
|
+
'La WEB va primero y es la referencia. iOS y Android primero llaman list_design_nav platform=web y REUTILIZAN sus keys para las mismas pantallas; solo inventan keys para lo propio de su plataforma.',
|
|
77
|
+
'Carga con upsert_design_nav en lotes de hasta 40 nodos (el padre puede ir en el mismo lote).',
|
|
78
|
+
'Verifica con get_design_status (navNodes > 0).',
|
|
79
|
+
],
|
|
80
|
+
doneWhen: 'navNodes > 0 y todas las pantallas del router/tab bar están cargadas con keys alineadas a la web.',
|
|
81
|
+
pitfalls: ['Keys nuevas en iOS/Android para pantallas que ya existen en web.', 'Pantallas sin parentKey (quedan en la raíz).', 'Lotes de más de 40 nodos.'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
order: 4, id: 'blocks', title: 'Bloques de cada pantalla',
|
|
85
|
+
goal: 'Describir cada pantalla (kind screen/tab) como bloques de arriba a abajo.',
|
|
86
|
+
tools: ['upsert_design_nav', 'update_design_nav_node'],
|
|
87
|
+
how: [
|
|
88
|
+
'Abre el código de cada pantalla y lista sus bloques en orden visual, de arriba a abajo.',
|
|
89
|
+
'Usa los nombres del glosario (glossary.blocks) cuando el bloque sea genérico: Encabezado, Lista, Filtros, Estado vacío, etc. Para algo propio, un nombre corto y descriptivo.',
|
|
90
|
+
'Cada bloque lleva name y, opcional, description corta.',
|
|
91
|
+
'Actualiza con upsert_design_nav (misma platform+key): no se duplican nodos.',
|
|
92
|
+
],
|
|
93
|
+
doneWhen: 'Al menos 50% de las pantallas tienen bloques (idealmente todas) según get_design_status.',
|
|
94
|
+
pitfalls: ['Describir el diseño ideal en vez del código real.', 'Nombres de bloque distintos para lo mismo entre plataformas.'],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
order: 5, id: 'actions-states', title: 'Acciones y estados por pantalla',
|
|
98
|
+
goal: 'Registrar qué puede hacer el usuario en cada pantalla y qué estados maneja; es lo que compara la paridad.',
|
|
99
|
+
tools: ['upsert_design_nav', 'update_design_nav_node'],
|
|
100
|
+
how: [
|
|
101
|
+
'Acciones con key entidad.verbo (ej. task.create) y verbos estándar: create, edit, delete, filter, search, sort, export, share, assign, comment, convert, archive, restore, open-detail, reorder, import, change-view.',
|
|
102
|
+
'Estados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty. Solo los que existan en el código de esa pantalla.',
|
|
103
|
+
'Misma key para la misma operación en las tres plataformas.',
|
|
104
|
+
'upsert_design_nav MEZCLA actions/states por key; no repitas los ya cargados innecesariamente.',
|
|
105
|
+
],
|
|
106
|
+
doneWhen: 'Al menos 50% de las pantallas tienen acciones (y estados donde aplique) según get_design_status.',
|
|
107
|
+
pitfalls: ['Registrar estados que la pantalla no maneja.', 'Verbos distintos para la misma operación (delete vs remove).', 'Usar update_design_nav_node y reemplazar sin querer el array completo.'],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
order: 6, id: 'components', title: 'Catálogo de componentes',
|
|
111
|
+
goal: 'Catalogar los componentes de UI reales con su referencia en código por plataforma.',
|
|
112
|
+
tools: ['list_design_components', 'upsert_design_components', 'update_design_component'],
|
|
113
|
+
how: [
|
|
114
|
+
'Usa nombres canónicos: Button, Card, Modal, Drawer, Input, Select, Checkbox, Toggle, Badge, Chip, Tabs, SegmentedControl, ListRow, EmptyState, Toast, Banner, Avatar, DropdownMenu, ProgressBar, Tooltip, Topbar, Sidebar, SearchBar, ConfirmDialog, DatePicker.',
|
|
115
|
+
'platforms[] con la platform de esta pasada y su sourceRef (archivo y símbolo).',
|
|
116
|
+
'tokens y usedIn SOLO con nombres de tokens y keys de nav que ya existen (list_design_tokens, list_design_nav).',
|
|
117
|
+
'Carga con upsert_design_components; revisa `warnings` y corrige.',
|
|
118
|
+
'UN agente a la vez por catálogo: cargar el mismo name desde otra plataforma mezcla, pero en paralelo se pisan.',
|
|
119
|
+
],
|
|
120
|
+
doneWhen: 'components > 0 para la plataforma y `warnings` vacío.',
|
|
121
|
+
pitfalls: ['Inventar tokens o usedIn que no existen.', 'Dos agentes cargando componentes al mismo tiempo.', 'Nombres distintos al canónico para el mismo componente.'],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
order: 7, id: 'parity', title: 'Paridad entre plataformas',
|
|
125
|
+
goal: 'Revisar huecos entre web, iOS y Android y fijar los que no aplican.',
|
|
126
|
+
tools: ['get_design_parity', 'set_design_parity_cell', 'create_parity_tasks'],
|
|
127
|
+
how: [
|
|
128
|
+
'Llama get_design_parity con soloHuecos:true.',
|
|
129
|
+
'Contenedores propios de cada plataforma (sidebar, tabbar, drawer, toolbar, grupos) se fijan not_applicable con set_design_parity_cell y un `reason` (obligatorio).',
|
|
130
|
+
'Los huecos reales (different/missing) NO se corrigen aquí: preséntalos al usuario. Solo con su confirmación conviértelos en tareas con create_parity_tasks (máx. 50 por llamada).',
|
|
131
|
+
],
|
|
132
|
+
doneWhen: 'Los huecos que quedan son reales y el usuario decidió qué hacer con ellos.',
|
|
133
|
+
pitfalls: ['Crear tareas sin confirmar.', 'Marcar not_applicable sin motivo (la API lo rechaza).', 'Marcar not_applicable un hueco real para que "salga verde".'],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
order: 8, id: 'drift', title: 'Deriva de tokens',
|
|
137
|
+
goal: 'Detectar valores de diseño escritos a mano fuera de los tokens.',
|
|
138
|
+
tools: ['report_design_drift', 'list_design_drift', 'update_design_drift', 'resolve_missing_design_drift'],
|
|
139
|
+
how: [
|
|
140
|
+
'Guarda la hora de inicio de la pasada (Date.now()).',
|
|
141
|
+
'Recorre el código de UNA plataforma buscando hex, radios, spacings y fuentes sueltos; ignora el archivo de definición del tema, tests, previews y valores dinámicos.',
|
|
142
|
+
'Compara cada valor con los tokens de esa misma plataforma y propón suggestedToken si coincide.',
|
|
143
|
+
'Reporta en lote con report_design_drift (hasta 200 por llamada).',
|
|
144
|
+
'Solo tras una pasada COMPLETA, resolve_missing_design_drift en dryRun (platform + reportedBefore = hora de inicio); si es correcto, repite con dryRun:false.',
|
|
145
|
+
],
|
|
146
|
+
doneWhen: 'La pasada completa de la plataforma está reportada y los hallazgos resueltos ya no aparecen.',
|
|
147
|
+
pitfalls: ['Resolver sin haber terminado la pasada completa.', 'Reportar el archivo del tema como deriva.', 'Mezclar plataformas en una misma pasada.'],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
order: 9, id: 'sync', title: 'Sincronía con el código (mantenimiento)',
|
|
151
|
+
goal: 'Detectar qué cambió en el código desde la última carga de Diseño / UI y mantener el catálogo al día.',
|
|
152
|
+
tools: ['check_design_sync', 'mark_design_synced', 'upsert_design_nav', 'upsert_design_components'],
|
|
153
|
+
how: [
|
|
154
|
+
'Línea base: tras una carga completa de la plataforma, corre mark_design_synced con all:true (platform + repoPath absoluto del repo) para guardar la huella de cada archivo citado en sourceRef.',
|
|
155
|
+
'Después, corre check_design_sync periódicamente o tras merges: compara la huella local con la guardada y marca como outdated lo que cambió.',
|
|
156
|
+
'Los `changed` se vuelven a leer en el código y se actualizan con upsert_design_nav / upsert_design_components.',
|
|
157
|
+
'Luego mark_design_synced SOLO de esos (nav: keys, components: nombres) para sellar la nueva huella y limpiar outdated.',
|
|
158
|
+
'Nada se borra solo: un archivo que ya no existe aparece como no-existe y lo decide el usuario.',
|
|
159
|
+
],
|
|
160
|
+
doneWhen: 'check_design_sync no reporta `changed` ni `baselineMissing` para la plataforma.',
|
|
161
|
+
pitfalls: ['Usar mark_design_synced sin haber revisado lo que cambió (oculta el cambio).', 'Pasar el repoPath de otra plataforma.', 'Borrar nodos porque su archivo no existe sin confirmar con el usuario.'],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
order: 10, id: 'screenshots', title: 'Capturas (opcional)',
|
|
165
|
+
goal: 'Adjuntar capturas a las pantallas clave.',
|
|
166
|
+
tools: ['add_design_screenshot', 'remove_design_screenshot'],
|
|
167
|
+
how: [
|
|
168
|
+
'Para cada pantalla clave: add_design_screenshot con platform, key del nodo y la ruta del archivo (png/jpg/webp ≤5 MB).',
|
|
169
|
+
'Para marketing, nunca captures el workspace real: usa un usuario demo con datos poblados.',
|
|
170
|
+
],
|
|
171
|
+
doneWhen: 'Las pantallas clave tienen captura, o el usuario decide omitir este paso.',
|
|
172
|
+
pitfalls: ['Capturas con datos reales de clientes.', 'Archivos fuera de png/jpg/webp o mayores a 5 MB.'],
|
|
173
|
+
},
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
const PLATFORM_LABEL = { web: 'web (React)', ios: 'iOS (Swift)', android: 'Android (Kotlin)' }
|
|
177
|
+
|
|
178
|
+
function adjustStep(step, platform) {
|
|
179
|
+
const s = { ...step, how: [...step.how], pitfalls: [...step.pitfalls], tools: [...step.tools] }
|
|
180
|
+
if (step.id === 'navigation' && platform !== 'web') {
|
|
181
|
+
s.how.unshift('PRIMERO lee list_design_nav platform=web (tree o plano) y reutiliza sus keys para las mismas pantallas.')
|
|
182
|
+
}
|
|
183
|
+
if (step.id === 'navigation' && platform === 'web') {
|
|
184
|
+
s.how.unshift('Eres la referencia: define keys estables y claras (iOS y Android las reutilizarán).')
|
|
185
|
+
}
|
|
186
|
+
if (step.id === 'drift' || step.id === 'identity' || step.id === 'components') {
|
|
187
|
+
s.how.push(`Trabaja SOLO platform=${platform} en esta pasada.`)
|
|
188
|
+
}
|
|
189
|
+
return s
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildAgentPrompt(platform, steps) {
|
|
193
|
+
const ids = steps.map(s => `${s.order}. ${s.id}`).join('; ')
|
|
194
|
+
const webNote = platform === 'web'
|
|
195
|
+
? 'La web es la referencia: define keys y nombres estables, porque iOS y Android los reutilizarán.'
|
|
196
|
+
: 'Antes de crear nodos lee list_design_nav platform=web y reutiliza sus keys; la web es la referencia.'
|
|
197
|
+
return [
|
|
198
|
+
`Vas a llenar el módulo Diseño / UI de Orquestra para la plataforma ${PLATFORM_LABEL[platform]}, leyendo su código real. Trabaja SOLO esta plataforma y no inventes nada que no esté en el código.`,
|
|
199
|
+
webNote,
|
|
200
|
+
`Pasos en orden (ids de get_design_flow): ${ids}.`,
|
|
201
|
+
'Empieza llamando get_design_status: su campo `next` te dice el primer paso pendiente de tu plataforma. Para cada paso llama get_design_flow con step=<id> y sigue sus `how`, usando los `tools` indicados.',
|
|
202
|
+
'Reglas: llama list_* antes de crear para no duplicar; lotes compactos (tokens ≤100, nodos ≤40, componentes ≤200); revisa los `warnings`; no borres nodos, actualízalos; no crees tareas sin confirmar con el usuario; un paso termina cuando get_design_status lo confirma.',
|
|
203
|
+
'Al terminar cada paso vuelve a llamar get_design_status y avanza al nuevo `next`. Cuando todo esté cargado, resume al usuario los huecos de paridad y la deriva abierta y pregúntale qué hacer.',
|
|
204
|
+
].join('\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const DESIGN_PLATFORM_ARGS = ['web', 'ios', 'android', 'all']
|
|
208
|
+
export const STEP_IDS = STEPS.map(s => s.id)
|
|
209
|
+
|
|
210
|
+
// Devuelve { error } si el step no existe (la tool lo convierte en un 404 amable).
|
|
211
|
+
export function getDesignFlow({ platform = 'all', step } = {}) {
|
|
212
|
+
const adjust = platform === 'all' ? s => s : s => adjustStep(s, platform)
|
|
213
|
+
const base = { version: 1, platform, principles: PRINCIPLES, glossary: GLOSSARY }
|
|
214
|
+
if (step) {
|
|
215
|
+
const found = STEPS.find(s => s.id === step)
|
|
216
|
+
if (!found) return { error: `Error (404): no existe el paso "${step}". Pasos disponibles: ${STEP_IDS.join(', ')}.` }
|
|
217
|
+
return { ...base, steps: [adjust(found)] }
|
|
218
|
+
}
|
|
219
|
+
const steps = STEPS.map(adjust)
|
|
220
|
+
const out = { ...base, steps }
|
|
221
|
+
if (platform !== 'all') out.agentPrompt = buildAgentPrompt(platform, steps)
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── get_design_status: lógica pura sobre las respuestas de la API ──────────────
|
|
226
|
+
const arr = r => (Array.isArray(r) ? r : Array.isArray(r?.items) ? r.items : [])
|
|
227
|
+
const nonEmpty = a => Array.isArray(a) && a.length > 0
|
|
228
|
+
const isScreen = n => n?.kind === 'screen' || n?.kind === 'tab'
|
|
229
|
+
|
|
230
|
+
function pickParity(parity, platform) {
|
|
231
|
+
const s = parity?.summary
|
|
232
|
+
const row = s?.[platform] || s?.byPlatform?.[platform]
|
|
233
|
+
if (!row || typeof row !== 'object') return null
|
|
234
|
+
const n = k => Number(row[k] ?? row[k.replace('_', '')] ?? 0) || 0
|
|
235
|
+
return { same: n('same'), different: n('different'), missing: n('missing'), not_applicable: n('not_applicable') }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function nextStep(p) {
|
|
239
|
+
if (!p.tokens) return { next: 'identity', reason: 'No hay design tokens cargados para esta plataforma.' }
|
|
240
|
+
if (!p.navNodes) return { next: 'navigation', reason: 'No hay nodos de navegación cargados para esta plataforma.' }
|
|
241
|
+
const screens = p.screens
|
|
242
|
+
if (screens && p.screensWithBlocks < screens / 2) return { next: 'blocks', reason: `Solo ${p.screensWithBlocks} de ${screens} pantallas tienen bloques (meta: al menos 50%).` }
|
|
243
|
+
if (screens && p.screensWithActions < screens / 2) return { next: 'actions-states', reason: `Solo ${p.screensWithActions} de ${screens} pantallas tienen acciones (meta: al menos 50%).` }
|
|
244
|
+
if (!p.components) return { next: 'components', reason: 'No hay componentes catalogados para esta plataforma.' }
|
|
245
|
+
const par = p.parity
|
|
246
|
+
if (par && (par.different + par.missing) > 0) return { next: 'parity', reason: `Hay ${par.different + par.missing} huecos de paridad (different/missing) por revisar.` }
|
|
247
|
+
if (p.driftOpen > 0) return { next: 'drift', reason: `Hay ${p.driftOpen} hallazgos de deriva abiertos.` }
|
|
248
|
+
return { next: 'parity', reason: 'Lo básico está cargado; revisa la paridad y, si quieres, corre la pasada de deriva (drift).' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// results: { tokens, nav: {web,ios,android}, components, drift, parity } — cada uno puede ser { error }
|
|
252
|
+
export function computeDesignStatus(results) {
|
|
253
|
+
const errors = []
|
|
254
|
+
const ok = (key, v) => { if (v?.__error) { errors.push({ source: key, error: v.__error }); return null } return v }
|
|
255
|
+
const tokens = arr(ok('tokens', results.tokens))
|
|
256
|
+
const components = arr(ok('components', results.components))
|
|
257
|
+
const driftRaw = ok('drift', results.drift)
|
|
258
|
+
const drift = arr(driftRaw)
|
|
259
|
+
const parity = ok('parity', results.parity)
|
|
260
|
+
const syncRes = {}
|
|
261
|
+
for (const pl of DESIGN_PLATFORMS) syncRes[pl] = ok(`sync:${pl}`, results.sync?.[pl])
|
|
262
|
+
const platforms = {}
|
|
263
|
+
for (const pl of DESIGN_PLATFORMS) {
|
|
264
|
+
const nav = arr(ok(`nav:${pl}`, results.nav?.[pl]))
|
|
265
|
+
const screens = nav.filter(isScreen)
|
|
266
|
+
const p = {
|
|
267
|
+
tokens: tokens.filter(t => (t.platform || 'all') === pl || t.platform === 'all' || !t.platform).length,
|
|
268
|
+
navNodes: nav.length,
|
|
269
|
+
screens: screens.length,
|
|
270
|
+
screensWithBlocks: screens.filter(n => nonEmpty(n.blocks)).length,
|
|
271
|
+
screensWithActions: screens.filter(n => nonEmpty(n.actions)).length,
|
|
272
|
+
screensWithStates: screens.filter(n => nonEmpty(n.states)).length,
|
|
273
|
+
components: components.filter(c => arr(c.platforms).some(x => x?.platform === pl)).length,
|
|
274
|
+
driftOpen: drift.filter(d => d.platform === pl && (d.status || 'open') === 'open').length,
|
|
275
|
+
parity: pickParity(parity, pl),
|
|
276
|
+
}
|
|
277
|
+
if (!drift.length && driftRaw?.summary?.byPlatform?.[pl] != null) {
|
|
278
|
+
const bp = driftRaw.summary.byPlatform[pl]
|
|
279
|
+
p.driftOpen = typeof bp === 'number' ? bp : Number(bp?.open ?? 0)
|
|
280
|
+
}
|
|
281
|
+
const sy = syncRes[pl]
|
|
282
|
+
if (sy) {
|
|
283
|
+
const sum = k => Number(sy.summary?.nav?.[k] ?? 0) + Number(sy.summary?.components?.[k] ?? 0)
|
|
284
|
+
p.sync = { withHash: sum('withHash'), outdated: sum('outdated'), stale: sum('stale') }
|
|
285
|
+
}
|
|
286
|
+
Object.assign(p, nextStep(p))
|
|
287
|
+
if (p.sync?.outdated > 0) {
|
|
288
|
+
p.next = 'sync'
|
|
289
|
+
p.reason = `Hay ${p.sync.outdated} elementos marcados como desactualizados respecto al código; revísalos con check_design_sync.`
|
|
290
|
+
}
|
|
291
|
+
platforms[pl] = p
|
|
292
|
+
}
|
|
293
|
+
const out = { platforms, parity: { capabilitiesNodes: parity?.capabilitiesNodes ?? null } }
|
|
294
|
+
if (errors.length) out.errors = errors
|
|
295
|
+
return out
|
|
296
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ORQ-575: detección de cambios del código desde la última carga de Diseño / UI.
|
|
2
|
+
// Lógica pura y testeable: huellas locales (sha1 de los bytes) de los archivos que citan los
|
|
3
|
+
// sourceRef, comparadas con el sourceHash guardado en Orquestra. Las funciones reciben la API
|
|
4
|
+
// por parámetro (io) para poder probarlas sin red.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
export const MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
10
|
+
export const BATCH_SIZE = 40
|
|
11
|
+
export const LIST_CAP = 100
|
|
12
|
+
|
|
13
|
+
const PATH_RE_ORDERED = /[\w@.\-/]+\.(?:json|jsx|js|tsx|ts|mjs|cjs|css|swift|kts|kt|xml|md)(?![\w])/
|
|
14
|
+
|
|
15
|
+
// Primer fragmento con pinta de ruta de archivo dentro de un sourceRef, o null.
|
|
16
|
+
export function extractSourcePath(sourceRef) {
|
|
17
|
+
if (typeof sourceRef !== 'string') return null
|
|
18
|
+
const m = sourceRef.match(PATH_RE_ORDERED)
|
|
19
|
+
return m ? m[0] : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// → { status: 'ok', hash } | { status: 'no-existe' | 'fuera-del-repo' | 'demasiado-grande' }
|
|
23
|
+
export function hashFile(repoPath, relPath) {
|
|
24
|
+
let repoReal
|
|
25
|
+
try { repoReal = realpathSync(repoPath) } catch { return { status: 'no-existe' } }
|
|
26
|
+
const inside = p => p === repoReal || p.startsWith(repoReal + path.sep)
|
|
27
|
+
const lexical = path.resolve(repoReal, relPath)
|
|
28
|
+
if (!inside(lexical)) return { status: 'fuera-del-repo' }
|
|
29
|
+
let real
|
|
30
|
+
try { real = realpathSync(lexical) } catch { return { status: 'no-existe' } }
|
|
31
|
+
if (!inside(real)) return { status: 'fuera-del-repo' }
|
|
32
|
+
let st
|
|
33
|
+
try { st = statSync(real) } catch { return { status: 'no-existe' } }
|
|
34
|
+
if (!st.isFile()) return { status: 'no-existe' }
|
|
35
|
+
if (st.size > MAX_FILE_BYTES) return { status: 'demasiado-grande' }
|
|
36
|
+
try {
|
|
37
|
+
return { status: 'ok', hash: createHash('sha1').update(readFileSync(real)).digest('hex') }
|
|
38
|
+
} catch { return { status: 'no-existe' } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const chunk = (a, n) => { const out = []; for (let i = 0; i < a.length; i += n) out.push(a.slice(i, i + n)); return out }
|
|
42
|
+
const arr = v => (Array.isArray(v) ? v : [])
|
|
43
|
+
|
|
44
|
+
const STATUS_BUCKET = {
|
|
45
|
+
unchanged: 'unchanged', changed: 'changed', 'baseline-missing': 'baselineMissing',
|
|
46
|
+
'no-existe': 'missing', 'fuera-del-repo': 'outsideRepo', 'demasiado-grande': 'tooBig', 'sin-ruta': 'noPath',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Clasifica un item de /v1/design-sync contra el repo local. cache: Map ruta → resultado de hashFile.
|
|
50
|
+
export function classifyItem(item, repoPath, cache = new Map()) {
|
|
51
|
+
const rel = extractSourcePath(item?.sourceRef)
|
|
52
|
+
if (!rel) return { state: 'sin-ruta', path: null }
|
|
53
|
+
if (!cache.has(rel)) cache.set(rel, hashFile(repoPath, rel))
|
|
54
|
+
const h = cache.get(rel)
|
|
55
|
+
if (h.status !== 'ok') return { state: h.status, path: rel }
|
|
56
|
+
if (!item.sourceHash) return { state: 'baseline-missing', path: rel, hash: h.hash }
|
|
57
|
+
return { state: h.hash === item.sourceHash ? 'unchanged' : 'changed', path: rel, hash: h.hash }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emptySummary() {
|
|
61
|
+
return { unchanged: 0, changed: 0, baselineMissing: 0, missing: 0, outsideRepo: 0, tooBig: 0, noPath: 0 }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// io: { fetchSync(), fetchCategories(), writeNav(nodes), writeComponents(components) }
|
|
65
|
+
export async function runCheck({ platform, repoPath, record = true }, io) {
|
|
66
|
+
const data = await io.fetchSync()
|
|
67
|
+
const nav = arr(data?.nav)
|
|
68
|
+
const components = arr(data?.components)
|
|
69
|
+
const cache = new Map()
|
|
70
|
+
const summary = { nav: emptySummary(), components: emptySummary() }
|
|
71
|
+
const changed = []
|
|
72
|
+
const toMark = { nav: [], components: [] }
|
|
73
|
+
for (const [kind, list] of [['nav', nav], ['component', components]]) {
|
|
74
|
+
for (const it of list) {
|
|
75
|
+
const c = classifyItem(it, repoPath, cache)
|
|
76
|
+
summary[kind === 'nav' ? 'nav' : 'components'][STATUS_BUCKET[c.state]]++
|
|
77
|
+
if (c.state === 'changed') {
|
|
78
|
+
changed.push({ kind, ...(kind === 'nav' ? { key: it.key } : { name: it.name }), path: c.path })
|
|
79
|
+
if (!it.outdated) toMark[kind === 'nav' ? 'nav' : 'components'].push(it)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const out = { platform, repoPath, summary }
|
|
84
|
+
out.changed = changed.slice(0, LIST_CAP)
|
|
85
|
+
if (changed.length > LIST_CAP) out.changedTruncated = changed.length - LIST_CAP
|
|
86
|
+
const stale = [...nav, ...components].filter(i => i.stale).map(i => i.name || i.key)
|
|
87
|
+
out.stale = stale.slice(0, LIST_CAP)
|
|
88
|
+
if (stale.length > LIST_CAP) out.staleTruncated = stale.length - LIST_CAP
|
|
89
|
+
if (record !== false) {
|
|
90
|
+
let navMarked = 0, compMarked = 0
|
|
91
|
+
for (const part of chunk(toMark.nav, BATCH_SIZE)) {
|
|
92
|
+
await io.writeNav(part.map(n => ({ platform, kind: n.kind, name: n.name, key: n.key, outdated: true })))
|
|
93
|
+
navMarked += part.length
|
|
94
|
+
}
|
|
95
|
+
if (toMark.components.length) {
|
|
96
|
+
const cats = await io.fetchCategories()
|
|
97
|
+
for (const part of chunk(toMark.components, BATCH_SIZE)) {
|
|
98
|
+
await io.writeComponents(part.map(c => ({ name: c.name, category: cats.get(c.name) || 'other', platforms: [{ platform, outdated: true }] })))
|
|
99
|
+
compMarked += part.length
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
out.markedOutdated = { nav: navMarked, components: compMarked }
|
|
103
|
+
} else {
|
|
104
|
+
out.markedOutdated = { nav: 0, components: 0, note: 'record:false, no se escribió nada' }
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runMark({ platform, repoPath, nav, components, all }, io) {
|
|
110
|
+
const navKeys = arr(nav), compNames = arr(components)
|
|
111
|
+
if (!all && !navKeys.length && !compNames.length) {
|
|
112
|
+
throw new Error('Indica qué marcar: all:true (línea base de todo lo que tenga sourceRef), nav (keys) o components (nombres).')
|
|
113
|
+
}
|
|
114
|
+
const data = await io.fetchSync()
|
|
115
|
+
const navItems = arr(data?.nav), compItems = arr(data?.components)
|
|
116
|
+
const unresolved = []
|
|
117
|
+
const pick = (items, wanted, idOf, kind) => {
|
|
118
|
+
if (all) return items.filter(i => extractSourcePath(i.sourceRef) || (unresolved.push({ kind, [idOf]: i[idOf], reason: 'sin-ruta' }), false))
|
|
119
|
+
const out = []
|
|
120
|
+
for (const w of wanted) {
|
|
121
|
+
const it = items.find(i => i[idOf] === w)
|
|
122
|
+
if (!it) unresolved.push({ kind, [idOf]: w, reason: 'no-encontrado' })
|
|
123
|
+
else out.push(it)
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
const navSel = pick(navItems, navKeys, 'key', 'nav')
|
|
128
|
+
const compSel = pick(compItems, compNames, 'name', 'component')
|
|
129
|
+
const cache = new Map()
|
|
130
|
+
const resolve = (it, kind, idOf) => {
|
|
131
|
+
const c = classifyItem(it, repoPath, cache)
|
|
132
|
+
if (!c.hash) { unresolved.push({ kind, [idOf]: it[idOf], reason: c.state }); return null }
|
|
133
|
+
return c.hash
|
|
134
|
+
}
|
|
135
|
+
const navW = [], compW = []
|
|
136
|
+
for (const it of navSel) { const h = resolve(it, 'nav', 'key'); if (h) navW.push({ platform, kind: it.kind, name: it.name, key: it.key, sourceHash: h }) }
|
|
137
|
+
const compPairs = []
|
|
138
|
+
for (const it of compSel) { const h = resolve(it, 'component', 'name'); if (h) compPairs.push({ name: it.name, h }) }
|
|
139
|
+
for (const part of chunk(navW, BATCH_SIZE)) await io.writeNav(part)
|
|
140
|
+
if (compPairs.length) {
|
|
141
|
+
const cats = await io.fetchCategories()
|
|
142
|
+
for (const p of compPairs) compW.push({ name: p.name, category: cats.get(p.name) || 'other', platforms: [{ platform, sourceHash: p.h }] })
|
|
143
|
+
for (const part of chunk(compW, BATCH_SIZE)) await io.writeComponents(part)
|
|
144
|
+
}
|
|
145
|
+
const out = { platform, repoPath, marked: { nav: navW.length, components: compW.length }, unresolved: unresolved.slice(0, LIST_CAP) }
|
|
146
|
+
if (unresolved.length > LIST_CAP) out.unresolvedTruncated = unresolved.length - LIST_CAP
|
|
147
|
+
return out
|
|
148
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { SKILLS_CATALOG } from './skillsCatalog.mjs'
|
|
|
13
13
|
import { parseSource, previewSkill, installSkill } from './skillInstall.mjs'
|
|
14
14
|
import { normalizeStatuses, findClosePath } from './closePath.mjs'
|
|
15
15
|
import { TOOL_GUIDES, GUIDE_TOPICS } from './toolGuides.mjs'
|
|
16
|
+
import { runCheck, runMark } from './designSync.mjs'
|
|
17
|
+
import { getDesignFlow, computeDesignStatus, DESIGN_PLATFORM_ARGS, DESIGN_PLATFORMS } from './designFlow.mjs'
|
|
16
18
|
|
|
17
19
|
// Versión leída de package.json (3 sep 2026) -- antes estaba hardcodeada
|
|
18
20
|
// aquí y quedaba atrás en cada bump (decía 1.16.0 con package.json en 1.16.2).
|
|
@@ -3275,6 +3277,111 @@ export function createServer() {
|
|
|
3275
3277
|
)
|
|
3276
3278
|
|
|
3277
3279
|
// ═══ PARIDAD DE DISEÑO (ORQ-563) ═══════════════════════════════════════════
|
|
3280
|
+
|
|
3281
|
+
// ═══ FLUJO GUIADO DE DISEÑO (ORQ-564) ══════════════════════════════════════
|
|
3282
|
+
server.registerTool(
|
|
3283
|
+
'get_design_flow',
|
|
3284
|
+
{
|
|
3285
|
+
description: 'Guía paso a paso para llenar Diseño / UI (tokens, navegación, bloques, acciones, componentes, paridad, deriva). Datos estáticos, sin red. Con platform devuelve además un agentPrompt listo para pegar; con step, solo ese paso. Empieza por get_design_status.',
|
|
3286
|
+
inputSchema: {
|
|
3287
|
+
platform: z.enum(DESIGN_PLATFORM_ARGS).optional().describe('web | ios | android | all (default all)'),
|
|
3288
|
+
step: z.string().optional().describe('Id de un paso (check, identity, navigation, blocks, actions-states, components, parity, drift, screenshots)'),
|
|
3289
|
+
},
|
|
3290
|
+
},
|
|
3291
|
+
async ({ platform, step }) => {
|
|
3292
|
+
const data = getDesignFlow({ platform, step })
|
|
3293
|
+
if (data.error) return { content: [{ type: 'text', text: data.error }], isError: true }
|
|
3294
|
+
return textResult(data)
|
|
3295
|
+
}
|
|
3296
|
+
)
|
|
3297
|
+
|
|
3298
|
+
server.registerTool(
|
|
3299
|
+
'get_design_status',
|
|
3300
|
+
{
|
|
3301
|
+
description: 'Estado de Diseño / UI por plataforma (web, ios, android): tokens, nodos de navegación, pantallas con bloques/acciones/estados, componentes, deriva abierta, sync con el código y resumen de paridad, más `next`: el primer paso de get_design_flow pendiente. Solo números, sin listas.',
|
|
3302
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
|
|
3303
|
+
},
|
|
3304
|
+
async ({ projectId }) => {
|
|
3305
|
+
try {
|
|
3306
|
+
const pid = resolveProjectId(projectId)
|
|
3307
|
+
const safe = p => p.catch(e => ({ __error: e instanceof ApiError ? `HTTP ${e.status}: ${e.message}` : e.message }))
|
|
3308
|
+
const [tokens, components, drift, parity, ...rest] = await Promise.all([
|
|
3309
|
+
safe(apiGet(config, '/v1/design-tokens', { projectId: pid })),
|
|
3310
|
+
safe(apiGet(config, '/v1/design-components', { projectId: pid })),
|
|
3311
|
+
safe(apiGet(config, '/v1/design-drift', { projectId: pid })),
|
|
3312
|
+
safe(apiGet(config, '/v1/design-parity', { projectId: pid })),
|
|
3313
|
+
...DESIGN_PLATFORMS.map(platform => safe(apiGet(config, '/v1/design-nav', { projectId: pid, platform }))),
|
|
3314
|
+
...DESIGN_PLATFORMS.map(platform => safe(apiGet(config, '/v1/design-sync', { projectId: pid, platform }))),
|
|
3315
|
+
])
|
|
3316
|
+
const n = DESIGN_PLATFORMS.length
|
|
3317
|
+
const nav = Object.fromEntries(DESIGN_PLATFORMS.map((pl, i) => [pl, rest[i]]))
|
|
3318
|
+
const sync = Object.fromEntries(DESIGN_PLATFORMS.map((pl, i) => [pl, rest[n + i]]))
|
|
3319
|
+
return textResult(computeDesignStatus({ tokens, components, drift, parity, nav, sync }))
|
|
3320
|
+
} catch (err) {
|
|
3321
|
+
return errorResult(err)
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
)
|
|
3325
|
+
|
|
3326
|
+
// ═══ SINCRONÍA CON EL CÓDIGO (ORQ-575) ═════════════════════════════════════
|
|
3327
|
+
const designSyncIo = (pid, platform, staleDays) => ({
|
|
3328
|
+
fetchSync: () => apiGet(config, '/v1/design-sync', { projectId: pid, platform, staleDays }),
|
|
3329
|
+
fetchCategories: async () => {
|
|
3330
|
+
const r = await apiGet(config, '/v1/design-components', { projectId: pid })
|
|
3331
|
+
const list = Array.isArray(r) ? r : Array.isArray(r?.items) ? r.items : []
|
|
3332
|
+
return new Map(list.filter(c => c?.name && c.category).map(c => [c.name, c.category]))
|
|
3333
|
+
},
|
|
3334
|
+
writeNav: nodes => apiPost(config, '/v1/design-nav/bulk', { projectId: pid, nodes, compact: true }),
|
|
3335
|
+
writeComponents: components => apiPost(config, '/v1/design-components/bulk', { projectId: pid, components, compact: true }),
|
|
3336
|
+
})
|
|
3337
|
+
const SYNC_PLATFORM = z.enum(['web', 'ios', 'android']).describe('web | ios | android')
|
|
3338
|
+
const SYNC_REPO = z.string().min(1).describe('Ruta absoluta del repo de esa plataforma en esta máquina')
|
|
3339
|
+
|
|
3340
|
+
server.registerTool(
|
|
3341
|
+
'check_design_sync',
|
|
3342
|
+
{
|
|
3343
|
+
description: 'Compara el código local con lo cargado en Diseño / UI (sourceRef + huella) y reporta qué cambió; con record (default true) marca lo cambiado como outdated. Solo lee archivos dentro de repoPath. Devuelve conteos y listas cortas.',
|
|
3344
|
+
inputSchema: {
|
|
3345
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3346
|
+
platform: SYNC_PLATFORM,
|
|
3347
|
+
repoPath: SYNC_REPO,
|
|
3348
|
+
staleDays: z.number().int().min(1).max(365).optional().describe('Días sin sincronizar para considerar stale (1-365)'),
|
|
3349
|
+
record: z.boolean().optional().describe('false = solo reporta, no marca outdated (default true)'),
|
|
3350
|
+
},
|
|
3351
|
+
},
|
|
3352
|
+
async ({ projectId, platform, repoPath, staleDays, record }) => {
|
|
3353
|
+
try {
|
|
3354
|
+
if (!path.isAbsolute(repoPath)) throw new Error('repoPath debe ser una ruta absoluta')
|
|
3355
|
+
return textResult(await runCheck({ platform, repoPath, record }, designSyncIo(resolveProjectId(projectId), platform, staleDays)))
|
|
3356
|
+
} catch (err) {
|
|
3357
|
+
return errorResult(err)
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
)
|
|
3361
|
+
|
|
3362
|
+
server.registerTool(
|
|
3363
|
+
'mark_design_synced',
|
|
3364
|
+
{
|
|
3365
|
+
description: 'Guarda la huella actual del código como sincronizada (limpia outdated). Úsala solo después de haber actualizado/revisado en Orquestra lo que cambió, o para fijar la línea base tras una carga completa (all:true).',
|
|
3366
|
+
inputSchema: {
|
|
3367
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3368
|
+
platform: SYNC_PLATFORM,
|
|
3369
|
+
repoPath: SYNC_REPO,
|
|
3370
|
+
nav: z.array(z.string()).optional().describe('Keys de nodos de navegación a marcar'),
|
|
3371
|
+
components: z.array(z.string()).optional().describe('Nombres de componentes a marcar'),
|
|
3372
|
+
all: z.boolean().optional().describe('true = línea base de todo lo que tenga sourceRef en la plataforma'),
|
|
3373
|
+
},
|
|
3374
|
+
},
|
|
3375
|
+
async ({ projectId, platform, repoPath, nav, components, all }) => {
|
|
3376
|
+
try {
|
|
3377
|
+
if (!path.isAbsolute(repoPath)) throw new Error('repoPath debe ser una ruta absoluta')
|
|
3378
|
+
return textResult(await runMark({ platform, repoPath, nav, components, all }, designSyncIo(resolveProjectId(projectId), platform)))
|
|
3379
|
+
} catch (err) {
|
|
3380
|
+
return errorResult(err)
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
)
|
|
3384
|
+
|
|
3278
3385
|
const PARITY_PLATFORMS = ['web', 'ios', 'android']
|
|
3279
3386
|
server.registerTool(
|
|
3280
3387
|
'get_design_parity',
|