orquestra-mcp 1.70.0 → 1.74.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/api.mjs +2 -2
- package/src/server.mjs +253 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.74.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": {
|
package/src/api.mjs
CHANGED
|
@@ -115,9 +115,9 @@ export async function apiDeletePath(config, path, { projectId, ...query }) {
|
|
|
115
115
|
// es la autorización completa. Plazo más largo (sube bytes); un PUT a la
|
|
116
116
|
// misma key es idempotente, pero igual no se reintenta para no duplicar
|
|
117
117
|
// tiempos largos de subida.
|
|
118
|
-
export async function putToPresignedUrl(uploadUrl, buffer, contentType) {
|
|
118
|
+
export async function putToPresignedUrl(uploadUrl, buffer, contentType, headers) {
|
|
119
119
|
try {
|
|
120
|
-
await request(uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: buffer }, { timeoutMs: 120_000, retries: 0 })
|
|
120
|
+
await request(uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType, ...(headers || {}) }, body: buffer }, { timeoutMs: 120_000, retries: 0 })
|
|
121
121
|
} catch (err) {
|
|
122
122
|
throw new Error(`Falló la subida a S3: ${err.status ? `HTTP ${err.status}` : err.message}`)
|
|
123
123
|
}
|
package/src/server.mjs
CHANGED
|
@@ -2961,15 +2961,16 @@ export function createServer() {
|
|
|
2961
2961
|
server.registerTool(
|
|
2962
2962
|
'upsert_design_tokens',
|
|
2963
2963
|
{
|
|
2964
|
-
description: `Carga en lote (hasta 100) tokens de identidad de diseño: es el camino para poblar o sincronizar la identidad visual de una vez. ${DESIGN_TOKEN_PURPOSE} Cada token se identifica por (name, kind, platform): si existe se actualiza, si no se crea; nada se borra. Se valida todo antes de escribir (un token inválido o repetido en el lote rechaza el lote entero). Devuelve { created, updated, items }.`,
|
|
2964
|
+
description: `Carga en lote (hasta 100) tokens de identidad de diseño: es el camino para poblar o sincronizar la identidad visual de una vez. ${DESIGN_TOKEN_PURPOSE} Cada token se identifica por (name, kind, platform): si existe se actualiza, si no se crea; nada se borra. Se valida todo antes de escribir (un token inválido o repetido en el lote rechaza el lote entero). Devuelve { created, updated, items }; por defecto compact=true y items trae solo {id,name,key?,platform?,action}, con compact=false devuelve las filas completas.`,
|
|
2965
2965
|
inputSchema: {
|
|
2966
2966
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
2967
|
+
compact: z.boolean().optional().describe('true (default) = el lote devuelve items resumidos {id,name,key?,platform?,action}; false = filas completas'),
|
|
2967
2968
|
tokens: z.array(z.object(designTokenFields(false))).min(1).max(100).describe('Tokens a crear/actualizar (1-100)'),
|
|
2968
2969
|
},
|
|
2969
2970
|
},
|
|
2970
|
-
async ({ projectId, tokens }) => {
|
|
2971
|
+
async ({ projectId, tokens, compact }) => {
|
|
2971
2972
|
try {
|
|
2972
|
-
return textResult(await apiPost(config, '/v1/design-tokens/bulk', { projectId: resolveProjectId(projectId), tokens }))
|
|
2973
|
+
return textResult(await apiPost(config, '/v1/design-tokens/bulk', { projectId: resolveProjectId(projectId), tokens, compact: compact ?? true }))
|
|
2973
2974
|
} catch (err) {
|
|
2974
2975
|
return errorResult(err)
|
|
2975
2976
|
}
|
|
@@ -2985,6 +2986,24 @@ export function createServer() {
|
|
|
2985
2986
|
name: z.string().describe('Nombre del bloque, ej: "Encabezado", "Lista de tareas"'),
|
|
2986
2987
|
description: z.string().optional().describe('Qué contiene o hace'),
|
|
2987
2988
|
})).describe('Bloques de la pantalla de arriba a abajo, ej: [{"name":"Encabezado","description":"Título y filtros"},{"name":"Lista de tareas"}]')
|
|
2989
|
+
const navItemKey = z.string().min(1).max(60).regex(/^[a-z0-9.-]+$/, 'key en minúsculas, dígitos, punto o guion').describe('minúsculas, dígitos, punto o guion (≤60), única en el nodo')
|
|
2990
|
+
const uniqueKeys = (arr, ctx) => {
|
|
2991
|
+
const seen = new Set()
|
|
2992
|
+
arr.forEach((it, i) => {
|
|
2993
|
+
if (seen.has(it.key)) ctx.addIssue({ code: 'custom', path: [i, 'key'], message: `key repetida en el nodo: ${it.key}` })
|
|
2994
|
+
seen.add(it.key)
|
|
2995
|
+
})
|
|
2996
|
+
}
|
|
2997
|
+
const navActionsSchema = z.array(z.object({
|
|
2998
|
+
key: navItemKey.describe('Key de la acción, convención entidad.verbo, ej: "task.create", "task.assign"; minúsculas, dígitos, punto o guion (≤60), única en el nodo'),
|
|
2999
|
+
name: z.string().min(1).max(120).describe('Nombre visible de la acción, ej: "Crear tarea" (≤120)'),
|
|
3000
|
+
description: z.string().optional().describe('Qué hace'),
|
|
3001
|
+
roles: z.array(z.string()).optional().describe('Roles que pueden ejecutarla, ej: ["owner","admin"]'),
|
|
3002
|
+
})).max(60).superRefine(uniqueKeys).describe('Acciones que el usuario puede hacer en el nodo (hasta 60). Convención entidad.verbo (task.create); la misma acción lleva la MISMA key en web, iOS y Android: eso permite compararlas en get_design_parity.')
|
|
3003
|
+
const navStatesSchema = z.array(z.object({
|
|
3004
|
+
key: navItemKey.describe('Key del estado; recomendados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty'),
|
|
3005
|
+
note: z.string().optional().describe('Cómo se ve o se comporta en ese estado'),
|
|
3006
|
+
})).max(20).superRefine(uniqueKeys).describe('Estados de la pantalla (hasta 20). Recomendados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty. Misma key en las tres plataformas.')
|
|
2988
3007
|
const navNodeFields = optional => {
|
|
2989
3008
|
const f = {
|
|
2990
3009
|
name: z.string().describe('Nombre visible del nodo, ej: "Tareas"'),
|
|
@@ -2998,6 +3017,16 @@ export function createServer() {
|
|
|
2998
3017
|
order: z.number().optional().describe('Orden entre hermanos'),
|
|
2999
3018
|
description: z.string().optional().describe('Qué hace o muestra'),
|
|
3000
3019
|
blocks: navBlocksSchema.optional(),
|
|
3020
|
+
actions: navActionsSchema.optional(),
|
|
3021
|
+
states: navStatesSchema.optional(),
|
|
3022
|
+
screenshots: z.array(z.object({
|
|
3023
|
+
key: z.string().describe('Key de S3 (de add_design_screenshot)'),
|
|
3024
|
+
caption: z.string().max(200).optional(),
|
|
3025
|
+
state: z.string().optional().describe('Estado que muestra'),
|
|
3026
|
+
width: z.number().int().positive().optional(),
|
|
3027
|
+
height: z.number().int().positive().optional(),
|
|
3028
|
+
takenAt: z.number().optional().describe('ms epoch'),
|
|
3029
|
+
})).max(12).optional().describe('Capturas (máx. 12); usa add_design_screenshot. PATCH reemplaza la lista'),
|
|
3001
3030
|
sourceRef: z.string().optional().describe('Dónde vive en el código, ej: archivo y símbolo'),
|
|
3002
3031
|
}
|
|
3003
3032
|
if (optional) { f.name = f.name.optional(); f.platform = f.platform.optional(); f.kind = f.kind.optional() }
|
|
@@ -3027,10 +3056,10 @@ export function createServer() {
|
|
|
3027
3056
|
}
|
|
3028
3057
|
)
|
|
3029
3058
|
registerCreate('create_design_nav_node', '/v1/design-nav',
|
|
3030
|
-
`Crea un nodo de navegación de diseño. ${NAV_PURPOSE} Para cargar muchos de una vez usa upsert_design_nav, no esta tool en bucle.`,
|
|
3059
|
+
`Crea un nodo de navegación de diseño. ${NAV_PURPOSE} Para cargar muchos de una vez usa upsert_design_nav, no esta tool en bucle. Cada nodo acepta "actions" (convención entidad.verbo, ej. task.create; la misma acción lleva la MISMA key en web, iOS y Android) y "states" (recomendados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty). La matriz de paridad compara primero por capacidades (acciones y estados) y cae a bloques si la referencia no las define.`,
|
|
3031
3060
|
navNodeFields(false))
|
|
3032
3061
|
registerUpdate('update_design_nav_node', '/v1/design-nav',
|
|
3033
|
-
`Edita un nodo de navegación de diseño existente (parche parcial: manda solo lo que cambia; parentId puede mover el nodo o ponerlo en null para dejarlo en la raíz). ${NAV_PURPOSE}`,
|
|
3062
|
+
`Edita un nodo de navegación de diseño existente (parche parcial: manda solo lo que cambia; parentId puede mover el nodo o ponerlo en null para dejarlo en la raíz). Ojo: "actions" y "states" REEMPLAZAN el array completo del nodo (manda la lista final; para mezclar por key usa upsert_design_nav). Convención de actions: entidad.verbo (task.create), misma key en web, iOS y Android; states recomendados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty. La matriz de paridad compara primero por capacidades (acciones y estados) y cae a bloques si la referencia no las define. ${NAV_PURPOSE}`,
|
|
3034
3063
|
navNodeFields(true))
|
|
3035
3064
|
|
|
3036
3065
|
server.registerTool(
|
|
@@ -3071,22 +3100,93 @@ export function createServer() {
|
|
|
3071
3100
|
}
|
|
3072
3101
|
)
|
|
3073
3102
|
|
|
3074
|
-
const { parentId: _navParentId, ...navBulkBase } = navNodeFields(false)
|
|
3103
|
+
const { parentId: _navParentId, screenshots: _navShots, ...navBulkBase } = navNodeFields(false) // capturas: solo con add/remove_design_screenshot
|
|
3075
3104
|
server.registerTool(
|
|
3076
3105
|
'upsert_design_nav',
|
|
3077
3106
|
{
|
|
3078
|
-
description: `Carga en lote (hasta 300 nodos) la navegación de diseño: es el camino para cargar el árbol completo de una plataforma leyendo su código (rutas del router, tab bar, sidebar, pantallas y sus bloques). ${NAV_PURPOSE} En vez de parentId cada nodo lleva parentKey (la key del padre, que puede venir en el mismo lote; sin parentKey = raíz). Un nodo existente se identifica por (platform, key) o, si no hay key, por (platform, parentId, name): se actualiza, si no se crea; nada se borra. Ejemplo: [{"platform":"web","kind":"section","name":"Ajustes","key":"settings","route":"/settings"},{"platform":"web","kind":"screen","name":"General","key":"settings.general","parentKey":"settings","route":"/settings/general","blocks":[{"name":"Datos del workspace","description":"Nombre y logo"},{"name":"Zona de peligro"}],"
|
|
3107
|
+
description: `Carga en lote (hasta 300 nodos) la navegación de diseño: es el camino para cargar el árbol completo de una plataforma leyendo su código (rutas del router, tab bar, sidebar, pantallas y sus bloques). ${NAV_PURPOSE} En vez de parentId cada nodo lleva parentKey (la key del padre, que puede venir en el mismo lote; sin parentKey = raíz). Un nodo existente se identifica por (platform, key) o, si no hay key, por (platform, parentId, name): se actualiza, si no se crea; nada se borra. "actions" (convención entidad.verbo, ej. task.create; la misma acción = la MISMA key en web, iOS y Android, es lo que permite compararlas) y "states" (recomendados: loading, empty, error, readonly, no-access, offline, refreshing, search-empty) se MEZCLAN por key al actualizar un nodo existente (update_design_nav_node reemplaza el array completo). La paridad compara por capacidades y cae a bloques si la referencia no las define. Ejemplo: [{"platform":"web","kind":"section","name":"Ajustes","key":"settings","route":"/settings"},{"platform":"web","kind":"screen","name":"General","key":"settings.general","parentKey":"settings","route":"/settings/general","blocks":[{"name":"Datos del workspace","description":"Nombre y logo"},{"name":"Zona de peligro"}],"actions":[{"key":"workspace.rename","name":"Renombrar workspace","roles":["owner"]}],"states":[{"key":"loading"},{"key":"readonly"}]}]. Usa la misma key para la misma pantalla en web, iOS y Android. Devuelve { created, updated, items }; por defecto compact=true y items trae solo {id,name,key?,platform?,action}, con compact=false devuelve las filas completas.`,
|
|
3079
3108
|
inputSchema: {
|
|
3080
3109
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3110
|
+
compact: z.boolean().optional().describe('true (default) = el lote devuelve items resumidos {id,name,key?,platform?,action}; false = filas completas'),
|
|
3081
3111
|
nodes: z.array(z.object({
|
|
3082
3112
|
...navBulkBase,
|
|
3083
3113
|
parentKey: z.string().optional().describe('Key del nodo padre (en este lote o ya existente); ausente = raíz'),
|
|
3084
3114
|
})).min(1).max(300).describe('Nodos a crear/actualizar (1-300)'),
|
|
3085
3115
|
},
|
|
3086
3116
|
},
|
|
3087
|
-
async ({ projectId, nodes }) => {
|
|
3117
|
+
async ({ projectId, nodes, compact }) => {
|
|
3088
3118
|
try {
|
|
3089
|
-
return textResult(await apiPost(config, '/v1/design-nav/bulk', { projectId: resolveProjectId(projectId), nodes }))
|
|
3119
|
+
return textResult(await apiPost(config, '/v1/design-nav/bulk', { projectId: resolveProjectId(projectId), nodes, compact: compact ?? true }))
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
return errorResult(err)
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
)
|
|
3125
|
+
|
|
3126
|
+
// ═══ CAPTURAS POR PANTALLA (ORQ-573) ═══════════════════════════════════════
|
|
3127
|
+
const SHOT_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }
|
|
3128
|
+
const SHOT_MAX_BYTES = 5 * 1024 * 1024
|
|
3129
|
+
const SHOT_NOTE = 'Las capturas son INTERNAS del equipo (se guardan en un bucket privado y se ven con URL firmada de 1 h). No uses capturas del workspace real si van a usarse en marketing o videos: arma un usuario demo.'
|
|
3130
|
+
const findNavNode = async (pid, platform, key) => {
|
|
3131
|
+
const list = await apiGet(config, '/v1/design-nav', { projectId: pid, platform })
|
|
3132
|
+
const node = (list.items || []).find(n => n.key === key)
|
|
3133
|
+
if (!node) throw new Error(`No hay un nodo con key "${key}" en ${platform}; revisa list_design_nav o créalo con upsert_design_nav`)
|
|
3134
|
+
return node
|
|
3135
|
+
}
|
|
3136
|
+
server.registerTool(
|
|
3137
|
+
'add_design_screenshot',
|
|
3138
|
+
{
|
|
3139
|
+
description: `Sube una captura de pantalla (imagen local png/jpg/webp, máx. 5 MB) y la agrega al nodo de navegación de diseño (platform + key) para ver cómo se ve esa pantalla en cada plataforma y compararlas lado a lado. Valida extensión y tamaño ANTES de subir, pide la URL firmada, hace el PUT a S3 y guarda la clave en el nodo MEZCLANDO por key (no pisa las otras capturas; máx. 12 por nodo). "state" marca qué estado muestra (empty, loading, error...). ${SHOT_NOTE} Devuelve { key, name, screenshots }.`,
|
|
3140
|
+
inputSchema: {
|
|
3141
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3142
|
+
platform: z.enum(NAV_PLATFORMS).describe('Plataforma del nodo: web | ios | android'),
|
|
3143
|
+
key: z.string().describe('Key del nodo de navegación (ej: "tasks", "settings.general")'),
|
|
3144
|
+
filePath: z.string().describe('Ruta local a la imagen (png/jpg/jpeg/webp, máx. 5 MB)'),
|
|
3145
|
+
caption: z.string().max(200).optional().describe('Pie de la captura (máx. 200)'),
|
|
3146
|
+
state: z.string().optional().describe('Estado que muestra, ej: empty, loading, error'),
|
|
3147
|
+
},
|
|
3148
|
+
},
|
|
3149
|
+
async ({ projectId, platform, key, filePath, caption, state }) => {
|
|
3150
|
+
try {
|
|
3151
|
+
const pid = resolveProjectId(projectId)
|
|
3152
|
+
const ext = path.extname(filePath).slice(1).toLowerCase()
|
|
3153
|
+
const mime = SHOT_MIME_BY_EXT[ext]
|
|
3154
|
+
if (!mime) throw new Error('Solo imágenes png, jpg/jpeg o webp')
|
|
3155
|
+
const stat = statSync(filePath)
|
|
3156
|
+
if (!stat.isFile()) throw new Error('filePath no es un archivo')
|
|
3157
|
+
if (stat.size > SHOT_MAX_BYTES) throw new Error(`La imagen pesa ${(stat.size / 1048576).toFixed(1)} MB; el máximo es 5 MB`)
|
|
3158
|
+
const node = await findNavNode(pid, platform, key)
|
|
3159
|
+
const name = path.basename(filePath)
|
|
3160
|
+
const { uploadUrl, key: shotKey, headers } = await apiPost(config, '/v1/design-assets/presign', { projectId: pid, nodeId: node.id, filename: name, contentType: mime, size: stat.size })
|
|
3161
|
+
await putToPresignedUrl(uploadUrl, readFileSync(filePath), mime, headers)
|
|
3162
|
+
const shot = { key: shotKey, takenAt: Date.now(), ...(caption ? { caption } : {}), ...(state ? { state } : {}) }
|
|
3163
|
+
const res = await apiPost(config, '/v1/design-nav/bulk', { projectId: pid, compact: false, nodes: [{ platform, kind: node.kind, name: node.name, key, screenshots: [shot] }] })
|
|
3164
|
+
const updated = res.items?.[0]
|
|
3165
|
+
return textResult({ key: shotKey, name, nodeId: node.id, screenshots: updated?.screenshots ?? [shot] })
|
|
3166
|
+
} catch (err) {
|
|
3167
|
+
return errorResult(err)
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
)
|
|
3171
|
+
server.registerTool(
|
|
3172
|
+
'remove_design_screenshot',
|
|
3173
|
+
{
|
|
3174
|
+
description: `Quita una captura de un nodo de navegación de diseño (platform + key del nodo + screenshotKey de la captura, la que devolvió add_design_screenshot o list_design_nav). La API también intenta borrar el archivo de S3. ${SHOT_NOTE}`,
|
|
3175
|
+
inputSchema: {
|
|
3176
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3177
|
+
platform: z.enum(NAV_PLATFORMS).describe('Plataforma del nodo: web | ios | android'),
|
|
3178
|
+
key: z.string().describe('Key del nodo de navegación'),
|
|
3179
|
+
screenshotKey: z.string().describe('Key de S3 de la captura a quitar'),
|
|
3180
|
+
},
|
|
3181
|
+
},
|
|
3182
|
+
async ({ projectId, platform, key, screenshotKey }) => {
|
|
3183
|
+
try {
|
|
3184
|
+
const pid = resolveProjectId(projectId)
|
|
3185
|
+
const node = await findNavNode(pid, platform, key)
|
|
3186
|
+
const current = node.screenshots || []
|
|
3187
|
+
if (!current.some(s => s.key === screenshotKey)) throw new Error(`El nodo "${key}" no tiene una captura con key "${screenshotKey}"`)
|
|
3188
|
+
const data = await apiPatch(config, '/v1/design-nav', { projectId: pid, id: node.id, screenshots: current.filter(s => s.key !== screenshotKey) })
|
|
3189
|
+
return textResult({ removed: screenshotKey, nodeId: node.id, screenshots: data.screenshots ?? [] })
|
|
3090
3190
|
} catch (err) {
|
|
3091
3191
|
return errorResult(err)
|
|
3092
3192
|
}
|
|
@@ -3158,15 +3258,16 @@ export function createServer() {
|
|
|
3158
3258
|
server.registerTool(
|
|
3159
3259
|
'upsert_design_components',
|
|
3160
3260
|
{
|
|
3161
|
-
description: `Carga en lote (hasta 200) componentes de diseño: es el camino para catalogar los componentes reales leyendo el código de cada plataforma (web, iOS y Android) y llenar platforms[].sourceRef con el archivo y símbolo de cada una. ${COMP_PURPOSE} Cada componente se identifica por name (sin distinguir mayúsculas): si existe se
|
|
3261
|
+
description: `Carga en lote (hasta 200) componentes de diseño: es el camino para catalogar los componentes reales leyendo el código de cada plataforma (web, iOS y Android) y llenar platforms[].sourceRef con el archivo y símbolo de cada una. ${COMP_PURPOSE} Cada componente se identifica por name (sin distinguir mayúsculas): si existe se crea o se MEZCLA, nada se borra. Cargar el MISMO name desde otra plataforma mezcla en vez de reemplazar: platforms se fusiona por plataforma, tokens/usedIn/states se unen y variants se combinan por nombre; para reemplazar de verdad usa update_design_component. Usa el mismo name para el mismo componente en las tres plataformas. Devuelve { created, updated, items, warnings }: warnings ([{name,field,value}], siempre presente) avisa de tokens o usedIn que no existen, corrígelos. Por defecto compact=true y items trae solo {id,name,key?,platform?,action}; con compact=false devuelve las filas completas.`,
|
|
3162
3262
|
inputSchema: {
|
|
3163
3263
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3264
|
+
compact: z.boolean().optional().describe('true (default) = el lote devuelve items resumidos {id,name,key?,platform?,action}; false = filas completas'),
|
|
3164
3265
|
components: z.array(z.object(designComponentFields(false))).min(1).max(200).describe('Componentes a crear/actualizar (1-200)'),
|
|
3165
3266
|
},
|
|
3166
3267
|
},
|
|
3167
|
-
async ({ projectId, components }) => {
|
|
3268
|
+
async ({ projectId, components, compact }) => {
|
|
3168
3269
|
try {
|
|
3169
|
-
return textResult(await apiPost(config, '/v1/design-components/bulk', { projectId: resolveProjectId(projectId), components }))
|
|
3270
|
+
return textResult(await apiPost(config, '/v1/design-components/bulk', { projectId: resolveProjectId(projectId), components, compact: compact ?? true }))
|
|
3170
3271
|
} catch (err) {
|
|
3171
3272
|
return errorResult(err)
|
|
3172
3273
|
}
|
|
@@ -3178,17 +3279,18 @@ export function createServer() {
|
|
|
3178
3279
|
server.registerTool(
|
|
3179
3280
|
'get_design_parity',
|
|
3180
3281
|
{
|
|
3181
|
-
description: 'Matriz de paridad de diseño del proyecto: una fila por pantalla/componente (key, name, kind, route) y, por plataforma (web, ios, android), una celda con status (same | different | missing | not_applicable), source (auto | manual), nodeId, path, note, taskId y diff. Devuelve además summary (conteos por plataforma y total) y gaps (los huecos: [{key,name,platform,status}]). `ref` es la plataforma de referencia (default web). "distinto" (different) sale de comparar kind y bloques del nodo contra la plataforma de referencia; "falta" (missing) de que no exista un nodo con esa key en esa plataforma. Flujo para cerrar huecos: cada hueco (different | missing) puede convertirse en una tarea hermana con create_task (scope de esa plataforma y parityOf cuando aplique) y luego enlazarse a su celda con set_design_parity_cell(taskId=folio). Si una celda automática es incorrecta (ej. no aplica), fíjala a mano con set_design_parity_cell. Con soloHuecos:true devuelve solo { summary, gaps } para ahorrar tokens.',
|
|
3282
|
+
description: 'Matriz de paridad de diseño del proyecto: una fila por pantalla/componente (key, name, kind, route) y, por plataforma (web, ios, android), una celda con status (same | different | missing | not_applicable), source (auto | manual), nodeId, path, note, taskId y diff. Cuando hay diff, cada celda suma además: basis ("capabilities" si se comparó por acciones y estados, "blocks" si la referencia no los define y se cayó a bloques), coverage, actionsMissing (acciones de la referencia que faltan aquí), actionsOnlyHere, statesMissing y statesOnlyHere; la raíz suma capabilitiesNodes. Devuelve además summary (conteos por plataforma y total) y gaps (los huecos: [{key,name,platform,status}]). `ref` es la plataforma de referencia (default web). "distinto" (different) sale de comparar kind y bloques del nodo contra la plataforma de referencia; "falta" (missing) de que no exista un nodo con esa key en esa plataforma. Flujo para cerrar huecos: cada hueco (different | missing) puede convertirse en una tarea hermana con create_task (scope de esa plataforma y parityOf cuando aplique) y luego enlazarse a su celda con set_design_parity_cell(taskId=folio). Si una celda automática es incorrecta (ej. no aplica), fíjala a mano con set_design_parity_cell. "Igual" (same) significa mismo tipo (kind) y similitud de bloques >= threshold (0..1, default 0.6); cada diff trae su similarity y la respuesta el threshold usado: sube el umbral para ser más estricto, bájalo para tolerar diferencias. Con soloHuecos:true devuelve solo { summary, gaps, threshold } para ahorrar tokens.',
|
|
3182
3283
|
inputSchema: {
|
|
3183
3284
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3184
3285
|
ref: z.enum(PARITY_PLATFORMS).optional().describe('Plataforma de referencia: web | ios | android (default web)'),
|
|
3185
|
-
soloHuecos: z.boolean().optional().describe('true = devuelve solo summary y
|
|
3286
|
+
soloHuecos: z.boolean().optional().describe('true = devuelve solo summary, gaps y threshold (sin las filas), para ahorrar tokens'),
|
|
3287
|
+
threshold: z.number().min(0).max(1).optional().describe('Umbral de similitud de bloques (0..1, default 0.6) para considerar "same"'),
|
|
3186
3288
|
},
|
|
3187
3289
|
},
|
|
3188
|
-
async ({ projectId, ref, soloHuecos }) => {
|
|
3290
|
+
async ({ projectId, ref, soloHuecos, threshold }) => {
|
|
3189
3291
|
try {
|
|
3190
|
-
const data = await apiGet(config, '/v1/design-parity', { projectId: resolveProjectId(projectId), ref })
|
|
3191
|
-
if (soloHuecos && data && typeof data === 'object') return textResult({ summary: data.summary, gaps: data.gaps })
|
|
3292
|
+
const data = await apiGet(config, '/v1/design-parity', { projectId: resolveProjectId(projectId), ref, threshold })
|
|
3293
|
+
if (soloHuecos && data && typeof data === 'object') return textResult({ summary: data.summary, gaps: data.gaps, threshold: data.threshold })
|
|
3192
3294
|
return textResult(data)
|
|
3193
3295
|
} catch (err) {
|
|
3194
3296
|
return errorResult(err)
|
|
@@ -3199,7 +3301,7 @@ export function createServer() {
|
|
|
3199
3301
|
server.registerTool(
|
|
3200
3302
|
'set_design_parity_cell',
|
|
3201
3303
|
{
|
|
3202
|
-
description: 'Fija a mano una celda de la matriz de paridad de diseño (get_design_parity): el override manual gana sobre el cálculo automático. Úsala para marcar not_applicable (la pantalla no existe en esa plataforma a propósito), corregir un estado, dejar una nota o enlazar la tarea hermana que cierra el hueco (taskId = folio, ej. ORQ-123). Con status:null quita el override y la celda vuelve al cálculo automático.',
|
|
3304
|
+
description: 'Fija a mano una celda de la matriz de paridad de diseño (get_design_parity): el override manual gana sobre el cálculo automático. Úsala para marcar not_applicable (la pantalla no existe en esa plataforma a propósito), corregir un estado, dejar una nota o enlazar la tarea hermana que cierra el hueco (taskId = folio, ej. ORQ-123). Con status:null y SIN note ni taskId quita el override y la celda vuelve al cálculo automático; con status:null y note o taskId conserva la nota y el folio sin fijar estado manual (la celda sigue automática). Regla: fijar status:"not_applicable" EXIGE un `reason` (motivo) no vacío; para los demás estados es opcional. `adr` enlaza la celda a un doc del proyecto (normalmente un ADR que documenta la decisión): pásale el id del doc (obtenlo con search types=["doc"] o listando los docs); "" lo quita. get_design_parity devuelve en la celda reason y decision {docId,title,type}.',
|
|
3203
3305
|
inputSchema: {
|
|
3204
3306
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3205
3307
|
key: z.string().min(1).describe('Key de la fila (ver get_design_parity)'),
|
|
@@ -3207,11 +3309,142 @@ export function createServer() {
|
|
|
3207
3309
|
status: z.enum(['same', 'different', 'missing', 'not_applicable']).nullable().describe('Estado manual; null = quitar el override'),
|
|
3208
3310
|
note: z.string().optional().describe('Nota de la celda'),
|
|
3209
3311
|
taskId: z.string().optional().describe('Folio de la tarea que cierra el hueco, ej: ORQ-123'),
|
|
3312
|
+
reason: z.string().max(500).optional().describe('Motivo de la decisión (máx. 500). Obligatorio al fijar status not_applicable'),
|
|
3313
|
+
adr: z.string().max(60).optional().describe('Id del doc (ADR) que documenta la decisión; se envía como docId. "" lo quita'),
|
|
3314
|
+
},
|
|
3315
|
+
},
|
|
3316
|
+
async ({ projectId, key, platform, status, note, taskId, reason, adr }) => {
|
|
3317
|
+
try {
|
|
3318
|
+
if (status === 'not_applicable' && !(reason && reason.trim())) {
|
|
3319
|
+
return errorResult(new Error('motivo obligatorio al fijar No aplica: pasa reason con el porqué (y opcionalmente adr con el id del doc)'))
|
|
3320
|
+
}
|
|
3321
|
+
return textResult(await apiPut(config, '/v1/design-parity/cell', { projectId: resolveProjectId(projectId), key, platform, status, note, taskId, reason, docId: adr }))
|
|
3322
|
+
} catch (err) {
|
|
3323
|
+
return errorResult(err)
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
)
|
|
3327
|
+
|
|
3328
|
+
server.registerTool(
|
|
3329
|
+
'create_parity_tasks',
|
|
3330
|
+
{
|
|
3331
|
+
description: 'Convierte huecos de la paridad de diseño en tareas. Flujo: primero get_design_parity con soloHuecos:true para ver los huecos (gaps); luego create_parity_tasks con las celdas {key, platform} que quieras cerrar. Crea UNA tarea por celda, con scope de esa plataforma y el detalle de la diferencia contra la plataforma de referencia (ref, default web; threshold para el umbral de similitud). No duplica: si la celda ya tiene folio la omite (aparece en skipped con su reason) salvo force:true; el folio nuevo se guarda en la celda. La tarea exige módulo y fecha: si no pasas moduleId/dueDate se resuelven solos (módulo de la plataforma, hoy + 14 días); priority es opcional. Responde { created: [{key,platform,taskId,id}], skipped: [{key,platform,reason}], warnings }. No la uses para cientos de celdas a la vez: máximo 50 por llamada.',
|
|
3332
|
+
inputSchema: {
|
|
3333
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3334
|
+
cells: z.array(z.object({
|
|
3335
|
+
key: z.string().min(1).describe('Key de la fila (ver get_design_parity)'),
|
|
3336
|
+
platform: z.enum(PARITY_PLATFORMS).describe('Plataforma de la celda: web | ios | android'),
|
|
3337
|
+
})).min(1).max(50).describe('Celdas a convertir en tarea (1 a 50)'),
|
|
3338
|
+
ref: z.enum(PARITY_PLATFORMS).optional().describe('Plataforma de referencia: web | ios | android (default web)'),
|
|
3339
|
+
threshold: z.number().min(0).max(1).optional().describe('Umbral de similitud de bloques (0..1, default 0.6)'),
|
|
3340
|
+
moduleId: z.string().optional().describe('Módulo de las tareas; si falta se usa el de la plataforma'),
|
|
3341
|
+
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato YYYY-MM-DD').optional().describe('Fecha límite YYYY-MM-DD; si falta, hoy + 14 días'),
|
|
3342
|
+
priority: z.enum(['high', 'medium', 'low']).optional().describe('Prioridad de las tareas'),
|
|
3343
|
+
force: z.boolean().optional().describe('true = crea la tarea aunque la celda ya tenga folio'),
|
|
3344
|
+
},
|
|
3345
|
+
},
|
|
3346
|
+
async ({ projectId, cells, ref, threshold, moduleId, dueDate, priority, force }) => {
|
|
3347
|
+
try {
|
|
3348
|
+
return textResult(await apiPost(config, '/v1/design-parity/tasks', { projectId: resolveProjectId(projectId), cells, ref, threshold, moduleId, dueDate, priority, force }))
|
|
3349
|
+
} catch (err) {
|
|
3350
|
+
return errorResult(err)
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
)
|
|
3354
|
+
|
|
3355
|
+
// ═══ DERIVA DE TOKENS (ORQ-574) ═══════════════════════════════════════════
|
|
3356
|
+
const DRIFT_KINDS = ['color', 'radius', 'spacing', 'typography', 'shadow']
|
|
3357
|
+
const DRIFT_STATUSES = ['open', 'ignored', 'fixed']
|
|
3358
|
+
const DRIFT_FLOW = 'Detector de deriva de tokens: valores de diseño (colores hex, radios, spacings, fuentes, sombras) escritos a mano en el código de una plataforma FUERA de la definición de tokens. FLUJO: 1) list_design_tokens para conocer los tokens de la plataforma; 2) recorre el código de UNA plataforma (web, ios o android) buscando colores hex, radios, spacings y fuentes escritos a mano; 3) compara cada valor con los tokens de esa MISMA plataforma y, si coincide, propón suggestedToken con el name del token; 4) reporta en lote con report_design_drift (hasta 200 por llamada; guarda la hora de inicio de la pasada, Date.now()); 5) al terminar una pasada COMPLETA de esa plataforma, llama a resolve_missing_design_drift en dryRun (platform + reportedBefore = hora de inicio de la pasada) para ver qué hallazgos abiertos ya no aparecen y, si es correcto, repítela con dryRun:false (se marcan fixed). NO es deriva: el archivo donde se DEFINEN los tokens (theme, tokens.css, Colors.swift, Color.kt, variables.css...), tests y fixtures, previews/storybook/SwiftUI previews, código generado, node_modules/build, ni valores dinámicos (calculados por el usuario o datos).'
|
|
3359
|
+
const driftItemSchema = z.object({
|
|
3360
|
+
platform: z.enum(PARITY_PLATFORMS).describe('Plataforma donde está el código: web | ios | android'),
|
|
3361
|
+
file: z.string().min(1).max(300).describe('Ruta RELATIVA al repo de la plataforma, ej: src/pages/Tasks.jsx'),
|
|
3362
|
+
line: z.number().int().min(1).optional().describe('Línea (>= 1); parte de la identidad del hallazgo'),
|
|
3363
|
+
kind: z.enum(DRIFT_KINDS).describe('color | radius | spacing | typography | shadow'),
|
|
3364
|
+
value: z.string().min(1).max(120).describe('Valor escrito a mano tal cual, ej: "#2F7A55", "13dp", "12px"'),
|
|
3365
|
+
context: z.string().max(200).optional().describe('Símbolo o línea de código donde aparece (≤200)'),
|
|
3366
|
+
suggestedToken: z.string().max(80).optional().describe('name de un design token EXISTENTE de esa plataforma que lo reemplazaría (≤80); si no existe la API avisa en warnings'),
|
|
3367
|
+
note: z.string().max(300).optional().describe('Nota (≤300), solo se guarda al crear'),
|
|
3368
|
+
reopen: z.boolean().optional().describe('true = reabre este hallazgo si estaba ignored o fixed (por default un reporte repetido NO los reabre)'),
|
|
3369
|
+
})
|
|
3370
|
+
|
|
3371
|
+
server.registerTool(
|
|
3372
|
+
'report_design_drift',
|
|
3373
|
+
{
|
|
3374
|
+
description: `Reporta en lote (1 a 200) hallazgos de deriva de tokens. ${DRIFT_FLOW} Cada hallazgo se identifica por (platform, file, line, value): reportar de nuevo uno existente solo refresca seenAt, suggestedToken y context y NO reabre los ignorados o corregidos (salvo reopen:true). Responde { created, updated, reopened, items (resumidos por default), warnings } donde warnings avisa de suggestedToken que no existe en los tokens. Valida todo el lote antes de escribir.`,
|
|
3375
|
+
inputSchema: {
|
|
3376
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3377
|
+
compact: z.boolean().optional().describe('true (default) = items resumidos {id,platform,file,line?,value,status,action}; false = filas completas'),
|
|
3378
|
+
items: z.array(driftItemSchema).min(1).max(200).describe('Hallazgos a reportar (1-200)'),
|
|
3379
|
+
},
|
|
3380
|
+
},
|
|
3381
|
+
async ({ projectId, items, compact }) => {
|
|
3382
|
+
try {
|
|
3383
|
+
return textResult(await apiPost(config, '/v1/design-drift/bulk', { projectId: resolveProjectId(projectId), items, compact: compact ?? true }))
|
|
3384
|
+
} catch (err) {
|
|
3385
|
+
return errorResult(err)
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
)
|
|
3389
|
+
|
|
3390
|
+
server.registerTool(
|
|
3391
|
+
'list_design_drift',
|
|
3392
|
+
{
|
|
3393
|
+
description: 'Lista los hallazgos de deriva de tokens (valores de diseño escritos a mano que deberían ser un token), ordenados por archivo y línea, con filtros por plataforma, tipo y estado. Responde { items, summary: { open, ignored, fixed, byPlatform: { web, ios, android } } }; el summary es de todo el proyecto, no depende de los filtros. Úsala para ver qué falta migrar a tokens, antes de reportar de nuevo (no repitas lo ya ignorado) o para armar tareas de limpieza. Se llena con report_design_drift; si viene vacía, nadie ha recorrido el código todavía.',
|
|
3394
|
+
inputSchema: {
|
|
3395
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3396
|
+
platform: z.enum(PARITY_PLATFORMS).optional().describe('Filtra por plataforma'),
|
|
3397
|
+
kind: z.enum(DRIFT_KINDS).optional().describe('Filtra por tipo'),
|
|
3398
|
+
status: z.enum(DRIFT_STATUSES).optional().describe('Filtra por estado: open | ignored | fixed'),
|
|
3399
|
+
},
|
|
3400
|
+
},
|
|
3401
|
+
async ({ projectId, platform, kind, status }) => {
|
|
3402
|
+
try {
|
|
3403
|
+
return textResult(await apiGet(config, '/v1/design-drift', { projectId: resolveProjectId(projectId), platform, kind, status }))
|
|
3404
|
+
} catch (err) {
|
|
3405
|
+
return errorResult(err)
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
)
|
|
3409
|
+
|
|
3410
|
+
server.registerTool(
|
|
3411
|
+
'update_design_drift',
|
|
3412
|
+
{
|
|
3413
|
+
description: 'Cambia el estado de un hallazgo de deriva (list_design_drift): ignored (es intencional o no aplica: un preview, un valor dinámico; ponle una nota con el motivo), fixed (ya se migró a un token) u open (reabrir). También puede ajustar la nota y el suggestedToken. Un hallazgo ignorado o corregido NO se reabre solo cuando se vuelve a reportar. Para marcar como corregidos los que ya no aparecen tras una pasada completa usa resolve_missing_design_drift, no esta tool en bucle.',
|
|
3414
|
+
inputSchema: {
|
|
3415
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3416
|
+
driftId: z.string().describe('ID del hallazgo (ver list_design_drift)'),
|
|
3417
|
+
status: z.enum(DRIFT_STATUSES).optional().describe('open | ignored | fixed'),
|
|
3418
|
+
note: z.string().max(300).optional().describe('Nota (≤300), ej. el motivo de ignorarlo'),
|
|
3419
|
+
suggestedToken: z.string().max(80).optional().describe('name del token que lo reemplazaría (≤80)'),
|
|
3420
|
+
},
|
|
3421
|
+
},
|
|
3422
|
+
async ({ projectId, driftId, status, note, suggestedToken }) => {
|
|
3423
|
+
if (status === undefined && note === undefined && suggestedToken === undefined) {
|
|
3424
|
+
return errorResult(new Error('Nada que actualizar: manda status, note o suggestedToken'))
|
|
3425
|
+
}
|
|
3426
|
+
try {
|
|
3427
|
+
return textResult(await apiPatch(config, '/v1/design-drift', { projectId: resolveProjectId(projectId), id: driftId, status, note, suggestedToken }))
|
|
3428
|
+
} catch (err) {
|
|
3429
|
+
return errorResult(err)
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
)
|
|
3433
|
+
|
|
3434
|
+
server.registerTool(
|
|
3435
|
+
'resolve_missing_design_drift',
|
|
3436
|
+
{
|
|
3437
|
+
description: 'Cierre de una pasada COMPLETA de deriva sobre una plataforma: marca como fixed los hallazgos OPEN de esa plataforma que no se vieron desde reportedBefore (porque la pasada ya no los encontró en el código). Úsala solo si recorriste TODA la plataforma; con una pasada parcial marcarías como corregido lo que simplemente no revisaste. reportedBefore = hora de inicio de la pasada en ms epoch (Date.now() guardado ANTES de empezar a reportar). dryRun es true por default: solo informa { dryRun, platform, reportedBefore, count, items } sin escribir; revisa la lista y repite con dryRun:false para aplicar. No toca los ignorados ni los ya corregidos.',
|
|
3438
|
+
inputSchema: {
|
|
3439
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
3440
|
+
platform: z.enum(PARITY_PLATFORMS).describe('Plataforma de la pasada: web | ios | android'),
|
|
3441
|
+
reportedBefore: z.number().int().positive().describe('ms epoch: se marcan los open con seenAt anterior a este valor'),
|
|
3442
|
+
dryRun: z.boolean().optional().describe('true (default) = solo informa; false = marca fixed'),
|
|
3210
3443
|
},
|
|
3211
3444
|
},
|
|
3212
|
-
async ({ projectId,
|
|
3445
|
+
async ({ projectId, platform, reportedBefore, dryRun }) => {
|
|
3213
3446
|
try {
|
|
3214
|
-
return textResult(await
|
|
3447
|
+
return textResult(await apiPost(config, '/v1/design-drift/resolve-missing', { projectId: resolveProjectId(projectId), platform, reportedBefore, dryRun: dryRun ?? true }))
|
|
3215
3448
|
} catch (err) {
|
|
3216
3449
|
return errorResult(err)
|
|
3217
3450
|
}
|