mcp-phase-gate 2.0.0 → 2.1.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/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, estaDentro, findProjectFolder, requireProjectFolder, resolveDocPath, taskFolderPath, } from "./paths.js";
14
+ import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, ubicarEnTarea, validarNombreVisible, validarSegmento, } from "./paths.js";
15
15
  import { describePhases, discoverPhases, loadPhasePrompt, nombreContextoInicial, } from "./phases.js";
16
- import { avisoDeCompuerta, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, tareaActiva, } from "./tasks.js";
16
+ import { avisoDeCompuerta, bloqueDeRecibos, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, motivoDeProteccion, tareaActiva, tareaDelPrompt, } 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,
@@ -115,8 +116,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
115
116
  project: { type: "string", description: `Proyecto, uno de los existentes: ${PROJECTS_DESCRIPTION}. Úsalo junto con task_name y file_name.` },
116
117
  task_name: { type: "string", description: "Nombre de la carpeta de la tarea." },
117
118
  file_name: { type: "string", description: "Nombre (o ruta relativa) del archivo dentro de la carpeta de la tarea (ej. '01 - Análisis Técnico.md')." },
118
- file_path: { type: "string", description: "ALTERNATIVA: ruta relativa dentro de DOCUMENTACIÓN. Solo si no usas project+task_name." },
119
- content: { type: "string", description: "Contenido a escribir" }
119
+ file_path: { type: "string", description: "ALTERNATIVA: ruta relativa dentro de DOCUMENTACIÓN, que tiene que caer dentro de la carpeta de una tarea que ya existe (ej. 'PROYECTO-A/Proyectos/tarea/01 - Análisis Técnico.md'). Los archivos del proyecto no se escriben con esta tool. Solo si no usas project+task_name." },
120
+ content: { type: "string", description: "Contenido a escribir" },
121
+ force: { type: "boolean", description: "Solo con confirmación explícita del usuario en el chat. Permite reescribir un documento congelado o el contexto inicial." }
120
122
  },
121
123
  required: ["content"],
122
124
  },
@@ -139,25 +141,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
139
141
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
140
142
  const { name, arguments: args } = request.params;
