mcp-phase-gate 2.0.0 → 2.0.1
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/LICENSE +21 -21
- package/README.es.md +525 -570
- package/README.md +497 -497
- package/build/index.js +69 -67
- package/build/paths.d.ts +13 -1
- package/build/paths.js +159 -7
- package/build/schemas.d.ts +92 -0
- package/build/schemas.js +105 -0
- package/package.json +53 -53
- package/prompts/fase-1-descubrimiento.md +167 -167
- package/prompts/fase-2-decisiones.md +246 -208
- package/prompts/fase-3-plan-tecnico.md +266 -266
- package/prompts/fase-4-ejecucion.md +235 -235
- package/prompts/fase-5-auditoria-pr.md +208 -208
- package/prompts/global-rules.md +4 -4
package/build/index.js
CHANGED
|
@@ -11,9 +11,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { DOCS, REPOS, SERVER_NAME, SERVER_VERSION, requireBasePath, stateFile } from "./config.js";
|
|
14
|
-
import { describeProjects, discoverProjects,
|
|
14
|
+
import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, validarSegmento, } from "./paths.js";
|
|
15
15
|
import { describePhases, discoverPhases, loadPhasePrompt, nombreContextoInicial, } from "./phases.js";
|
|
16
16
|
import { avisoDeCompuerta, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, tareaActiva, } from "./tasks.js";
|
|
17
|
+
import { validarLlamada } from "./schemas.js";
|
|
17
18
|
const server = new Server({
|
|
18
19
|
name: SERVER_NAME,
|
|
19
20
|
version: SERVER_VERSION,
|
|
@@ -139,25 +140,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
139
140
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
140
141
|
const { name, arguments: args } = request.params;
|
|
141
142
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
// Todo argumento se valida aquí, antes de que ninguna tool lo use. Un campo ausente o de
|
|
144
|
+
// otro tipo devuelve un error que lo nombra, en vez de convertirse en "undefined".
|
|
145
|
+
const llamada = validarLlamada(name, args);
|
|
146
|
+
if (llamada.tool === "start_task") {
|
|
147
|
+
const proj = llamada.args.project; // el schema ya le quitó los espacios
|
|
148
|
+
const taskName = llamada.args.task_name;
|
|
149
|
+
const context = llamada.args.initial_context;
|
|
146
150
|
// Estrenar proyecto tiene que ser deliberado: sin esta puerta, un nombre mal escrito
|
|
147
151
|
// creaba una carpeta suelta en la Documentación y nadie se enteraba hasta buscarla.
|
|
148
152
|
let carpetaProyecto = findProjectFolder(proj);
|
|
149
153
|
let estrenado = false;
|
|
150
154
|
if (!carpetaProyecto) {
|
|
151
|
-
if (args
|
|
155
|
+
if (llamada.args.crear_proyecto !== true) {
|
|
152
156
|
throw new Error(`No existe el proyecto "${proj}". Los proyectos disponibles son: ${describeProjects()}. ` +
|
|
153
157
|
`Si el usuario te confirmó que quiere estrenar el proyecto "${proj.toUpperCase()}", ` +
|
|
154
158
|
`vuelve a llamar a start_task con crear_proyecto: true.`);
|
|
155
159
|
}
|
|
160
|
+
// El nombre se vuelve carpeta tal cual: "../evil" la creaba fuera de la Documentación.
|
|
161
|
+
validarSegmento(proj, "project");
|
|
156
162
|
carpetaProyecto = proj.toUpperCase();
|
|
157
163
|
estrenado = true;
|
|
158
164
|
}
|
|
159
165
|
const carpetaTarea = taskFolderPath(carpetaProyecto, taskName);
|
|
160
166
|
const contextoInicial = path.join(carpetaTarea, nombreContextoInicial());
|
|
167
|
+
const docsBase = requireBasePath(DOCS);
|
|
168
|
+
if (!estaDentroReal(docsBase, carpetaTarea)) {
|
|
169
|
+
throw new Error(`Acceso denegado: la carpeta de la tarea queda fuera de ${docsBase}.`);
|
|
170
|
+
}
|
|
161
171
|
// start_task escribe el contexto inicial sin preguntar, así que llamarlo sobre una tarea
|
|
162
172
|
// que ya existe borraba el requerimiento original — el único documento que no se puede
|
|
163
173
|
// regenerar leyendo el código. Retomar una tarea es switch_task, que no toca archivos.
|
|
@@ -178,19 +188,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
178
188
|
content: [{ type: "text", text: `Tarea '${taskName}' inicializada correctamente. Archivo guardado en: ${contextoInicial}\nTarea activa de ${carpetaProyecto}: ${taskName}.${aviso}` }],
|
|
179
189
|
};
|
|
180
190
|
}
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
throw new Error(`Debes indicar un número de fase. Disponibles: ${PHASES_DESCRIPTION}.`);
|
|
185
|
-
}
|
|
191
|
+
if (llamada.tool === "get_phase_prompt") {
|
|
192
|
+
// Que sea un entero ya lo garantiza el schema; si la fase no existe, lo dice loadPhasePrompt.
|
|
193
|
+
const { phase, project, task_name } = llamada.args;
|
|
186
194
|
const prompt = loadPhasePrompt(phase);
|
|
187
195
|
// La compuerta avisa, no bloquea: el prompt se entrega siempre. Si comprobarla fallara
|
|
188
196
|
// por lo que sea, entregarlo igual es mejor que dejar al usuario sin fase por un aviso.
|
|
189
197
|
let aviso = null;
|
|
190
198
|
try {
|
|
191
199
|
aviso = avisoDeCompuerta(phase, discoverPhases(), {
|
|
192
|
-
...(
|
|
193
|
-
...(
|
|
200
|
+
...(project ? { project } : {}),
|
|
201
|
+
...(task_name ? { taskName: task_name } : {}),
|
|
194
202
|
});
|
|
195
203
|
}
|
|
196
204
|
catch (error) {
|
|
@@ -200,9 +208,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
200
208
|
content: [{ type: "text", text: aviso ? `${aviso}\n\n${prompt}` : prompt }],
|
|
201
209
|
};
|
|
202
210
|
}
|
|
203
|
-
if (
|
|
211
|
+
if (llamada.tool === "get_active_task") {
|
|
204
212
|
const phases = discoverPhases();
|
|
205
|
-
const pedido = args
|
|
213
|
+
const pedido = llamada.args.project ?? "";
|
|
206
214
|
// Con proyecto: la suya. Sin proyecto: la de cada uno. Antes había un único puntero
|
|
207
215
|
// global y responder "la activa" a secas obligaba a adivinar cuál de dos ventanas
|
|
208
216
|
// preguntaba; listar todas es la respuesta honesta cuando no se sabe.
|
|
@@ -230,9 +238,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
230
238
|
content: [{ type: "text", text: `${bloques.join("\n\n")}\n\nPunteros guardados en: ${stateFile()}` }],
|
|
231
239
|
};
|
|
232
240
|
}
|
|
233
|
-
if (
|
|
241
|
+
if (llamada.tool === "list_tasks") {
|
|
234
242
|
const phases = discoverPhases();
|
|
235
|
-
const pedido = args
|
|
243
|
+
const pedido = llamada.args.project ?? "";
|
|
236
244
|
const proyectos = pedido
|
|
237
245
|
? [requireProjectFolder(pedido)]
|
|
238
246
|
: [...discoverProjects().values()].sort();
|
|
@@ -254,10 +262,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
254
262
|
content: [{ type: "text", text: bloques.join("\n\n") || "No hay proyectos todavía." }],
|
|
255
263
|
};
|
|
256
264
|
}
|
|
257
|
-
if (
|
|
265
|
+
if (llamada.tool === "switch_task") {
|
|
258
266
|
const phases = discoverPhases();
|
|
259
|
-
const carpetaProyecto = requireProjectFolder(
|
|
260
|
-
const taskName =
|
|
267
|
+
const carpetaProyecto = requireProjectFolder(llamada.args.project);
|
|
268
|
+
const taskName = llamada.args.task_name;
|
|
261
269
|
const tareas = listarTareas(carpetaProyecto, phases);
|
|
262
270
|
const encontrada = tareas.find((tarea) => tarea.taskName === taskName);
|
|
263
271
|
if (!encontrada) {
|
|
@@ -272,22 +280,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
272
280
|
content: [{ type: "text", text: `Tarea activa de ${carpetaProyecto}: ${taskName}.\nFase: ${describeEstado(encontrada, phases)}\nCarpeta: ${encontrada.folderPath}` }],
|
|
273
281
|
};
|
|
274
282
|
}
|
|
275
|
-
if (
|
|
283
|
+
if (llamada.tool === "read_central_doc") {
|
|
276
284
|
const docsBase = requireBasePath(DOCS);
|
|
277
|
-
const fullPath = resolveDocPath(args);
|
|
278
|
-
if (!
|
|
285
|
+
const fullPath = resolveDocPath(llamada.args);
|
|
286
|
+
if (!estaDentroReal(docsBase, fullPath)) {
|
|
279
287
|
throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
|
|
280
288
|
}
|
|
281
|
-
const content =
|
|
289
|
+
const content = leerTextoAcotado(fullPath);
|
|
282
290
|
return {
|
|
283
291
|
content: [{ type: "text", text: content }],
|
|
284
292
|
};
|
|
285
293
|
}
|
|
286
|
-
if (
|
|
287
|
-
const content =
|
|
294
|
+
if (llamada.tool === "write_central_doc") {
|
|
295
|
+
const content = llamada.args.content;
|
|
288
296
|
const docsBase = requireBasePath(DOCS);
|
|
289
|
-
const fullPath = resolveDocPath(args);
|
|
290
|
-
if (!
|
|
297
|
+
const fullPath = resolveDocPath(llamada.args);
|
|
298
|
+
if (!estaDentroReal(docsBase, fullPath)) {
|
|
291
299
|
throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
|
|
292
300
|
}
|
|
293
301
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
@@ -296,15 +304,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
296
304
|
content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}` }],
|
|
297
305
|
};
|
|
298
306
|
}
|
|
299
|
-
if (
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const reposBase = requireBasePath(REPOS);
|
|
303
|
-
const fullPath = path.join(reposBase, repoName, filePath);
|
|
304
|
-
if (!estaDentro(reposBase, fullPath)) {
|
|
305
|
-
throw new Error(`Acceso denegado: la ruta queda fuera de ${reposBase}.`);
|
|
306
|
-
}
|
|
307
|
-
const content = fs.readFileSync(fullPath, "utf-8");
|
|
307
|
+
if (llamada.tool === "read_cross_repo") {
|
|
308
|
+
const fullPath = resolveRepoPath(llamada.args.repo_name, llamada.args.file_path);
|
|
309
|
+
const content = leerTextoAcotado(fullPath);
|
|
308
310
|
return {
|
|
309
311
|
content: [{ type: "text", text: content }],
|
|
310
312
|
};
|
|
@@ -335,36 +337,36 @@ function reportarRutas() {
|
|
|
335
337
|
// una terminal —que es exactamente lo que hace quien acaba de leer `npx mcp-phase-gate` en el
|
|
336
338
|
// README— se queda esperando entrada, sin señal de vida, y parece roto. Estas dos banderas
|
|
337
339
|
// existen para que ese momento devuelva una explicación en vez de un cursor parpadeando.
|
|
338
|
-
const AYUDA = `${SERVER_NAME} v${SERVER_VERSION}
|
|
339
|
-
|
|
340
|
-
Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada
|
|
341
|
-
una deje su documento en una documentación central.
|
|
342
|
-
|
|
343
|
-
Esto NO se ejecuta a mano: lo lanza tu cliente MCP. Ejecutado directamente se queda esperando
|
|
344
|
-
mensajes JSON-RPC por stdin, que es lo que parece que se colgó.
|
|
345
|
-
|
|
346
|
-
Regístralo en tu cliente (Cursor, VS Code, Claude Code) así:
|
|
347
|
-
|
|
348
|
-
{
|
|
349
|
-
"mcpServers": {
|
|
350
|
-
"${SERVER_NAME}": {
|
|
351
|
-
"command": "npx",
|
|
352
|
-
"args": ["-y", "mcp-phase-gate"],
|
|
353
|
-
"env": {
|
|
354
|
-
"ORQUESTADOR_DOCS_PATH": "<tu carpeta de documentación>",
|
|
355
|
-
"ORQUESTADOR_REPOS_PATH": "<tu carpeta de repositorios>"
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
Variables de entorno:
|
|
362
|
-
ORQUESTADOR_DOCS_PATH Obligatoria. Raíz de la Documentación Central.
|
|
363
|
-
ORQUESTADOR_REPOS_PATH Obligatoria. Carpeta con tus repositorios de código.
|
|
364
|
-
ORQUESTADOR_TASKS_SUBDIR Subcarpeta de tareas dentro de cada proyecto. Por defecto "Proyectos".
|
|
365
|
-
ORQUESTADOR_PROMPTS_PATH Carpeta de prompts propios, que se superponen a los del paquete.
|
|
366
|
-
ORQUESTADOR_STATE_PATH Dónde guardar los punteros de tarea activa.
|
|
367
|
-
|
|
340
|
+
const AYUDA = `${SERVER_NAME} v${SERVER_VERSION}
|
|
341
|
+
|
|
342
|
+
Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada
|
|
343
|
+
una deje su documento en una documentación central.
|
|
344
|
+
|
|
345
|
+
Esto NO se ejecuta a mano: lo lanza tu cliente MCP. Ejecutado directamente se queda esperando
|
|
346
|
+
mensajes JSON-RPC por stdin, que es lo que parece que se colgó.
|
|
347
|
+
|
|
348
|
+
Regístralo en tu cliente (Cursor, VS Code, Claude Code) así:
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
"mcpServers": {
|
|
352
|
+
"${SERVER_NAME}": {
|
|
353
|
+
"command": "npx",
|
|
354
|
+
"args": ["-y", "mcp-phase-gate"],
|
|
355
|
+
"env": {
|
|
356
|
+
"ORQUESTADOR_DOCS_PATH": "<tu carpeta de documentación>",
|
|
357
|
+
"ORQUESTADOR_REPOS_PATH": "<tu carpeta de repositorios>"
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
Variables de entorno:
|
|
364
|
+
ORQUESTADOR_DOCS_PATH Obligatoria. Raíz de la Documentación Central.
|
|
365
|
+
ORQUESTADOR_REPOS_PATH Obligatoria. Carpeta con tus repositorios de código.
|
|
366
|
+
ORQUESTADOR_TASKS_SUBDIR Subcarpeta de tareas dentro de cada proyecto. Por defecto "Proyectos".
|
|
367
|
+
ORQUESTADOR_PROMPTS_PATH Carpeta de prompts propios, que se superponen a los del paquete.
|
|
368
|
+
ORQUESTADOR_STATE_PATH Dónde guardar los punteros de tarea activa.
|
|
369
|
+
|
|
368
370
|
Documentación: https://github.com/Montse2308/mcp-phase-gate`;
|
|
369
371
|
async function main() {
|
|
370
372
|
const bandera = process.argv[2];
|
package/build/paths.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare function estaDentro(base: string, ruta: string): boolean;
|
|
2
|
+
export declare function rutaReal(ruta: string): string;
|
|
3
|
+
export declare function estaDentroReal(base: string, ruta: string): boolean;
|
|
4
|
+
export declare function validarSegmento(valor: string, campo: string): string;
|
|
2
5
|
export declare function discoverProjects(): Map<string, string>;
|
|
3
6
|
export declare function describeProjects(): string;
|
|
4
7
|
export declare function findProjectFolder(project: string): string | null;
|
|
@@ -6,5 +9,14 @@ export declare function requireProjectFolder(project: string): string;
|
|
|
6
9
|
export declare function taskFolderPath(carpetaProyecto: string, taskName: string): string;
|
|
7
10
|
export declare function resolveTaskFolder(project: string, taskName: string): string;
|
|
8
11
|
export declare function tasksRootPath(carpetaProyecto: string): string;
|
|
9
|
-
export
|
|
12
|
+
export type ArgumentosDeDocumento = {
|
|
13
|
+
project?: string | undefined;
|
|
14
|
+
task_name?: string | undefined;
|
|
15
|
+
file_name?: string | undefined;
|
|
16
|
+
file_path?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveDocPath(args: ArgumentosDeDocumento): string;
|
|
19
|
+
export declare function resolveRepoPath(repoName: string, filePath: string): string;
|
|
20
|
+
export declare const LIMITE_LECTURA_BYTES = 1048576;
|
|
21
|
+
export declare function leerTextoAcotado(ruta: string): string;
|
|
10
22
|
//# sourceMappingURL=paths.d.ts.map
|
package/build/paths.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// busca. Por eso además de armarlas hay que comprobar que caigan dentro de la base.
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
|
-
import { DOCS, requireBasePath, tasksSubdir } from "./config.js";
|
|
9
|
+
import { DOCS, REPOS, requireBasePath, tasksSubdir } from "./config.js";
|
|
10
10
|
// Comprueba que una ruta quede dentro de otra. No sirve startsWith, y falla en las dos
|
|
11
11
|
// direcciones: con base "C:\PROYECTOS\TRABAJO" deja pasar "C:\PROYECTOS\TRABAJO_VIEJO",
|
|
12
12
|
// que empieza igual sin estar dentro; y rechaza rutas válidas cuando la base viene escrita
|
|
@@ -15,6 +15,60 @@ export function estaDentro(base, ruta) {
|
|
|
15
15
|
const relativa = path.relative(path.resolve(base), path.resolve(ruta));
|
|
16
16
|
return relativa === "" || (!relativa.startsWith("..") && !path.isAbsolute(relativa));
|
|
17
17
|
}
|
|
18
|
+
// Resuelve symlinks y junctions del ancestro existente más cercano y vuelve a unir el resto.
|
|
19
|
+
// Hace falta porque la ruta que se va a escribir casi nunca existe todavía, y realpath solo
|
|
20
|
+
// funciona sobre lo que existe. "Existe" se mira con lstat y no con existsSync: un enlace roto
|
|
21
|
+
// cuenta como existente, realpath falla sobre él y la operación se rechaza. Con existsSync se
|
|
22
|
+
// saltaría, y escribir a través de él crearía su destino, esté donde esté.
|
|
23
|
+
export function rutaReal(ruta) {
|
|
24
|
+
let existente = path.resolve(ruta);
|
|
25
|
+
const pendientes = [];
|
|
26
|
+
while (!fs.lstatSync(existente, { throwIfNoEntry: false })) {
|
|
27
|
+
const padre = path.dirname(existente);
|
|
28
|
+
if (padre === existente)
|
|
29
|
+
break; // ni la raíz existe: que realpath dé el error
|
|
30
|
+
pendientes.unshift(path.basename(existente));
|
|
31
|
+
existente = padre;
|
|
32
|
+
}
|
|
33
|
+
return path.join(fs.realpathSync.native(existente), ...pendientes);
|
|
34
|
+
}
|
|
35
|
+
// estaDentro sobre rutas reales. Comparar el texto no basta: un symlink dentro de la
|
|
36
|
+
// Documentación que apunta fuera tiene una ruta que "está dentro" y un destino que no.
|
|
37
|
+
// La base también se resuelve: una DOCS que es symlink sigue valiendo.
|
|
38
|
+
export function estaDentroReal(base, ruta) {
|
|
39
|
+
return estaDentro(rutaReal(base), rutaReal(ruta));
|
|
40
|
+
}
|
|
41
|
+
// Un nombre de proyecto, tarea o repo es UN segmento de ruta, portable entre sistemas:
|
|
42
|
+
// la Documentación viaja entre equipos con Windows, macOS y Linux. Lo que se rechaza es lo
|
|
43
|
+
// que hoy ya era peligroso (separadores, "..") o lo que Windows no puede guardar y rompe la
|
|
44
|
+
// sincronización (caracteres reservados, punto o espacio final, CON, NUL…).
|
|
45
|
+
const CARACTERES_RESERVADOS = /[<>:"|?*]/;
|
|
46
|
+
const CARACTERES_DE_CONTROL = /[\u0000-\u001f\u007f]/;
|
|
47
|
+
// Windows también reserva COM y LPT con los superíndices ¹²³, y las consolas CONIN$ y CONOUT$.
|
|
48
|
+
// Los espacios antes de la extensión no cuentan: "nul .txt" sigue siendo NUL.
|
|
49
|
+
const NOMBRES_RESERVADOS_WINDOWS = /^(con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³]) *(\..*)?$/i;
|
|
50
|
+
export function validarSegmento(valor, campo) {
|
|
51
|
+
const incumple = (regla) => {
|
|
52
|
+
throw new Error(`${campo} ${JSON.stringify(valor)} no es válido: ${regla}. Tiene que ser un solo nombre de ` +
|
|
53
|
+
`carpeta o archivo, que funcione igual en Windows, macOS y Linux.`);
|
|
54
|
+
};
|
|
55
|
+
if (valor.trim() === "")
|
|
56
|
+
incumple("está vacío");
|
|
57
|
+
if (valor === "." || valor === "..")
|
|
58
|
+
incumple(`no puede ser "${valor}"`);
|
|
59
|
+
if (/[\\/]/.test(valor))
|
|
60
|
+
incumple("no puede llevar / ni \\");
|
|
61
|
+
if (CARACTERES_DE_CONTROL.test(valor))
|
|
62
|
+
incumple("no puede llevar caracteres de control");
|
|
63
|
+
if (CARACTERES_RESERVADOS.test(valor))
|
|
64
|
+
incumple('no puede llevar ninguno de < > : " | ? *');
|
|
65
|
+
if (/[. ]$/.test(valor))
|
|
66
|
+
incumple("no puede terminar en punto ni en espacio");
|
|
67
|
+
if (NOMBRES_RESERVADOS_WINDOWS.test(valor)) {
|
|
68
|
+
incumple("es un nombre reservado de Windows (CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9)");
|
|
69
|
+
}
|
|
70
|
+
return valor;
|
|
71
|
+
}
|
|
18
72
|
// ─── Proyectos ──────────────────────────────────────────────────────────────
|
|
19
73
|
// Los proyectos no se declaran en ninguna parte: son las carpetas que existen. Antes había
|
|
20
74
|
// una tabla fija con los proyectos de quien escribió el código, que no restringía nada
|
|
@@ -62,6 +116,7 @@ export function requireProjectFolder(project) {
|
|
|
62
116
|
// La ruta de una tarea SIEMPRE se arma aquí, en un solo lugar.
|
|
63
117
|
// Estructura: <ORQUESTADOR_DOCS_PATH>/<PROYECTO>/<subcarpeta de tareas>/<tarea>
|
|
64
118
|
export function taskFolderPath(carpetaProyecto, taskName) {
|
|
119
|
+
validarSegmento(taskName, "task_name");
|
|
65
120
|
return path.join(requireBasePath(DOCS), carpetaProyecto, tasksSubdir(), taskName);
|
|
66
121
|
}
|
|
67
122
|
export function resolveTaskFolder(project, taskName) {
|
|
@@ -74,16 +129,113 @@ export function tasksRootPath(carpetaProyecto) {
|
|
|
74
129
|
}
|
|
75
130
|
// Resuelve la ruta absoluta para read/write. Prioriza project+task_name+file_name;
|
|
76
131
|
// si no, cae al modo legacy con file_path relativo a la Documentación Central.
|
|
132
|
+
//
|
|
133
|
+
// Una ruta relativa hecha de segmentos portables. Cada segmento se valida por separado para que
|
|
134
|
+
// una subcarpeta legítima como "04 - Ejecución/L01 - x.md" siga valiendo.
|
|
135
|
+
function validarRutaRelativa(valor, campo, relativaA) {
|
|
136
|
+
if (path.isAbsolute(valor)) {
|
|
137
|
+
throw new Error(`${campo} no puede ser una ruta absoluta ("${valor}"): es relativo a ${relativaA}.`);
|
|
138
|
+
}
|
|
139
|
+
for (const segmento of valor.split(/[\\/]/))
|
|
140
|
+
validarSegmento(segmento, campo);
|
|
141
|
+
}
|
|
142
|
+
// Con file_name la ruta tiene que quedar dentro de la carpeta de la tarea, no solo dentro de
|
|
143
|
+
// la base: antes "../otra-tarea/x.md" escribía en otra tarea y la comprobación de la tool,
|
|
144
|
+
// que solo miraba la base, lo dejaba pasar.
|
|
145
|
+
//
|
|
146
|
+
// Con file_path basta con no salir de la base (la restricción a carpetas de tarea llega en la
|
|
147
|
+
// Etapa 1), pero sus segmentos también tienen que ser portables: antes creaba dentro de la
|
|
148
|
+
// Documentación un "CON", un "nul.md" o un flujo alterno "nota.md:secreto", que el Explorador
|
|
149
|
+
// y OneDrive no saben manejar.
|
|
77
150
|
export function resolveDocPath(args) {
|
|
78
|
-
const project =
|
|
79
|
-
const taskName = args?.task_name ? String(args.task_name) : "";
|
|
80
|
-
const fileName = args?.file_name ? String(args.file_name) : "";
|
|
151
|
+
const { project = "", task_name: taskName = "", file_name: fileName = "", file_path: filePath = "" } = args;
|
|
81
152
|
if (project && taskName && fileName) {
|
|
82
|
-
|
|
153
|
+
const carpetaTarea = resolveTaskFolder(project, taskName);
|
|
154
|
+
validarRutaRelativa(fileName, "file_name", "la carpeta de la tarea");
|
|
155
|
+
const ruta = path.join(carpetaTarea, fileName);
|
|
156
|
+
if (!estaDentroReal(carpetaTarea, ruta)) {
|
|
157
|
+
throw new Error(`Acceso denegado: file_name "${fileName}" queda fuera de la carpeta de la tarea ${carpetaTarea}.`);
|
|
158
|
+
}
|
|
159
|
+
return ruta;
|
|
83
160
|
}
|
|
84
|
-
if (
|
|
85
|
-
|
|
161
|
+
if (filePath) {
|
|
162
|
+
const docsBase = requireBasePath(DOCS);
|
|
163
|
+
validarRutaRelativa(filePath, "file_path", "la Documentación Central");
|
|
164
|
+
const ruta = path.join(docsBase, filePath);
|
|
165
|
+
if (!estaDentroReal(docsBase, ruta)) {
|
|
166
|
+
throw new Error(`Acceso denegado: file_path queda fuera de ${docsBase}.`);
|
|
167
|
+
}
|
|
168
|
+
return ruta;
|
|
86
169
|
}
|
|
87
170
|
throw new Error("Debes proporcionar (project + task_name + file_name) o bien file_path.");
|
|
88
171
|
}
|
|
172
|
+
// ─── Repos ──────────────────────────────────────────────────────────────────
|
|
173
|
+
// Ruta de un archivo dentro de UN repo. Antes la comprobación miraba la carpeta de repos
|
|
174
|
+
// entera, y desde repoA "../repoB/.env" pasaba: estaba dentro de la carpeta de repos. El repo
|
|
175
|
+
// se resuelve a su ruta real para que un repo que es un enlace a otra unidad siga valiendo.
|
|
176
|
+
export function resolveRepoPath(repoName, filePath) {
|
|
177
|
+
validarSegmento(repoName, "repo_name");
|
|
178
|
+
const repo = path.join(requireBasePath(REPOS), repoName);
|
|
179
|
+
const repoReal = rutaReal(repo);
|
|
180
|
+
const ruta = path.join(repo, filePath);
|
|
181
|
+
if (!estaDentroReal(repoReal, ruta)) {
|
|
182
|
+
throw new Error(`Acceso denegado: file_path "${filePath}" queda fuera del repositorio ${repoName}.`);
|
|
183
|
+
}
|
|
184
|
+
return ruta;
|
|
185
|
+
}
|
|
186
|
+
// ─── Lectura acotada ────────────────────────────────────────────────────────
|
|
187
|
+
// Lo que leen las tools va entero al contexto del modelo. Un volcado de varios megas o un
|
|
188
|
+
// binario no le sirven de nada y se comen la conversación, así que se cortan antes de leer.
|
|
189
|
+
export const LIMITE_LECTURA_BYTES = 1_048_576;
|
|
190
|
+
// La misma heurística que usa git para decidir si un archivo es binario.
|
|
191
|
+
const BYTES_PARA_DETECTAR_BINARIO = 8000;
|
|
192
|
+
// Se lee del descriptor y no de la ruta: tipo, tamaño y contenido salen del mismo archivo
|
|
193
|
+
// abierto. Con statSync seguido de readFileSync, un archivo que crecía entre las dos llamadas
|
|
194
|
+
// se leía entero, sin tope. Y nunca se piden más de LIMITE + 1 bytes, que es lo justo para
|
|
195
|
+
// saber si se pasa. O_NONBLOCK, que no existe en Windows, evita que abrir un FIFO se quede
|
|
196
|
+
// esperando a que alguien escriba en él.
|
|
197
|
+
//
|
|
198
|
+
// Riesgo aceptado: la contención se comprueba sobre la ruta real y aquí se abre la ruta tal
|
|
199
|
+
// cual, así que cambiar un enlace justo entre las dos cosas abriría otro destino. Cerrarlo
|
|
200
|
+
// exige abrir sin seguir enlaces componente a componente, que Node no ofrece de forma portable.
|
|
201
|
+
export function leerTextoAcotado(ruta) {
|
|
202
|
+
let descriptor;
|
|
203
|
+
try {
|
|
204
|
+
descriptor = fs.openSync(ruta, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error?.code === "EISDIR")
|
|
208
|
+
throw new Error(`${ruta} no es un archivo: es una carpeta.`);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const info = fs.fstatSync(descriptor);
|
|
213
|
+
if (!info.isFile()) {
|
|
214
|
+
throw new Error(`${ruta} no es un archivo normal: es una carpeta, un dispositivo o una tubería.`);
|
|
215
|
+
}
|
|
216
|
+
if (info.size > LIMITE_LECTURA_BYTES) {
|
|
217
|
+
throw new Error(`${ruta} pesa ${info.size} bytes y el límite de lectura es 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
|
|
218
|
+
}
|
|
219
|
+
const buffer = Buffer.alloc(LIMITE_LECTURA_BYTES + 1);
|
|
220
|
+
let leidos = 0;
|
|
221
|
+
while (leidos < buffer.length) {
|
|
222
|
+
const bytes = fs.readSync(descriptor, buffer, leidos, buffer.length - leidos, null);
|
|
223
|
+
if (bytes === 0)
|
|
224
|
+
break;
|
|
225
|
+
leidos += bytes;
|
|
226
|
+
}
|
|
227
|
+
if (leidos > LIMITE_LECTURA_BYTES) {
|
|
228
|
+
throw new Error(`${ruta} creció mientras se leía y pasa del límite de lectura de 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
|
|
229
|
+
}
|
|
230
|
+
const contenido = buffer.subarray(0, leidos);
|
|
231
|
+
if (contenido.subarray(0, BYTES_PARA_DETECTAR_BINARIO).includes(0)) {
|
|
232
|
+
throw new Error(`${ruta} parece binario: tiene un byte NUL en sus primeros ${BYTES_PARA_DETECTAR_BINARIO} bytes. ` +
|
|
233
|
+
`Estas tools solo leen texto.`);
|
|
234
|
+
}
|
|
235
|
+
return contenido.toString("utf-8");
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
fs.closeSync(descriptor);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
89
241
|
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const startTaskShape: {
|
|
3
|
+
project: z.ZodString;
|
|
4
|
+
task_name: z.ZodString;
|
|
5
|
+
initial_context: z.ZodString;
|
|
6
|
+
crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
7
|
+
};
|
|
8
|
+
export declare const getPhasePromptShape: {
|
|
9
|
+
phase: z.ZodNumber;
|
|
10
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
12
|
+
};
|
|
13
|
+
export declare const getActiveTaskShape: {
|
|
14
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
15
|
+
};
|
|
16
|
+
export declare const listTasksShape: {
|
|
17
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
18
|
+
};
|
|
19
|
+
export declare const switchTaskShape: {
|
|
20
|
+
project: z.ZodString;
|
|
21
|
+
task_name: z.ZodString;
|
|
22
|
+
};
|
|
23
|
+
export declare const readCentralDocShape: {
|
|
24
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
25
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
26
|
+
file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
27
|
+
file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
28
|
+
};
|
|
29
|
+
export declare const writeCentralDocShape: {
|
|
30
|
+
content: z.ZodString;
|
|
31
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
32
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
33
|
+
file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
34
|
+
file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
35
|
+
};
|
|
36
|
+
export declare const readCrossRepoShape: {
|
|
37
|
+
repo_name: z.ZodString;
|
|
38
|
+
file_path: z.ZodString;
|
|
39
|
+
};
|
|
40
|
+
declare const SHAPES: {
|
|
41
|
+
start_task: {
|
|
42
|
+
project: z.ZodString;
|
|
43
|
+
task_name: z.ZodString;
|
|
44
|
+
initial_context: z.ZodString;
|
|
45
|
+
crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
46
|
+
};
|
|
47
|
+
get_phase_prompt: {
|
|
48
|
+
phase: z.ZodNumber;
|
|
49
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
50
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
51
|
+
};
|
|
52
|
+
get_active_task: {
|
|
53
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
};
|
|
55
|
+
list_tasks: {
|
|
56
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
57
|
+
};
|
|
58
|
+
switch_task: {
|
|
59
|
+
project: z.ZodString;
|
|
60
|
+
task_name: z.ZodString;
|
|
61
|
+
};
|
|
62
|
+
read_central_doc: {
|
|
63
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
64
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
65
|
+
file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
66
|
+
file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
67
|
+
};
|
|
68
|
+
write_central_doc: {
|
|
69
|
+
content: z.ZodString;
|
|
70
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
71
|
+
task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
72
|
+
file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
73
|
+
file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
74
|
+
};
|
|
75
|
+
read_cross_repo: {
|
|
76
|
+
repo_name: z.ZodString;
|
|
77
|
+
file_path: z.ZodString;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
type NombreTool = keyof typeof SHAPES;
|
|
81
|
+
type SinNulos<T> = {
|
|
82
|
+
[K in keyof T]: Exclude<T[K], null>;
|
|
83
|
+
};
|
|
84
|
+
export type LlamadaValidada = {
|
|
85
|
+
[K in NombreTool]: {
|
|
86
|
+
tool: K;
|
|
87
|
+
args: SinNulos<z.output<z.ZodObject<(typeof SHAPES)[K]>>>;
|
|
88
|
+
};
|
|
89
|
+
}[NombreTool];
|
|
90
|
+
export declare function validarLlamada(tool: string, args: unknown): LlamadaValidada;
|
|
91
|
+
export {};
|
|
92
|
+
//# sourceMappingURL=schemas.d.ts.map
|
package/build/schemas.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Los argumentos de cada tool, declarados una sola vez con zod.
|
|
2
|
+
//
|
|
3
|
+
// Antes cada tool leía sus argumentos con String(args?.x), que no valida nada: sin task_name,
|
|
4
|
+
// String(undefined) creaba la tarea "undefined/" y la dejaba activa. Ahora todo pasa por aquí
|
|
5
|
+
// antes de usarse, y el error nombra el campo.
|
|
6
|
+
//
|
|
7
|
+
// Se exportan shapes (los campos sueltos) y no z.object(...): es lo que recibe registerTool
|
|
8
|
+
// del SDK, al que se pasarán tal cual en la migración a McpServer (Etapa 2).
|
|
9
|
+
//
|
|
10
|
+
// Los opcionales son .nullish() y null cuenta como ausente: algunos clientes con modelos en
|
|
11
|
+
// modo estricto mandan null en todo campo opcional. Un número en texto (phase: "3") no se
|
|
12
|
+
// convierte: se rechaza con un mensaje que dice qué se espera. Es raro, el modelo lo corrige
|
|
13
|
+
// al primer intento, y convertirlo complicaría el JSON Schema que se generará desde aquí.
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
export const startTaskShape = {
|
|
16
|
+
project: z.string().trim().min(1, "Falta `project`."),
|
|
17
|
+
task_name: z.string().min(1, "Falta `task_name`."),
|
|
18
|
+
initial_context: z.string().min(1, "Falta `initial_context`."),
|
|
19
|
+
crear_proyecto: z.boolean().nullish(),
|
|
20
|
+
};
|
|
21
|
+
export const getPhasePromptShape = {
|
|
22
|
+
phase: z.number().int(),
|
|
23
|
+
project: z.string().nullish(),
|
|
24
|
+
task_name: z.string().nullish(),
|
|
25
|
+
};
|
|
26
|
+
export const getActiveTaskShape = {
|
|
27
|
+
project: z.string().nullish(),
|
|
28
|
+
};
|
|
29
|
+
export const listTasksShape = {
|
|
30
|
+
project: z.string().nullish(),
|
|
31
|
+
};
|
|
32
|
+
export const switchTaskShape = {
|
|
33
|
+
project: z.string().trim().min(1, "Falta `project`."),
|
|
34
|
+
task_name: z.string().min(1, "Falta `task_name`."),
|
|
35
|
+
};
|
|
36
|
+
// Los cuatro son opcionales por separado: qué combinación vale lo decide resolveDocPath.
|
|
37
|
+
export const readCentralDocShape = {
|
|
38
|
+
project: z.string().nullish(),
|
|
39
|
+
task_name: z.string().nullish(),
|
|
40
|
+
file_name: z.string().nullish(),
|
|
41
|
+
file_path: z.string().nullish(),
|
|
42
|
+
};
|
|
43
|
+
export const writeCentralDocShape = {
|
|
44
|
+
...readCentralDocShape,
|
|
45
|
+
content: z.string(),
|
|
46
|
+
};
|
|
47
|
+
export const readCrossRepoShape = {
|
|
48
|
+
repo_name: z.string().min(1, "Falta `repo_name`."),
|
|
49
|
+
file_path: z.string().min(1, "Falta `file_path`."),
|
|
50
|
+
};
|
|
51
|
+
const SHAPES = {
|
|
52
|
+
start_task: startTaskShape,
|
|
53
|
+
get_phase_prompt: getPhasePromptShape,
|
|
54
|
+
get_active_task: getActiveTaskShape,
|
|
55
|
+
list_tasks: listTasksShape,
|
|
56
|
+
switch_task: switchTaskShape,
|
|
57
|
+
read_central_doc: readCentralDocShape,
|
|
58
|
+
write_central_doc: writeCentralDocShape,
|
|
59
|
+
read_cross_repo: readCrossRepoShape,
|
|
60
|
+
};
|
|
61
|
+
// Lo que se espera de cada tipo, dicho como se le diría al modelo para que corrija la llamada.
|
|
62
|
+
// El único número es `phase`, de ahí el ejemplo.
|
|
63
|
+
const TIPOS = {
|
|
64
|
+
string: "un texto",
|
|
65
|
+
number: "un número, por ejemplo 3",
|
|
66
|
+
int: "un número entero, por ejemplo 3",
|
|
67
|
+
boolean: "true o false",
|
|
68
|
+
object: "un objeto",
|
|
69
|
+
};
|
|
70
|
+
function describirProblema(issue, recibidos) {
|
|
71
|
+
const campo = issue.path.map(String).join(".");
|
|
72
|
+
if (!campo)
|
|
73
|
+
return "los argumentos tienen que ser un objeto.";
|
|
74
|
+
const nombre = `\`${campo}\``;
|
|
75
|
+
// null también es "falta": en un opcional se tolera, en un obligatorio es lo mismo que no venir.
|
|
76
|
+
if (issue.code === "invalid_type") {
|
|
77
|
+
return recibidos[campo] == null
|
|
78
|
+
? `Falta ${nombre}.`
|
|
79
|
+
: `${nombre} debe ser ${TIPOS[issue.expected] ?? issue.expected}.`;
|
|
80
|
+
}
|
|
81
|
+
// Los mínimos llevan su mensaje en español en el propio shape ("Falta `task_name`.").
|
|
82
|
+
if (issue.code === "too_small")
|
|
83
|
+
return issue.message;
|
|
84
|
+
// Los máximos no: el único que puede saltar es el del entero seguro de `phase`.
|
|
85
|
+
if (issue.code === "too_big")
|
|
86
|
+
return `${nombre} es demasiado grande.`;
|
|
87
|
+
return `${nombre}: ${issue.message}.`;
|
|
88
|
+
}
|
|
89
|
+
export function validarLlamada(tool, args) {
|
|
90
|
+
if (!Object.hasOwn(SHAPES, tool)) {
|
|
91
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
92
|
+
}
|
|
93
|
+
const resultado = z.object(SHAPES[tool]).safeParse(args ?? {});
|
|
94
|
+
if (!resultado.success) {
|
|
95
|
+
const recibidos = (args && typeof args === "object" ? args : {});
|
|
96
|
+
const problemas = resultado.error.issues.map((issue) => describirProblema(issue, recibidos));
|
|
97
|
+
throw new Error(`Argumentos no válidos. ${problemas.join(" ")}`);
|
|
98
|
+
}
|
|
99
|
+
// null cuenta como ausente: se quita aquí, una vez, para que ninguna tool tenga que
|
|
100
|
+
// distinguir entre un campo que no vino y uno que vino vacío.
|
|
101
|
+
const sinNulos = Object.fromEntries(Object.entries(resultado.data).filter(([, valor]) => valor !== null));
|
|
102
|
+
// El cast es seguro: los datos los acaba de producir el shape de esta misma tool.
|
|
103
|
+
return { tool, args: sinNulos };
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=schemas.js.map
|