mcp-phase-gate 2.1.0 → 2.2.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.
@@ -0,0 +1,372 @@
1
+ // El servidor MCP: registra las tools y las conecta con la lógica de los demás módulos.
2
+ // Aquí no se calcula nada — las rutas viven en paths.ts, las fases en phases.ts y las tareas en
3
+ // tasks.ts. Este archivo solo traduce entre el protocolo y esas funciones.
4
+ //
5
+ // crearServidor no conecta ningún transporte: lo hace index.ts con stdio, y los tests con un
6
+ // transporte en memoria. Así el servidor entero se puede probar sin lanzar un proceso.
7
+ //
8
+ // Cada tool se registra con su shape de schemas.ts: el SDK valida los argumentos antes del
9
+ // callback y convierte en `isError` tanto un fallo de validación como un Error lanzado aquí. Por
10
+ // eso los callbacks lanzan Error con el mensaje para el modelo, y no arman respuestas de error.
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import { DOCS, SERVER_NAME, SERVER_VERSION, requireBasePath, stateFile } from "./config.js";
15
+ import { diagnosticar, formatearDiagnostico } from "./diagnostico.js";
16
+ import { pedirConfirmacion } from "./confirmacion.js";
17
+ import { conectarLog, log } from "./log.js";
18
+ import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, ubicarEnTarea, validarNombreVisible, validarSegmento, } from "./paths.js";
19
+ import { discoverPhases, loadPhasePrompt, nombreContextoInicial } from "./phases.js";
20
+ import { asegurarCapacidadDePrompts, sincronizarPrompts, vigilarCapas } from "./prompts-mcp.js";
21
+ import { registrarRecursos } from "./recursos.js";
22
+ import { diagnosticoShape, getActiveTaskShape, getOrchestratorStatusShape, getPhasePromptShape, listTasksShape, readCentralDocShape, readCrossRepoShape, sinNulos, startTaskShape, switchTaskShape, writeCentralDocShape, } from "./schemas.js";
23
+ import { avisoDeCompuerta, avisoDePendientes, bloqueDeRecibos, describeEstado, guardarPuntero, listarTareas, motivoDeProteccion, tareaActiva, tareaDelPrompt, } from "./tasks.js";
24
+ function responder(texto) {
25
+ return { content: [{ type: "text", text: texto }] };
26
+ }
27
+ function siCancelada(extra) {
28
+ if (extra.signal.aborted)
29
+ throw new Error("El cliente canceló la llamada antes de escribir. No se escribió nada.");
30
+ }
31
+ function lineaDeTarea(estado, phases) {
32
+ return `- ${estado.taskName} — ${describeEstado(estado, phases)}`;
33
+ }
34
+ // Las anotaciones dicen al cliente qué hace cada tool, para que decida si pide permiso. Los
35
+ // valores por defecto del protocolo son pesimistas (destructiva y de mundo abierto), así que todas
36
+ // declaran los cuatro valores. openWorldHint es false en todas: ninguna sale de las carpetas
37
+ // configuradas.
38
+ function anotaciones(title, readOnlyHint, destructiveHint, idempotentHint) {
39
+ return { title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false };
40
+ }
41
+ export function crearServidor() {
42
+ const mcp = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { logging: {} } });
43
+ conectarLog(mcp);
44
+ registrarTools(mcp);
45
+ registrarRecursos(mcp);
46
+ // Los /fN se registran ya, antes de conectar, y se vuelven a sincronizar tras cada tool y
47
+ // cuando cambia una capa de prompts.
48
+ sincronizarPrompts(mcp);
49
+ asegurarCapacidadDePrompts(mcp);
50
+ const dejarDeVigilar = vigilarCapas(mcp);
51
+ const alCerrar = mcp.server.onclose;
52
+ mcp.server.onclose = () => {
53
+ dejarDeVigilar();
54
+ alCerrar?.();
55
+ };
56
+ return mcp;
57
+ }
58
+ function registrarTools(mcp) {
59
+ // Tras cada llamada a una tool se sincronizan los prompts /fN. Leer las carpetas de prompts
60
+ // cuesta milisegundos, y es lo único que garantiza que una fase nueva aparezca en OneDrive,
61
+ // donde fs.watch no avisa.
62
+ const sincronizandoTras = (callback) => async (...args) => {
63
+ try {
64
+ return await callback(...args);
65
+ }
66
+ finally {
67
+ sincronizarPrompts(mcp);
68
+ }
69
+ };
70
+ // start_task no es destructiva (se niega a pisar una tarea) ni idempotente (la segunda llamada falla).
71
+ mcp.registerTool("start_task", {
72
+ title: "Iniciar tarea",
73
+ description: "Inicia una tarea NUEVA creando su carpeta en la Documentación Central y guardando el contexto inicial. " +
74
+ "Para volver a una tarea que ya existe usa 'switch_task': start_task reescribiría su contexto inicial.",
75
+ inputSchema: startTaskShape,
76
+ annotations: anotaciones("Iniciar tarea", false, false, false),
77
+ }, sincronizandoTras(async (entrada, extra) => {
78
+ const args = sinNulos(entrada);
79
+ const proj = args.project; // el schema ya le quitó los espacios
80
+ const taskName = args.task_name;
81
+ // Estrenar proyecto tiene que ser deliberado: sin esta puerta, un nombre mal escrito
82
+ // creaba una carpeta suelta en la Documentación y nadie se enteraba hasta buscarla.
83
+ let carpetaProyecto = findProjectFolder(proj);
84
+ let estrenado = false;
85
+ if (!carpetaProyecto) {
86
+ if (args.crear_proyecto !== true) {
87
+ throw new Error(`No existe el proyecto "${proj}". Los proyectos disponibles son: ${describeProjects()}. ` +
88
+ `Si el usuario te confirmó que quiere estrenar el proyecto "${proj.toUpperCase()}", ` +
89
+ `vuelve a llamar a start_task con crear_proyecto: true.`);
90
+ }
91
+ // El nombre se vuelve carpeta tal cual: "../evil" la creaba fuera de la Documentación.
92
+ validarSegmento(proj, "project");
93
+ validarNombreVisible(proj, "project");
94
+ carpetaProyecto = proj.toUpperCase();
95
+ estrenado = true;
96
+ }
97
+ validarNombreVisible(taskName, "task_name");
98
+ const carpetaTarea = taskFolderPath(carpetaProyecto, taskName);
99
+ const contextoInicial = path.join(carpetaTarea, nombreContextoInicial());
100
+ const docsBase = requireBasePath(DOCS);
101
+ if (!estaDentroReal(docsBase, carpetaTarea)) {
102
+ throw new Error(`Acceso denegado: la carpeta de la tarea queda fuera de ${docsBase}.`);
103
+ }
104
+ // Estrenar un proyecto se le confirma a la persona si el cliente puede preguntarle. Sin
105
+ // crear_proyecto no se pregunta nada: ofrecer "¿lo creo?" ante un nombre desconocido
106
+ // invitaría a aceptar un typo con un clic.
107
+ if (estrenado) {
108
+ const { estado, nota } = await pedirConfirmacion(mcp, extra, {
109
+ mensaje: `¿Estrenar el proyecto «${carpetaProyecto}»? Se creará la carpeta \`${path.join(docsBase, carpetaProyecto)}\`.`,
110
+ etiqueta: `Crear el proyecto ${carpetaProyecto}`,
111
+ });
112
+ if (estado === "rechazada") {
113
+ throw new Error(`El usuario no confirmó el proyecto nuevo. No se creó nada.${nota ? ` ${nota}` : ""}`);
114
+ }
115
+ }
116
+ // start_task escribe el contexto inicial sin preguntar, así que llamarlo sobre una tarea
117
+ // que ya existe borraba el requerimiento original — el único documento que no se puede
118
+ // regenerar leyendo el código. Retomar una tarea es switch_task, que no toca archivos.
119
+ if (fs.existsSync(contextoInicial)) {
120
+ throw new Error(`La tarea "${taskName}" ya existe en ${carpetaProyecto} y start_task reescribiría su ` +
121
+ `contexto inicial. Si quieres retomarla, usa switch_task. Si de verdad quieres ` +
122
+ `empezar de cero, borra o renombra la carpeta primero: ${carpetaTarea}`);
123
+ }
124
+ siCancelada(extra);
125
+ if (!fs.existsSync(carpetaTarea)) {
126
+ fs.mkdirSync(carpetaTarea, { recursive: true });
127
+ }
128
+ fs.writeFileSync(contextoInicial, args.initial_context, "utf-8");
129
+ // Queda como activa de SU proyecto. Otra ventana trabajando en otro proyecto conserva
130
+ // la suya: los punteros son independientes.
131
+ guardarPuntero(carpetaProyecto, taskName);
132
+ // Cambia la tarea activa, y con ella qué documentos se listan como recursos.
133
+ mcp.sendResourceListChanged();
134
+ const aviso = estrenado ? `\nProyecto "${carpetaProyecto}" estrenado.` : "";
135
+ return responder(`Tarea '${taskName}' inicializada correctamente. Archivo guardado en: ${contextoInicial}\n` +
136
+ `Tarea activa de ${carpetaProyecto}: ${taskName}.${aviso}`);
137
+ }));
138
+ mcp.registerTool("get_phase_prompt", {
139
+ title: "Prompt de fase",
140
+ description: "Obtiene el prompt maestro de comportamiento de una fase. Comprueba además que no te estés saltando una " +
141
+ "fase: si faltan documentos anteriores, el prompt llega con un aviso al principio.",
142
+ inputSchema: getPhasePromptShape,
143
+ annotations: anotaciones("Prompt de fase", true, false, true),
144
+ }, sincronizandoTras(async (entrada) => {
145
+ // Que sea un entero ya lo garantiza el schema; si la fase no existe, lo dice loadPhasePrompt.
146
+ const { phase, project, task_name } = sinNulos(entrada);
147
+ const prompt = loadPhasePrompt(phase);
148
+ // La compuerta avisa, no bloquea: el prompt se entrega siempre. Si comprobarla fallara
149
+ // por lo que sea, entregarlo igual es mejor que dejar al usuario sin fase por un aviso.
150
+ // Lo mismo con la lista de recibos: es una ayuda, no una condición.
151
+ let aviso = null;
152
+ let pendientes = null;
153
+ let recibos = null;
154
+ try {
155
+ const phases = discoverPhases();
156
+ const opciones = { ...(project ? { project } : {}), ...(task_name ? { taskName: task_name } : {}) };
157
+ aviso = avisoDeCompuerta(phase, phases, opciones);
158
+ const estado = tareaDelPrompt(phases, opciones);
159
+ if (estado && estado !== "ambigua") {
160
+ pendientes = avisoDePendientes(phase, estado, phases);
161
+ recibos = bloqueDeRecibos(phase, estado, phases);
162
+ }
163
+ }
164
+ catch (error) {
165
+ log("warning", `No se pudo comprobar la compuerta: ${error instanceof Error ? error.message : String(error)}`);
166
+ }
167
+ // Lo que el modelo tiene que leer antes de nada va delante: el salto de fase y los pendientes.
168
+ return responder([aviso, pendientes, prompt, recibos].filter(Boolean).join("\n\n"));
169
+ }));
170
+ mcp.registerTool("get_active_task", {
171
+ title: "Tarea activa",
172
+ description: "Devuelve la tarea activa y en qué fase va, deducida de los documentos que ya tiene. Úsalo al iniciar un " +
173
+ "chat nuevo o si perdiste el contexto. Sin 'project' devuelve la activa de CADA proyecto.",
174
+ inputSchema: getActiveTaskShape,
175
+ annotations: anotaciones("Tarea activa", true, false, true),
176
+ }, sincronizandoTras(async (entrada) => {
177
+ const phases = discoverPhases();
178
+ const pedido = sinNulos(entrada).project ?? "";
179
+ // Con proyecto: la suya. Sin proyecto: la de cada uno. Antes había un único puntero
180
+ // global y responder "la activa" a secas obligaba a adivinar cuál de dos ventanas
181
+ // preguntaba; listar todas es la respuesta honesta cuando no se sabe.
182
+ const proyectos = pedido ? [requireProjectFolder(pedido)] : [...discoverProjects().values()].sort();
183
+ if (proyectos.length === 0) {
184
+ return responder("No hay ningún proyecto en la Documentación Central todavía.");
185
+ }
186
+ const bloques = [];
187
+ for (const carpetaProyecto of proyectos) {
188
+ const activa = tareaActiva(carpetaProyecto, phases);
189
+ if (!activa) {
190
+ bloques.push(`${carpetaProyecto}: sin tareas todavía.`);
191
+ continue;
192
+ }
193
+ const nota = activa.origen === "deducida"
194
+ ? " (deducida por ser la más reciente: no había puntero guardado, confírmalo antes de escribir)"
195
+ : "";
196
+ bloques.push(`${carpetaProyecto}: ${activa.estado.taskName}${nota}\n` +
197
+ ` Fase: ${describeEstado(activa.estado, phases)}\n` +
198
+ ` Carpeta: ${activa.estado.folderPath}`);
199
+ }
200
+ return responder(`${bloques.join("\n\n")}\n\nPunteros guardados en: ${stateFile()}`);
201
+ }));
202
+ mcp.registerTool("list_tasks", {
203
+ title: "Listar tareas",
204
+ description: "Lista las tareas que existen y la fase en la que va cada una. Úsalo para retomar una tarea vieja o para " +
205
+ "saber qué hay pendiente.",
206
+ inputSchema: listTasksShape,
207
+ annotations: anotaciones("Listar tareas", true, false, true),
208
+ }, sincronizandoTras(async (entrada) => {
209
+ const phases = discoverPhases();
210
+ const pedido = sinNulos(entrada).project ?? "";
211
+ const proyectos = pedido ? [requireProjectFolder(pedido)] : [...discoverProjects().values()].sort();
212
+ const bloques = [];
213
+ for (const carpetaProyecto of proyectos) {
214
+ const tareas = listarTareas(carpetaProyecto, phases);
215
+ const activa = tareaActiva(carpetaProyecto, phases);
216
+ if (tareas.length === 0) {
217
+ bloques.push(`${carpetaProyecto}: sin tareas.`);
218
+ continue;
219
+ }
220
+ const lineas = tareas.map((tarea) => {
221
+ const marca = tarea.taskName === activa?.estado.taskName ? " [activa]" : "";
222
+ return `${lineaDeTarea(tarea, phases)}${marca}`;
223
+ });
224
+ bloques.push(`${carpetaProyecto} (${tareas.length}), de la más reciente a la más antigua:\n${lineas.join("\n")}`);
225
+ }
226
+ return responder(bloques.join("\n\n") || "No hay proyectos todavía.");
227
+ }));
228
+ // Escribe el puntero, así que no es de solo lectura; pero repetirla da el mismo estado.
229
+ mcp.registerTool("switch_task", {
230
+ title: "Cambiar de tarea activa",
231
+ description: "Cambia cuál es la tarea activa de un proyecto, sin tocar ningún archivo. Es la forma correcta de " +
232
+ "retomar una tarea que ya existe.",
233
+ inputSchema: switchTaskShape,
234
+ annotations: anotaciones("Cambiar de tarea activa", false, false, true),
235
+ }, sincronizandoTras(async (entrada) => {
236
+ const args = sinNulos(entrada);
237
+ const phases = discoverPhases();
238
+ const carpetaProyecto = requireProjectFolder(args.project);
239
+ const taskName = args.task_name;
240
+ const tareas = listarTareas(carpetaProyecto, phases);
241
+ const encontrada = tareas.find((tarea) => tarea.taskName === taskName);
242
+ if (!encontrada) {
243
+ const disponibles = tareas.length ? tareas.map((tarea) => `"${tarea.taskName}"`).join(", ") : "(ninguna)";
244
+ throw new Error(`No existe la tarea "${taskName}" en ${carpetaProyecto}. Las que hay son: ${disponibles}. ` +
245
+ `Si es una tarea nueva, créala con start_task.`);
246
+ }
247
+ guardarPuntero(carpetaProyecto, taskName);
248
+ mcp.sendResourceListChanged();
249
+ return responder(`Tarea activa de ${carpetaProyecto}: ${taskName}.\nFase: ${describeEstado(encontrada, phases)}\n` +
250
+ `Carpeta: ${encontrada.folderPath}`);
251
+ }));
252
+ mcp.registerTool("read_central_doc", {
253
+ title: "Leer documento",
254
+ description: "Lee un archivo de la Documentación Central. RECOMENDADO: pasa 'project' + 'task_name' + 'file_name' y el " +
255
+ "servidor arma la ruta correcta automáticamente (<PROYECTO>/<subcarpeta de tareas>/<tarea>/<archivo>). " +
256
+ "Alternativamente puedes pasar 'file_path' relativo a la Documentación Central.",
257
+ inputSchema: readCentralDocShape,
258
+ annotations: anotaciones("Leer documento", true, false, true),
259
+ }, sincronizandoTras(async (entrada) => {
260
+ const docsBase = requireBasePath(DOCS);
261
+ const fullPath = resolveDocPath(sinNulos(entrada));
262
+ if (!estaDentroReal(docsBase, fullPath)) {
263
+ throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
264
+ }
265
+ return responder(leerTextoAcotado(fullPath));
266
+ }));
267
+ // La única destructiva: puede sobrescribir un documento. Repetirla con el mismo contenido deja
268
+ // el mismo archivo, así que es idempotente.
269
+ mcp.registerTool("write_central_doc", {
270
+ title: "Escribir documento",
271
+ description: "Escribe o sobrescribe un archivo en la Documentación Central. RECOMENDADO: pasa 'project' + 'task_name' + " +
272
+ "'file_name' y el servidor arma la ruta correcta automáticamente " +
273
+ "(<PROYECTO>/<subcarpeta de tareas>/<tarea>/<archivo>). Alternativamente puedes pasar 'file_path' relativo " +
274
+ "a la Documentación Central.",
275
+ inputSchema: writeCentralDocShape,
276
+ annotations: anotaciones("Escribir documento", false, true, true),
277
+ }, sincronizandoTras(async (entrada, extra) => {
278
+ const args = sinNulos(entrada);
279
+ const docsBase = requireBasePath(DOCS);
280
+ const fullPath = resolveDocPath(args);
281
+ if (!estaDentroReal(docsBase, fullPath)) {
282
+ throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
283
+ }
284
+ // Antes del mkdir: con una tarea mal escrita, el mkdir recursivo creaba su carpeta.
285
+ const ubicacion = ubicarEnTarea(fullPath);
286
+ if (!ubicacion) {
287
+ throw new Error("write_central_doc solo escribe dentro de la carpeta de una tarea que ya existe " +
288
+ "(<DOCS>/<PROYECTO>/<subcarpeta de tareas>/<tarea>/). Los archivos del proyecto, como " +
289
+ "phase-gate.json, no se escriben con esta tool. Para una tarea nueva, usa start_task.");
290
+ }
291
+ const motivo = motivoDeProteccion(ubicacion.carpetaTarea, ubicacion.relativo, discoverPhases(), nombreContextoInicial());
292
+ const existia = fs.existsSync(fullPath);
293
+ // Un documento protegido se le pregunta a la persona si el cliente puede mostrarle la
294
+ // pregunta; entonces force no basta, porque lo pone el modelo. Solo sin elicitation decide
295
+ // force, como antes.
296
+ let sufijo = "";
297
+ if (motivo) {
298
+ const archivo = ubicacion.relativo;
299
+ const { estado, nota } = await pedirConfirmacion(mcp, extra, {
300
+ mensaje: `\`${archivo}\` de la tarea «${ubicacion.taskName}» está protegido: ${motivo.replace(/\.$/, "")}. ` +
301
+ (existia ? "¿Reescribirlo?" : "¿Crearlo?"),
302
+ etiqueta: `${existia ? "Reescribir" : "Crear"} ${archivo}`,
303
+ });
304
+ const conNota = nota ? ` ${nota}` : "";
305
+ if (estado === "rechazada") {
306
+ const accion = existia ? "la reescritura" : "la escritura";
307
+ throw new Error(`El usuario no confirmó ${accion} de \`${archivo}\`. No se escribió nada.${conNota}`);
308
+ }
309
+ if (estado === "no-disponible" && args.force !== true) {
310
+ throw new Error(`${motivo} Si de verdad hay que reescribirlo, pídele al usuario confirmación explícita ` +
311
+ `y vuelve a llamar con force: true.${conNota}`);
312
+ }
313
+ // force también crea el contexto inicial cuando falta: no se dice "sobrescrito" si no lo era.
314
+ sufijo =
315
+ estado === "confirmada"
316
+ ? existia
317
+ ? " (reescrito con confirmación del usuario)"
318
+ : " (creado con confirmación del usuario)"
319
+ : existia
320
+ ? " (sobrescrito con force)"
321
+ : " (creado con force)";
322
+ if (nota)
323
+ sufijo += `\n${nota}`;
324
+ }
325
+ // Si el cliente canceló la llamada mientras se esperaba algo (una confirmación), ya no
326
+ // escucha la respuesta: escribir ahora dejaría un archivo que el modelo cree que no existe.
327
+ siCancelada(extra);
328
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
329
+ fs.writeFileSync(fullPath, args.content, "utf-8");
330
+ // Un archivo nuevo puede ser un documento o un recibo que se lista como recurso. Reescribir
331
+ // uno que ya existía no cambia la lista.
332
+ if (!existia)
333
+ mcp.sendResourceListChanged();
334
+ return responder(`Archivo escrito exitosamente en ${fullPath}${sufijo}`);
335
+ }));
336
+ // La misma radiografía que `npx mcp-phase-gate doctor`, más lo que solo se sabe desde dentro de
337
+ // una sesión: qué cliente está conectado y qué capacidades anunció.
338
+ mcp.registerTool("get_orchestrator_status", {
339
+ title: "Estado de la instalación",
340
+ description: "Radiografía de la instalación: versión y ruta del paquete, variables y de dónde salió cada una, qué " +
341
+ "archivo de prompt gana en cada fase, proyectos y tarea activa, y avisos. Úsala cuando algo no se " +
342
+ "comporte como esperas.",
343
+ inputSchema: getOrchestratorStatusShape,
344
+ outputSchema: diagnosticoShape,
345
+ annotations: anotaciones("Estado de la instalación", true, false, true),
346
+ }, sincronizandoTras(async () => {
347
+ const version = mcp.server.getClientVersion();
348
+ const capacidades = mcp.server.getClientCapabilities();
349
+ const cliente = version
350
+ ? {
351
+ nombre: version.name,
352
+ version: version.version,
353
+ capacidades: { elicitation: Boolean(capacidades?.elicitation?.form), roots: Boolean(capacidades?.roots) },
354
+ }
355
+ : undefined;
356
+ const diagnostico = diagnosticar(cliente ? { cliente } : {});
357
+ return {
358
+ content: [{ type: "text", text: formatearDiagnostico(diagnostico, "markdown") }],
359
+ structuredContent: diagnostico,
360
+ };
361
+ }));
362
+ mcp.registerTool("read_cross_repo", {
363
+ title: "Leer archivo de repositorio",
364
+ description: "Lee archivos de otros repositorios locales, dentro de la carpeta configurada en PHASE_GATE_REPOS_PATH.",
365
+ inputSchema: readCrossRepoShape,
366
+ annotations: anotaciones("Leer archivo de repositorio", true, false, true),
367
+ }, sincronizandoTras(async (entrada) => {
368
+ const args = sinNulos(entrada);
369
+ return responder(leerTextoAcotado(resolveRepoPath(args.repo_name, args.file_path)));
370
+ }));
371
+ }
372
+ //# sourceMappingURL=servidor.js.map
package/build/tasks.d.ts CHANGED
@@ -36,5 +36,7 @@ export type OpcionesDeTarea = {
36
36
  export declare function tareaDelPrompt(phases: Map<number, PhaseFile>, opciones?: OpcionesDeTarea): EstadoTarea | "ambigua" | null;
37
37
  export declare function avisoDeCompuerta(fasePedida: number, phases: Map<number, PhaseFile>, opciones?: OpcionesDeTarea): string | null;
38
38
  export declare function bloqueDeRecibos(fasePedida: number, estado: EstadoTarea, phases: Map<number, PhaseFile>): string | null;
39
+ export declare function tieneValidacionesPendientes(texto: string): boolean;
40
+ export declare function avisoDePendientes(fasePedida: number, estado: EstadoTarea, phases: Map<number, PhaseFile>): string | null;
39
41
  export declare function avisoDeSalto(salto: Salto, taskName: string, phases: Map<number, PhaseFile>): string;
40
42
  //# sourceMappingURL=tasks.d.ts.map
package/build/tasks.js CHANGED
@@ -14,7 +14,9 @@
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { MODULE_DIR, stateDir, stateFile } from "./config.js";
17
- import { claveDeNombre, findProjectFolder, requireProjectFolder, tasksRootPath } from "./paths.js";
17
+ import { log } from "./log.js";
18
+ import { claveDeNombre, findProjectFolder, leerTextoAcotado, requireProjectFolder, tasksRootPath } from "./paths.js";
19
+ import { separarFrontmatter } from "./phases.js";
18
20
  export function leerPunteros() {
19
21
  const archivo = stateFile();
20
22
  if (!fs.existsSync(archivo))
@@ -67,7 +69,7 @@ export function limpiarEstadoViejo() {
67
69
  try {
68
70
  if (fs.existsSync(viejo)) {
69
71
  fs.unlinkSync(viejo);
70
- console.error(`[orquestador] Estado en formato anterior eliminado: ${viejo}`);
72
+ log("info", `Estado en formato anterior eliminado: ${viejo}`);
71
73
  }
72
74
  }
73
75
  catch {
@@ -304,6 +306,116 @@ export function bloqueDeRecibos(fasePedida, estado, phases) {
304
306
  ...lineas,
305
307
  ].join("\n");
306
308
  }
309
+ // ─── Pendientes heredados ───────────────────────────────────────────────────
310
+ // Refuerzo del servidor para la regla global PENDIENTES HEREDADOS (#39): si la fase anterior dejó
311
+ // algo sin comprobar, el prompt de la siguiente llega con un aviso que lo nombra. Lee encabezados
312
+ // y frontmatter de documentos que el prompt ya obliga a escribir; no guarda estado ni cambia la
313
+ // deducción de fase. Si el modelo no escribió la sección, el aviso no sale y todo sigue igual.
314
+ //
315
+ // No contradice el rechazo de la #39 a un campo `Estado:`: aquello era hacer depender la
316
+ // compuerta de algo escrito por el modelo. Esto no bloquea ni cambia la fase: solo señala.
317
+ const ENCABEZADO_PENDIENTES = "## Validaciones pendientes";
318
+ // Lo que se escribe por costumbre en una sección que no tiene nada: no es un pendiente.
319
+ const SECCION_VACIA = new Set([
320
+ "",
321
+ "ninguna",
322
+ "ninguno",
323
+ "ninguna pendiente",
324
+ "nada",
325
+ "sin pendientes",
326
+ "no hay pendientes",
327
+ "fase cerrada sin pendientes",
328
+ "n/a",
329
+ "no aplica",
330
+ ]);
331
+ // La sección reducida a su texto: sin viñetas ni numeración, sin énfasis de Markdown (_ * `),
332
+ // sin puntos suspensivos ni punto final, en una línea y en minúsculas. Así "- Ninguna.",
333
+ // "_Ninguna._" o "Ninguna..." cuentan igual que "Ninguna".
334
+ function textoDeSeccion(seccion) {
335
+ return seccion
336
+ .split("\n")
337
+ .map((linea) => linea.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, ""))
338
+ .join(" ")
339
+ .replace(/[_*`]/g, "")
340
+ .replace(/\s+/g, " ")
341
+ .trim()
342
+ .replace(/(?:\.+|…)$/, "")
343
+ .trim()
344
+ .toLowerCase();
345
+ }
346
+ const ESTADOS_PENDIENTES = new Set(["con pendientes", "fallido", "descartado"]);
347
+ // La sección es el texto entre su encabezado y el siguiente "## " (o el final). Devuelve true si
348
+ // existe y tiene algo que no sea una forma de decir "ninguna".
349
+ export function tieneValidacionesPendientes(texto) {
350
+ const lineas = separarFrontmatter(texto).cuerpo.split("\n");
351
+ // El encabezado se compara sin distinguir mayúsculas: "## Validaciones Pendientes" también vale.
352
+ const buscado = ENCABEZADO_PENDIENTES.toLowerCase();
353
+ const inicio = lineas.findIndex((linea) => linea.trimEnd().normalize("NFC").toLowerCase() === buscado);
354
+ if (inicio === -1)
355
+ return false;
356
+ const resto = lineas.slice(inicio + 1);
357
+ const fin = resto.findIndex((linea) => linea.startsWith("## "));
358
+ const seccion = (fin === -1 ? resto : resto.slice(0, fin)).join("\n");
359
+ return !SECCION_VACIA.has(textoDeSeccion(seccion));
360
+ }
361
+ function leerSinFallar(ruta) {
362
+ try {
363
+ return leerTextoAcotado(ruta);
364
+ }
365
+ catch {
366
+ return null; // un documento ilegible no es un pendiente: el aviso es una ayuda, no un control
367
+ }
368
+ }
369
+ export function avisoDePendientes(fasePedida, estado, phases) {
370
+ const ordenadas = [...phases.entries()].sort((a, b) => a[0] - b[0]);
371
+ const lineas = [];
372
+ // La fase anterior es la de mayor número, por debajo de la pedida, cuyo documento existe.
373
+ const anterior = ordenadas
374
+ .filter(([numero, datos]) => numero < fasePedida && datos.documento)
375
+ .reverse()
376
+ .find(([, datos]) => fs.existsSync(path.join(estado.folderPath, datos.documento)));
377
+ if (anterior) {
378
+ const documento = anterior[1].documento;
379
+ const texto = leerSinFallar(path.join(estado.folderPath, documento));
380
+ if (texto !== null && tieneValidacionesPendientes(texto)) {
381
+ lineas.push(`- \`${documento}\` tiene una sección \`${ENCABEZADO_PENDIENTES}\`.`);
382
+ }
383
+ }
384
+ // Los recibos de lote de las fases anteriores que quedaron con pendientes, fallidos o
385
+ // descartados. Con un solo lote, el recibo es el documento de la fase y se mira también.
386
+ for (const [numero, datos] of ordenadas) {
387
+ if (numero >= fasePedida || !datos.recibos)
388
+ continue;
389
+ const candidatos = [];
390
+ if (datos.documento)
391
+ candidatos.push(datos.documento);
392
+ const subcarpeta = path.join(estado.folderPath, datos.recibos);
393
+ if (fs.existsSync(subcarpeta)) {
394
+ for (const entrada of fs.readdirSync(subcarpeta, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
395
+ if (entrada.isFile() && entrada.name.toLowerCase().endsWith(".md"))
396
+ candidatos.push(`${datos.recibos}/${entrada.name}`);
397
+ }
398
+ }
399
+ for (const relativo of candidatos) {
400
+ const ruta = path.join(estado.folderPath, ...relativo.split("/"));
401
+ if (!fs.existsSync(ruta))
402
+ continue;
403
+ const texto = leerSinFallar(ruta);
404
+ const valor = texto === null ? "" : (separarFrontmatter(texto).meta.estado ?? "").trim().toLowerCase();
405
+ if (ESTADOS_PENDIENTES.has(valor))
406
+ lineas.push(`- \`${relativo}\`: estado \`${valor}\`.`);
407
+ }
408
+ }
409
+ if (lineas.length === 0)
410
+ return null;
411
+ return [
412
+ "## PENDIENTES HEREDADOS (detectados por el servidor)",
413
+ "",
414
+ ...lineas,
415
+ "",
416
+ "Nómbraselos al usuario antes de empezar (ver PENDIENTES HEREDADOS en las reglas globales).",
417
+ ].join("\n");
418
+ }
307
419
  export function avisoDeSalto(salto, taskName, phases) {
308
420
  const titulo = phases.get(salto.siguienteFase)?.title;
309
421
  const vaEn = titulo ? `Fase ${salto.siguienteFase} (${titulo})` : `Fase ${salto.siguienteFase}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-phase-gate",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada una deje su documento en una documentación central.",
5
5
  "main": "build/index.js",
6
6
  "bin": {
@@ -43,7 +43,6 @@
43
43
  "homepage": "https://github.com/Montse2308/mcp-phase-gate#readme",
44
44
  "dependencies": {
45
45
  "@modelcontextprotocol/sdk": "^1.32.1",
46
- "dotenv": "^17.4.2",
47
46
  "zod": "^4.4.3"
48
47
  },
49
48
  "devDependencies": {
@@ -0,0 +1,14 @@
1
+ Vas a iniciar la FASE 1 (Descubrimiento) del flujo del mcp-phase-gate.
2
+
3
+ Datos de la tarea (tómalos del texto que escribí después del comando; si falta alguno, pregúntame antes de seguir):
4
+ - project: uno de los proyectos que ya existen. Si no lo reconoce, `start_task` te devuelve la lista; no inventes uno ni pases `crear_proyecto` salvo que yo te diga explícitamente que quiero estrenar un proyecto nuevo.
5
+ - task_name: nombre corto de la tarea
6
+ - initial_context: el correo / mensaje / requerimiento completo
7
+
8
+ Pasos:
9
+ 1. Llama a la tool `start_task` del mcp-phase-gate con project, task_name e initial_context.
10
+ 2. Llama a `get_phase_prompt(1)` indicándole también el `project` en el que estoy trabajando —con eso comprueba que no me esté saltando una fase— y asume ese rol de comportamiento.
11
+ 3. Analiza el código actual de este repositorio para entender el impacto del requerimiento.
12
+ 4. Guarda el análisis técnico y funcional con `write_central_doc`, usando el nombre de archivo exacto que te indicó `get_phase_prompt`.
13
+
14
+ No escribas código de producción todavía.
@@ -0,0 +1,12 @@
1
+ Vas a iniciar la FASE 2 (Decisiones) del flujo del mcp-phase-gate.
2
+
3
+ Pasos:
4
+ 1. Si no sabes cuál es la tarea activa, llama a `get_active_task` (o pídeme project + task_name).
5
+ 2. Llama a `get_phase_prompt(2)` indicándole también el `project` en el que estoy trabajando —con eso comprueba que no me esté saltando una fase— y asume ese rol.
6
+ 3. Lee el documento de la Fase 1 con `read_central_doc`; `get_phase_prompt` te dio su nombre exacto.
7
+ 4. Identifica lagunas técnicas, riesgos y dependencias con el código existente.
8
+ 5. Hazme la lista de preguntas críticas de decisión, explicándome las implicaciones de cada una.
9
+ 6. Detente y espera mis respuestas. No escribas código aún.
10
+ 7. Cuando ya te haya respondido: valida cada decisión contra el código real antes de darla por cerrada y guarda el documento de la fase con `write_central_doc`, con el nombre que te indicó `get_phase_prompt` y la estructura que pide el prompt.
11
+
12
+ El documento de la Fase 2 es obligatorio, aunque las decisiones se hayan tomado conversando: el chat no viaja entre dispositivos, el documento sí.
@@ -0,0 +1,9 @@
1
+ Vas a iniciar la FASE 3 (Plan Técnico) del flujo del mcp-phase-gate.
2
+
3
+ Pasos:
4
+ 1. Si no sabes la tarea activa, llama a `get_active_task` (o pídeme project + task_name).
5
+ 2. Llama a `get_phase_prompt(3)` indicándole también el `project` en el que estoy trabajando —con eso comprueba que no me esté saltando una fase— y asume ese rol.
6
+ 3. Lee los documentos de las fases anteriores con `read_central_doc`; `get_phase_prompt` te dio sus nombres exactos.
7
+ 4. Comprueba contra el código real lo que el plan va a afirmar, ANTES de escribirlo.
8
+ 5. Con las decisiones ya tomadas, genera el plan con su tabla de lotes, respetando "Cero Rupturas".
9
+ 6. Guárdalo con `write_central_doc`, con el nombre que te indicó `get_phase_prompt`.
@@ -0,0 +1,10 @@
1
+ Vas a iniciar la FASE 4 (Ejecución) del flujo del mcp-phase-gate.
2
+
3
+ Pasos:
4
+ 1. Si no sabes la tarea activa, llama a `get_active_task` (o pídeme project + task_name).
5
+ 2. Llama a `get_phase_prompt(4)` indicándole también el `project` en el que estoy trabajando —con eso comprueba que no me esté saltando una fase— y asume ese rol.
6
+ 3. Lee el plan técnico de la Fase 3 con `read_central_doc` —`get_phase_prompt` te dio su nombre exacto— y nómbrame sus pendientes antes de tocar código.
7
+ 4. Ejecútalo **lote por lote**, como indica el prompt: implementa un lote, verifícalo ejecutando, escribe su recibo, muéstrame archivos y mensaje de commit, y con mi OK haz el commit antes de abrir el siguiente. Nunca hagas push.
8
+ 5. Con un solo lote, su recibo es el documento de la fase. Con más de uno, el documento de la fase es el índice y se escribe solo al cerrar el último.
9
+
10
+ El entregable de esta fase es el código funcionando, lote por lote; cada recibo es corto y se escribe al cerrar su lote.
@@ -0,0 +1,12 @@
1
+ Vas a iniciar la FASE 5 (Auditoría / Pre-PR) del flujo del mcp-phase-gate.
2
+
3
+ Commits a auditar: si escribí un rango después del comando, usa ese. Si no, tómalos de los recibos de la Fase 4. Si tampoco hay recibos, pregúntame cuáles antes de seguir.
4
+
5
+ Pasos:
6
+ 1. Si no sabes la tarea activa, llama a `get_active_task` (o pídeme project + task_name): la auditoría se guarda en su carpeta.
7
+ 2. Llama a `get_phase_prompt(5)` indicándole también el `project` en el que estoy trabajando —con eso comprueba que no me esté saltando una fase— y asume ese rol de auditor externo.
8
+ 3. Inspecciona los commits indicados usando SOLO lectura (git diff / git log de solo lectura, leer archivos). Nunca hagas commits, push ni subas PRs; de eso me encargo yo.
9
+ 4. Entrégame en el chat:
10
+ - La AUDITORÍA con el formato del revisor, dejando clarísimo si hay cambios BLOQUEANTES para subir el PR.
11
+ - La DESCRIPCIÓN del PR (título + cuerpo), contrastada contra el código real.
12
+ 5. Guarda el documento de la fase con `write_central_doc`, con el nombre que te indicó `get_phase_prompt`: lleva la auditoría y la descripción del PR tal cual me las entregaste.