141
143
  try {
142
- if (name === "start_task") {
143
- const proj = String(args?.project).trim();
144
- const taskName = String(args?.task_name);
145
- const context = String(args?.initial_context);
144
+ // Todo argumento se valida aquí, antes de que ninguna tool lo use. Un campo ausente o de
145
+ // otro tipo devuelve un error que lo nombra, en vez de convertirse en "undefined".
146
+ const llamada = validarLlamada(name, args);
147
+ if (llamada.tool === "start_task") {
148
+ const proj = llamada.args.project; // el schema ya le quitó los espacios
149
+ const taskName = llamada.args.task_name;
150
+ const context = llamada.args.initial_context;
146
151
  // Estrenar proyecto tiene que ser deliberado: sin esta puerta, un nombre mal escrito
147
152
  // creaba una carpeta suelta en la Documentación y nadie se enteraba hasta buscarla.
148
153
  let carpetaProyecto = findProjectFolder(proj);
149
154
  let estrenado = false;
150
155
  if (!carpetaProyecto) {
151
- if (args?.crear_proyecto !== true) {
156
+ if (llamada.args.crear_proyecto !== true) {
152
157
  throw new Error(`No existe el proyecto "${proj}". Los proyectos disponibles son: ${describeProjects()}. ` +
153
158
  `Si el usuario te confirmó que quiere estrenar el proyecto "${proj.toUpperCase()}", ` +
154
159
  `vuelve a llamar a start_task con crear_proyecto: true.`);
155
160
  }
161
+ // El nombre se vuelve carpeta tal cual: "../evil" la creaba fuera de la Documentación.
162
+ validarSegmento(proj, "project");
163
+ validarNombreVisible(proj, "project");
156
164
  carpetaProyecto = proj.toUpperCase();
157
165
  estrenado = true;
158
166
  }
167
+ validarNombreVisible(taskName, "task_name");
159
168
  const carpetaTarea = taskFolderPath(carpetaProyecto, taskName);
160
169
  const contextoInicial = path.join(carpetaTarea, nombreContextoInicial());
170
+ const docsBase = requireBasePath(DOCS);
171
+ if (!estaDentroReal(docsBase, carpetaTarea)) {
172
+ throw new Error(`Acceso denegado: la carpeta de la tarea queda fuera de ${docsBase}.`);
173
+ }
161
174
  // start_task escribe el contexto inicial sin preguntar, así que llamarlo sobre una tarea
162
175
  // que ya existe borraba el requerimiento original — el único documento que no se puede
163
176
  // regenerar leyendo el código. Retomar una tarea es switch_task, que no toca archivos.
@@ -178,31 +191,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
178
191
  content: [{ type: "text", text: `Tarea '${taskName}' inicializada correctamente. Archivo guardado en: ${contextoInicial}\nTarea activa de ${carpetaProyecto}: ${taskName}.${aviso}` }],
179
192
  };
180
193
  }
181
- if (name === "get_phase_prompt") {
182
- const phase = Number(args?.phase);
183
- if (!Number.isInteger(phase)) {
184
- throw new Error(`Debes indicar un número de fase. Disponibles: ${PHASES_DESCRIPTION}.`);
185
- }
194
+ if (llamada.tool === "get_phase_prompt") {
195
+ // Que sea un entero ya lo garantiza el schema; si la fase no existe, lo dice loadPhasePrompt.
196
+ const { phase, project, task_name } = llamada.args;
186
197
  const prompt = loadPhasePrompt(phase);
187
198
  // La compuerta avisa, no bloquea: el prompt se entrega siempre. Si comprobarla fallara
188
199
  // por lo que sea, entregarlo igual es mejor que dejar al usuario sin fase por un aviso.
200
+ // Lo mismo con la lista de recibos: es una ayuda, no una condición.
189
201
  let aviso = null;
202
+ let recibos = null;
190
203
  try {
191
- aviso = avisoDeCompuerta(phase, discoverPhases(), {
192
- ...(args?.project ? { project: String(args.project) } : {}),
193
- ...(args?.task_name ? { taskName: String(args.task_name) } : {}),
194
- });
204
+ const phases = discoverPhases();
205
+ const opciones = { ...(project ? { project } : {}), ...(task_name ? { taskName: task_name } : {}) };
206
+ aviso = avisoDeCompuerta(phase, phases, opciones);
207
+ const estado = tareaDelPrompt(phases, opciones);
208
+ if (estado && estado !== "ambigua")
209
+ recibos = bloqueDeRecibos(phase, estado, phases);
195
210
  }
196
211
  catch (error) {
197
212
  console.error(`[orquestador] No se pudo comprobar la compuerta: ${error.message}`);
198
213
  }
214
+ const texto = [aviso, prompt, recibos].filter(Boolean).join("\n\n");
199
215
  return {
200
- content: [{ type: "text", text: aviso ? `${aviso}\n\n${prompt}` : prompt }],
216
+ content: [{ type: "text", text: texto }],
201
217
  };
202
218
  }
203
- if (name === "get_active_task") {
219
+ if (llamada.tool === "get_active_task") {
204
220
  const phases = discoverPhases();
205
- const pedido = args?.project ? String(args.project) : "";
221
+ const pedido = llamada.args.project ?? "";
206
222
  // Con proyecto: la suya. Sin proyecto: la de cada uno. Antes había un único puntero
207
223
  // global y responder "la activa" a secas obligaba a adivinar cuál de dos ventanas
208
224
  // preguntaba; listar todas es la respuesta honesta cuando no se sabe.
@@ -230,9 +246,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
230
246
  content: [{ type: "text", text: `${bloques.join("\n\n")}\n\nPunteros guardados en: ${stateFile()}` }],
231
247
  };
232
248
  }
233
- if (name === "list_tasks") {
249
+ if (llamada.tool === "list_tasks") {
234
250
  const phases = discoverPhases();
235
- const pedido = args?.project ? String(args.project) : "";
251
+ const pedido = llamada.args.project ?? "";
236
252
  const proyectos = pedido
237
253
  ? [requireProjectFolder(pedido)]
238
254
  : [...discoverProjects().values()].sort();
@@ -254,10 +270,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
254
270
  content: [{ type: "text", text: bloques.join("\n\n") || "No hay proyectos todavía." }],
255
271
  };
256
272
  }
257
- if (name === "switch_task") {
273
+ if (llamada.tool === "switch_task") {
258
274
  const phases = discoverPhases();
259
- const carpetaProyecto = requireProjectFolder(String(args?.project));
260
- const taskName = String(args?.task_name);
275
+ const carpetaProyecto = requireProjectFolder(llamada.args.project);
276
+ const taskName = llamada.args.task_name;
261
277
  const tareas = listarTareas(carpetaProyecto, phases);
262
278
  const encontrada = tareas.find((tarea) => tarea.taskName === taskName);
263
279
  if (!encontrada) {
@@ -272,39 +288,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
272
288
  content: [{ type: "text", text: `Tarea activa de ${carpetaProyecto}: ${taskName}.\nFase: ${describeEstado(encontrada, phases)}\nCarpeta: ${encontrada.folderPath}` }],
273
289
  };
274
290
  }
275
- if (name === "read_central_doc") {
291
+ if (llamada.tool === "read_central_doc") {
276
292
  const docsBase = requireBasePath(DOCS);
277
- const fullPath = resolveDocPath(args);
278
- if (!estaDentro(docsBase, fullPath)) {
293
+ const fullPath = resolveDocPath(llamada.args);
294
+ if (!estaDentroReal(docsBase, fullPath)) {
279
295
  throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
280
296
  }
281
- const content = fs.readFileSync(fullPath, "utf-8");
297
+ const content = leerTextoAcotado(fullPath);
282
298
  return {
283
299
  content: [{ type: "text", text: content }],
284
300
  };
285
301
  }
286
- if (name === "write_central_doc") {
287
- const content = String(args?.content);
302
+ if (llamada.tool === "write_central_doc") {
303
+ const content = llamada.args.content;
288
304
  const docsBase = requireBasePath(DOCS);
289
- const fullPath = resolveDocPath(args);
290
- if (!estaDentro(docsBase, fullPath)) {
305
+ const fullPath = resolveDocPath(llamada.args);
306
+ if (!estaDentroReal(docsBase, fullPath)) {
291
307
  throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
292
308
  }
309
+ // Antes del mkdir: con una tarea mal escrita, el mkdir recursivo creaba su carpeta.
310
+ const ubicacion = ubicarEnTarea(fullPath);
311
+ if (!ubicacion) {
312
+ throw new Error("write_central_doc solo escribe dentro de la carpeta de una tarea que ya existe " +
313
+ "(<DOCS>/<PROYECTO>/<subcarpeta de tareas>/<tarea>/). Los archivos del proyecto, como " +
314
+ "phase-gate.json, no se escriben con esta tool. Para una tarea nueva, usa start_task.");
315
+ }
316
+ const motivo = motivoDeProteccion(ubicacion.carpetaTarea, ubicacion.relativo, discoverPhases(), nombreContextoInicial());
317
+ if (motivo && llamada.args.force !== true) {
318
+ throw new Error(`${motivo} Si de verdad hay que reescribirlo, pídele al usuario confirmación explícita ` +
319
+ `y vuelve a llamar con force: true.`);
320
+ }
321
+ const existia = fs.existsSync(fullPath);
293
322
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
294
323
  fs.writeFileSync(fullPath, content, "utf-8");
324
+ // force también crea el contexto inicial cuando falta: no se dice "sobrescrito" si no lo era.
325
+ const forzado = motivo ? (existia ? " (sobrescrito con force)" : " (creado con force)") : "";
295
326
  return {
296
- content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}` }],
327
+ content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}${forzado}` }],
297
328
  };
298
329
  }
299
- if (name === "read_cross_repo") {
300
- const repoName = String(args?.repo_name);
301
- const filePath = String(args?.file_path);
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");
330
+ if (llamada.tool === "read_cross_repo") {
331
+ const fullPath = resolveRepoPath(llamada.args.repo_name, llamada.args.file_path);
332
+ const content = leerTextoAcotado(fullPath);
308
333
  return {
309
334
  content: [{ type: "text", text: content }],
310
335
  };
@@ -335,36 +360,36 @@ function reportarRutas() {
335
360
  // una terminal —que es exactamente lo que hace quien acaba de leer `npx mcp-phase-gate` en el
336
361
  // README— se queda esperando entrada, sin señal de vida, y parece roto. Estas dos banderas
337
362
  // 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
-
363
+ const AYUDA = `${SERVER_NAME} v${SERVER_VERSION}
364
+
365
+ Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada
366
+ una deje su documento en una documentación central.
367
+
368
+ Esto NO se ejecuta a mano: lo lanza tu cliente MCP. Ejecutado directamente se queda esperando
369
+ mensajes JSON-RPC por stdin, que es lo que parece que se colgó.
370
+
371
+ Regístralo en tu cliente (Cursor, VS Code, Claude Code) así:
372
+
373
+ {
374
+ "mcpServers": {
375
+ "${SERVER_NAME}": {
376
+ "command": "npx",
377
+ "args": ["-y", "mcp-phase-gate"],
378
+ "env": {
379
+ "ORQUESTADOR_DOCS_PATH": "<tu carpeta de documentación>",
380
+ "ORQUESTADOR_REPOS_PATH": "<tu carpeta de repositorios>"
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ Variables de entorno:
387
+ ORQUESTADOR_DOCS_PATH Obligatoria. Raíz de la Documentación Central.
388
+ ORQUESTADOR_REPOS_PATH Obligatoria. Carpeta con tus repositorios de código.
389
+ ORQUESTADOR_TASKS_SUBDIR Subcarpeta de tareas dentro de cada proyecto. Por defecto "Proyectos".
390
+ ORQUESTADOR_PROMPTS_PATH Carpeta de prompts propios, que se superponen a los del paquete.
391
+ ORQUESTADOR_STATE_PATH Dónde guardar los punteros de tarea activa.
392
+
368
393
  Documentación: https://github.com/Montse2308/mcp-phase-gate`;
369
394
  async function main() {
370
395
  const bandera = process.argv[2];
package/build/paths.d.ts CHANGED
@@ -1,4 +1,9 @@
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 claveDeNombre(nombre: string): string;
5
+ export declare function validarSegmento(valor: string, campo: string): string;
6
+ export declare function validarNombreVisible(valor: string, campo: string): string;
2
7
  export declare function discoverProjects(): Map<string, string>;
3
8
  export declare function describeProjects(): string;
4
9
  export declare function findProjectFolder(project: string): string | null;
@@ -6,5 +11,21 @@ export declare function requireProjectFolder(project: string): string;
6
11
  export declare function taskFolderPath(carpetaProyecto: string, taskName: string): string;
7
12
  export declare function resolveTaskFolder(project: string, taskName: string): string;
8
13
  export declare function tasksRootPath(carpetaProyecto: string): string;
9
- export declare function resolveDocPath(args: any): string;
14
+ export type ArgumentosDeDocumento = {
15
+ project?: string | undefined;
16
+ task_name?: string | undefined;
17
+ file_name?: string | undefined;
18
+ file_path?: string | undefined;
19
+ };
20
+ export declare function resolveDocPath(args: ArgumentosDeDocumento): string;
21
+ export type UbicacionEnTarea = {
22
+ carpetaProyecto: string;
23
+ taskName: string;
24
+ carpetaTarea: string;
25
+ relativo: string;
26
+ };
27
+ export declare function ubicarEnTarea(rutaAbsoluta: string): UbicacionEnTarea | null;
28
+ export declare function resolveRepoPath(repoName: string, filePath: string): string;
29
+ export declare const LIMITE_LECTURA_BYTES = 1048576;
30
+ export declare function leerTextoAcotado(ruta: string): string;
10
31
  //# 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,78 @@ 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
+ // Clave para comparar nombres de documento: dos nombres con la misma clave se ven iguales, y en
42
+ // Windows y macOS abren el mismo archivo. rutaReal solo corrige las mayúsculas de lo que ya
43
+ // existe, así que lo que todavía no existe se compara por aquí. Sin distinguir mayúsculas en
44
+ // todos los sistemas, para que el comportamiento no dependa del equipo.
45
+ export function claveDeNombre(nombre) {
46
+ return nombre.normalize("NFC").toLowerCase();
47
+ }
48
+ // Un nombre de proyecto, tarea o repo es UN segmento de ruta, portable entre sistemas:
49
+ // la Documentación viaja entre equipos con Windows, macOS y Linux. Lo que se rechaza es lo
50
+ // que hoy ya era peligroso (separadores, "..") o lo que Windows no puede guardar y rompe la
51
+ // sincronización (caracteres reservados, punto o espacio final, CON, NUL…).
52
+ const CARACTERES_RESERVADOS = /[<>:"|?*]/;
53
+ const CARACTERES_DE_CONTROL = /[\u0000-\u001f\u007f]/;
54
+ // Windows también reserva COM y LPT con los superíndices ¹²³, y las consolas CONIN$ y CONOUT$.
55
+ // Los espacios antes de la extensión no cuentan: "nul .txt" sigue siendo NUL.
56
+ const NOMBRES_RESERVADOS_WINDOWS = /^(con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³]) *(\..*)?$/i;
57
+ export function validarSegmento(valor, campo) {
58
+ const incumple = (regla) => {
59
+ throw new Error(`${campo} ${JSON.stringify(valor)} no es válido: ${regla}. Tiene que ser un solo nombre de ` +
60
+ `carpeta o archivo, que funcione igual en Windows, macOS y Linux.`);
61
+ };
62
+ if (valor.trim() === "")
63
+ incumple("está vacío");
64
+ if (valor === "." || valor === "..")
65
+ incumple(`no puede ser "${valor}"`);
66
+ if (/[\\/]/.test(valor))
67
+ incumple("no puede llevar / ni \\");
68
+ if (CARACTERES_DE_CONTROL.test(valor))
69
+ incumple("no puede llevar caracteres de control");
70
+ if (CARACTERES_RESERVADOS.test(valor))
71
+ incumple('no puede llevar ninguno de < > : " | ? *');
72
+ if (/[. ]$/.test(valor))
73
+ incumple("no puede terminar en punto ni en espacio");
74
+ if (NOMBRES_RESERVADOS_WINDOWS.test(valor)) {
75
+ incumple("es un nombre reservado de Windows (CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9)");
76
+ }
77
+ return valor;
78
+ }
79
+ // Un proyecto o una tarea que start_task va a crear tampoco puede empezar por punto: las carpetas
80
+ // con punto no son proyecto ni tarea, así que no aparecería en list_tasks y write_central_doc no
81
+ // podría escribir en ella. No va en validarSegmento porque read_central_doc también lo usa, y la
82
+ // lectura no cambia.
83
+ export function validarNombreVisible(valor, campo) {
84
+ if (valor.startsWith(".")) {
85
+ throw new Error(`${campo} ${JSON.stringify(valor)} no es válido: no puede empezar por punto. Las carpetas ` +
86
+ `con punto no cuentan como proyecto ni como tarea, así que no se vería después.`);
87
+ }
88
+ return valor;
89
+ }
18
90
  // ─── Proyectos ──────────────────────────────────────────────────────────────
19
91
  // Los proyectos no se declaran en ninguna parte: son las carpetas que existen. Antes había
20
92
  // una tabla fija con los proyectos de quien escribió el código, que no restringía nada
@@ -24,12 +96,13 @@ export function estaDentro(base, ruta) {
24
96
  // Es proyecto la carpeta que contenga la subcarpeta de tareas. Esa regla deja fuera sola a
25
97
  // las carpetas que no son proyectos, y da de alta uno nuevo con solo crearle su subcarpeta.
26
98
  // Si la subcarpeta está vacía la regla no se puede aplicar y vale cualquier carpeta.
99
+ // Las carpetas con punto nunca son proyecto: .obsidian o .git en la raíz de la Documentación.
27
100
  export function discoverProjects() {
28
101
  const proyectos = new Map(); // clave en minúsculas -> nombre real de la carpeta
29
102
  const base = requireBasePath(DOCS);
30
103
  const subdir = tasksSubdir();
31
104
  for (const entrada of fs.readdirSync(base, { withFileTypes: true })) {
32
- if (!entrada.isDirectory())
105
+ if (!entrada.isDirectory() || entrada.name.startsWith("."))
33
106
  continue;
34
107
  if (subdir && !fs.existsSync(path.join(base, entrada.name, subdir)))
35
108
  continue;
@@ -62,6 +135,7 @@ export function requireProjectFolder(project) {
62
135
  // La ruta de una tarea SIEMPRE se arma aquí, en un solo lugar.
63
136
  // Estructura: <ORQUESTADOR_DOCS_PATH>/<PROYECTO>/<subcarpeta de tareas>/<tarea>
64
137
  export function taskFolderPath(carpetaProyecto, taskName) {
138
+ validarSegmento(taskName, "task_name");
65
139
  return path.join(requireBasePath(DOCS), carpetaProyecto, tasksSubdir(), taskName);
66
140
  }
67
141
  export function resolveTaskFolder(project, taskName) {
@@ -74,16 +148,157 @@ export function tasksRootPath(carpetaProyecto) {
74
148
  }
75
149
  // Resuelve la ruta absoluta para read/write. Prioriza project+task_name+file_name;
76
150
  // si no, cae al modo legacy con file_path relativo a la Documentación Central.
151
+ //
152
+ // Una ruta relativa hecha de segmentos portables. Cada segmento se valida por separado para que
153
+ // una subcarpeta legítima como "04 - Ejecución/L01 - x.md" siga valiendo.
154
+ function validarRutaRelativa(valor, campo, relativaA) {
155
+ if (path.isAbsolute(valor)) {
156
+ throw new Error(`${campo} no puede ser una ruta absoluta ("${valor}"): es relativo a ${relativaA}.`);
157
+ }
158
+ for (const segmento of valor.split(/[\\/]/))
159
+ validarSegmento(segmento, campo);
160
+ }
161
+ // Con file_name la ruta tiene que quedar dentro de la carpeta de la tarea, no solo dentro de
162
+ // la base: antes "../otra-tarea/x.md" escribía en otra tarea y la comprobación de la tool,
163
+ // que solo miraba la base, lo dejaba pasar.
164
+ //
165
+ // Con file_path aquí basta con no salir de la base (write_central_doc además exige que caiga en
166
+ // una tarea: ubicarEnTarea), pero sus segmentos también tienen que ser portables: antes creaba
167
+ // dentro de la Documentación un "CON", un "nul.md" o un flujo alterno "nota.md:secreto", que el
168
+ // Explorador y OneDrive no saben manejar.
77
169
  export function resolveDocPath(args) {
78
- const project = args?.project ? String(args.project) : "";
79
- const taskName = args?.task_name ? String(args.task_name) : "";
80
- const fileName = args?.file_name ? String(args.file_name) : "";
170
+ const { project = "", task_name: taskName = "", file_name: fileName = "", file_path: filePath = "" } = args;
81
171
  if (project && taskName && fileName) {
82
- return path.join(resolveTaskFolder(project, taskName), fileName);
172
+ const carpetaTarea = resolveTaskFolder(project, taskName);
173
+ validarRutaRelativa(fileName, "file_name", "la carpeta de la tarea");
174
+ const ruta = path.join(carpetaTarea, fileName);
175
+ if (!estaDentroReal(carpetaTarea, ruta)) {
176
+ throw new Error(`Acceso denegado: file_name "${fileName}" queda fuera de la carpeta de la tarea ${carpetaTarea}.`);
177
+ }
178
+ return ruta;
83
179
  }
84
- if (args?.file_path) {
85
- return path.join(requireBasePath(DOCS), String(args.file_path));
180
+ if (filePath) {
181
+ const docsBase = requireBasePath(DOCS);
182
+ validarRutaRelativa(filePath, "file_path", "la Documentación Central");
183
+ const ruta = path.join(docsBase, filePath);
184
+ if (!estaDentroReal(docsBase, ruta)) {
185
+ throw new Error(`Acceso denegado: file_path queda fuera de ${docsBase}.`);
186
+ }
187
+ return ruta;
86
188
  }
87
189
  throw new Error("Debes proporcionar (project + task_name + file_name) o bien file_path.");
88
190
  }
191
+ // Segmentos de `ruta` dentro de `base`, o null si no cae dentro. Las dos ya vienen resueltas.
192
+ function segmentosDentro(base, ruta) {
193
+ const relativa = path.relative(base, ruta);
194
+ if (relativa === "")
195
+ return [];
196
+ if (relativa.startsWith("..") || path.isAbsolute(relativa))
197
+ return null;
198
+ return relativa.split(/[\\/]/);
199
+ }
200
+ // write_central_doc solo escribe dentro de la carpeta de una tarea que ya existe. Con file_path
201
+ // se podía escribir en cualquier lugar de la Documentación, incluido el phase-gate.json del
202
+ // proyecto, y con una tarea mal escrita el mkdir recursivo creaba una carpeta suelta.
203
+ //
204
+ // Se mide sobre rutas reales, como la contención: un enlace interno que lleva a una tarea vale,
205
+ // porque lo que se escribe acaba dentro de ella. La subcarpeta de tareas se compara con la que
206
+ // hay en disco y no con la variable: en Windows y macOS pueden diferir en mayúsculas, y
207
+ // discoverProjects ya da el proyecto por bueno.
208
+ //
209
+ // Devuelve null si la ruta no cae dentro de la carpeta de una tarea existente, o si algún
210
+ // segmento dentro de la tarea empieza por punto (.git, .phase-gate no son documentos).
211
+ export function ubicarEnTarea(rutaAbsoluta) {
212
+ const docsReal = rutaReal(requireBasePath(DOCS));
213
+ const segmentos = segmentosDentro(docsReal, rutaReal(rutaAbsoluta));
214
+ if (!segmentos || segmentos.length === 0)
215
+ return null;
216
+ // El nombre que da el disco tiene que ser el del proyecto, no solo parecerse en mayúsculas.
217
+ const [proyecto = "", ...resto] = segmentos;
218
+ const carpetaProyecto = findProjectFolder(proyecto);
219
+ if (carpetaProyecto !== proyecto)
220
+ return null;
221
+ const raizProyecto = path.join(docsReal, carpetaProyecto);
222
+ const subdir = segmentosDentro(raizProyecto, rutaReal(tasksRootPath(carpetaProyecto)));
223
+ if (!subdir || subdir.some((segmento, i) => resto[i] !== segmento))
224
+ return null;
225
+ const [taskName, ...archivo] = resto.slice(subdir.length);
226
+ if (!taskName || taskName.startsWith(".") || archivo.length === 0)
227
+ return null;
228
+ if (archivo.some((segmento) => segmento.startsWith(".")))
229
+ return null;
230
+ const carpetaTarea = path.join(raizProyecto, ...subdir, taskName);
231
+ if (!fs.statSync(carpetaTarea, { throwIfNoEntry: false })?.isDirectory())
232
+ return null;
233
+ return { carpetaProyecto, taskName, carpetaTarea, relativo: archivo.join("/") };
234
+ }
235
+ // ─── Repos ──────────────────────────────────────────────────────────────────
236
+ // Ruta de un archivo dentro de UN repo. Antes la comprobación miraba la carpeta de repos
237
+ // entera, y desde repoA "../repoB/.env" pasaba: estaba dentro de la carpeta de repos. El repo
238
+ // se resuelve a su ruta real para que un repo que es un enlace a otra unidad siga valiendo.
239
+ export function resolveRepoPath(repoName, filePath) {
240
+ validarSegmento(repoName, "repo_name");
241
+ const repo = path.join(requireBasePath(REPOS), repoName);
242
+ const repoReal = rutaReal(repo);
243
+ const ruta = path.join(repo, filePath);
244
+ if (!estaDentroReal(repoReal, ruta)) {
245
+ throw new Error(`Acceso denegado: file_path "${filePath}" queda fuera del repositorio ${repoName}.`);
246
+ }
247
+ return ruta;
248
+ }
249
+ // ─── Lectura acotada ────────────────────────────────────────────────────────
250
+ // Lo que leen las tools va entero al contexto del modelo. Un volcado de varios megas o un
251
+ // binario no le sirven de nada y se comen la conversación, así que se cortan antes de leer.
252
+ export const LIMITE_LECTURA_BYTES = 1_048_576;
253
+ // La misma heurística que usa git para decidir si un archivo es binario.
254
+ const BYTES_PARA_DETECTAR_BINARIO = 8000;
255
+ // Se lee del descriptor y no de la ruta: tipo, tamaño y contenido salen del mismo archivo
256
+ // abierto. Con statSync seguido de readFileSync, un archivo que crecía entre las dos llamadas
257
+ // se leía entero, sin tope. Y nunca se piden más de LIMITE + 1 bytes, que es lo justo para
258
+ // saber si se pasa. O_NONBLOCK, que no existe en Windows, evita que abrir un FIFO se quede
259
+ // esperando a que alguien escriba en él.
260
+ //
261
+ // Riesgo aceptado: la contención se comprueba sobre la ruta real y aquí se abre la ruta tal
262
+ // cual, así que cambiar un enlace justo entre las dos cosas abriría otro destino. Cerrarlo
263
+ // exige abrir sin seguir enlaces componente a componente, que Node no ofrece de forma portable.
264
+ export function leerTextoAcotado(ruta) {
265
+ let descriptor;
266
+ try {
267
+ descriptor = fs.openSync(ruta, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
268
+ }
269
+ catch (error) {
270
+ if (error?.code === "EISDIR")
271
+ throw new Error(`${ruta} no es un archivo: es una carpeta.`);
272
+ throw error;
273
+ }
274
+ try {
275
+ const info = fs.fstatSync(descriptor);
276
+ if (!info.isFile()) {
277
+ throw new Error(`${ruta} no es un archivo normal: es una carpeta, un dispositivo o una tubería.`);
278
+ }
279
+ if (info.size > LIMITE_LECTURA_BYTES) {
280
+ throw new Error(`${ruta} pesa ${info.size} bytes y el límite de lectura es 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
281
+ }
282
+ const buffer = Buffer.alloc(LIMITE_LECTURA_BYTES + 1);
283
+ let leidos = 0;
284
+ while (leidos < buffer.length) {
285
+ const bytes = fs.readSync(descriptor, buffer, leidos, buffer.length - leidos, null);
286
+ if (bytes === 0)
287
+ break;
288
+ leidos += bytes;
289
+ }
290
+ if (leidos > LIMITE_LECTURA_BYTES) {
291
+ throw new Error(`${ruta} creció mientras se leía y pasa del límite de lectura de 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
292
+ }
293
+ const contenido = buffer.subarray(0, leidos);
294
+ if (contenido.subarray(0, BYTES_PARA_DETECTAR_BINARIO).includes(0)) {
295
+ throw new Error(`${ruta} parece binario: tiene un byte NUL en sus primeros ${BYTES_PARA_DETECTAR_BINARIO} bytes. ` +
296
+ `Estas tools solo leen texto.`);
297
+ }
298
+ return contenido.toString("utf-8");
299
+ }
300
+ finally {
301
+ fs.closeSync(descriptor);
302
+ }
303
+ }
89
304
  //# sourceMappingURL=paths.js.map
package/build/phases.d.ts CHANGED
@@ -4,7 +4,10 @@ export type PhaseFile = {
4
4
  title: string;
5
5
  documento?: string | undefined;
6
6
  documentoInicial?: string | undefined;
7
+ recibos?: string | undefined;
7
8
  };
9
+ export declare const MARCADOR_RANURA: RegExp;
10
+ export declare function quitarMarcadores(texto: string): string;
8
11
  export declare function parsePhaseFile(fullPath: string): {
9
12
  meta: Record<string, string>;
10
13
  cuerpo: string;