mcp-phase-gate 2.0.1 → 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.
package/build/schemas.js CHANGED
@@ -1,105 +1,181 @@
1
1
  // Los argumentos de cada tool, declarados una sola vez con zod.
2
2
  //
3
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.
4
+ // String(undefined) creaba la tarea "undefined/" y la dejaba activa. Desde la Etapa 0 todo pasa
5
+ // por aquí antes de usarse, y el error nombra el campo.
6
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).
7
+ // Se exportan shapes (los campos sueltos) y no z.object(...): es lo que recibe registerTool del
8
+ // SDK. El SDK genera con ellos el JSON Schema que ve el cliente —por eso cada campo lleva aquí su
9
+ // descripción— y valida cada llamada antes del callback.
10
+ //
11
+ // Los mensajes de error van en cada campo, en español, porque el SDK los entrega tal cual detrás
12
+ // de su prefijo en inglés ("Input validation error: Invalid arguments for tool …"). Sin ellos
13
+ // llegaría el texto genérico de zod ("expected string, received undefined"), que no le dice al
14
+ // modelo cómo corregir la llamada.
9
15
  //
10
16
  // Los opcionales son .nullish() y null cuenta como ausente: algunos clientes con modelos en
11
17
  // modo estricto mandan null en todo campo opcional. Un número en texto (phase: "3") no se
12
18
  // 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í.
19
+ // al primer intento, y convertirlo complicaría el JSON Schema que se genera desde aquí.
14
20
  import { z } from "zod";
21
+ // Lo que se espera de cada tipo, dicho como se le diría al modelo para que corrija la llamada.
22
+ // El único número es `phase`, de ahí el ejemplo.
23
+ const TIPOS = {
24
+ string: "un texto",
25
+ number: "un número, por ejemplo 3",
26
+ int: "un número entero, por ejemplo 3",
27
+ boolean: "true o false",
28
+ object: "un objeto",
29
+ };
30
+ // El mensaje de un campo. null también es "falta": en un opcional se tolera antes de llegar aquí,
31
+ // en un obligatorio es lo mismo que no venir.
32
+ function mensaje(campo) {
33
+ const nombre = `\`${campo}\``;
34
+ return (issue) => {
35
+ if (issue.input === undefined || issue.input === null)
36
+ return `Falta ${nombre}.`;
37
+ if (issue.code === "invalid_type")
38
+ return `${nombre} debe ser ${TIPOS[issue.expected] ?? issue.expected}.`;
39
+ // El único máximo que puede saltar es el del entero seguro de `phase`.
40
+ if (issue.code === "too_big")
41
+ return `${nombre} es demasiado grande.`;
42
+ return `${nombre} no es válido.`;
43
+ };
44
+ }
45
+ function texto(campo) {
46
+ return z.string({ error: mensaje(campo) });
47
+ }
48
+ // `recortar` quita los espacios ANTES de exigir el mínimo: " " cuenta como que falta.
49
+ function obligatorio(campo, opciones = {}) {
50
+ const base = opciones.recortar ? texto(campo).trim() : texto(campo);
51
+ return base.min(1, `Falta \`${campo}\`.`);
52
+ }
53
+ function booleano(campo) {
54
+ return z.boolean({ error: mensaje(campo) });
55
+ }
56
+ // ─── Descripciones ──────────────────────────────────────────────────────────
57
+ // Son estáticas: no nombran proyectos ni fases concretas. Antes se armaban al arrancar con la
58
+ // lista de proyectos y de fases, y se quedaban viejas en cuanto alguien creaba una carpeta con el
59
+ // cliente abierto, porque la lista de tools no se vuelve a pedir. Lo que cambia vive ahora en los
60
+ // recursos phase-gate://estado y phase-gate://fases, que sí avisan cuando cambian.
61
+ const PROYECTO = "Proyecto: el nombre de una carpeta de proyecto de la Documentación Central. Si no existe, la " +
62
+ "tool te devuelve los que hay. También los listan `list_tasks`, `get_orchestrator_status` y el recurso " +
63
+ "`phase-gate://estado`.";
64
+ const FASE = "Número de fase. Con los prompts de fábrica son 1 a 5; `get_orchestrator_status` y el recurso " +
65
+ "`phase-gate://fases` listan las disponibles con su título y documento.";
66
+ const TAREA = "Nombre de la carpeta de la tarea.";
67
+ const ARCHIVO = "Nombre (o ruta relativa) del archivo dentro de la carpeta de la tarea (ej. '01 - Análisis Técnico.md').";
68
+ // ─── Shapes ─────────────────────────────────────────────────────────────────
15
69
  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(),
70
+ project: obligatorio("project", { recortar: true }).describe(PROYECTO),
71
+ task_name: obligatorio("task_name").describe("Nombre de la tarea (creará una carpeta con este nombre)."),
72
+ initial_context: obligatorio("initial_context").describe("El contexto, correo o requerimiento inicial completo."),
73
+ crear_proyecto: booleano("crear_proyecto")
74
+ .nullish()
75
+ .describe("Solo para ESTRENAR un proyecto que todavía no existe. Déjalo sin poner salvo que el usuario te haya " +
76
+ "dicho explícitamente que quiere crear un proyecto nuevo: sirve para que un nombre mal escrito no cree " +
77
+ "una carpeta suelta en la Documentación."),
20
78
  };
21
79
  export const getPhasePromptShape = {
22
- phase: z.number().int(),
23
- project: z.string().nullish(),
24
- task_name: z.string().nullish(),
80
+ phase: z.number({ error: mensaje("phase") }).int({ error: mensaje("phase") }).describe(FASE),
81
+ project: texto("project")
82
+ .nullish()
83
+ .describe(`${PROYECTO} Pásalo siempre que lo sepas —lo sabes por el repositorio que tienes abierto— para que se ` +
84
+ "pueda comprobar que no te saltas una fase. La tarea se toma de la activa de ese proyecto."),
85
+ task_name: texto("task_name")
86
+ .nullish()
87
+ .describe("Solo si quieres comprobar contra una tarea distinta de la activa del proyecto. Normalmente se omite."),
25
88
  };
26
89
  export const getActiveTaskShape = {
27
- project: z.string().nullish(),
90
+ project: texto("project")
91
+ .nullish()
92
+ .describe(`${PROYECTO} Si lo omites, se devuelve la tarea activa de cada proyecto.`),
28
93
  };
29
94
  export const listTasksShape = {
30
- project: z.string().nullish(),
95
+ project: texto("project").nullish().describe(`${PROYECTO} Si lo omites, se listan todos.`),
31
96
  };
32
97
  export const switchTaskShape = {
33
- project: z.string().trim().min(1, "Falta `project`."),
34
- task_name: z.string().min(1, "Falta `task_name`."),
98
+ project: obligatorio("project", { recortar: true }).describe(PROYECTO),
99
+ task_name: obligatorio("task_name").describe("Nombre de la carpeta de la tarea a activar. Si no existe, la tool te devuelve las que sí."),
35
100
  };
36
101
  // Los cuatro son opcionales por separado: qué combinación vale lo decide resolveDocPath.
37
102
  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(),
103
+ project: texto("project").nullish().describe(`${PROYECTO} Úsalo junto con task_name y file_name.`),
104
+ task_name: texto("task_name").nullish().describe(TAREA),
105
+ file_name: texto("file_name").nullish().describe(ARCHIVO),
106
+ file_path: texto("file_path")
107
+ .nullish()
108
+ .describe("ALTERNATIVA: ruta relativa dentro de la Documentación Central (ej. " +
109
+ "'PROYECTO-A/Proyectos/tarea/01 - Análisis Técnico.md'). Solo si no usas project+task_name."),
42
110
  };
43
111
  export const writeCentralDocShape = {
44
112
  ...readCentralDocShape,
45
- content: z.string(),
113
+ file_path: texto("file_path")
114
+ .nullish()
115
+ .describe("ALTERNATIVA: ruta relativa dentro de la Documentación Central, que tiene que caer dentro de la carpeta " +
116
+ "de una tarea que ya existe (ej. 'PROYECTO-A/Proyectos/tarea/01 - Análisis Técnico.md'). Los archivos " +
117
+ "del proyecto no se escriben con esta tool. Solo si no usas project+task_name."),
118
+ content: texto("content").describe("Contenido a escribir."),
119
+ force: booleano("force")
120
+ .nullish()
121
+ .describe("Permite reescribir un documento congelado o el contexto inicial. Solo se usa si tu cliente no puede " +
122
+ "pedirle la confirmación al usuario directamente. Nunca lo pongas sin que el usuario lo haya confirmado en el chat."),
46
123
  };
47
124
  export const readCrossRepoShape = {
48
- repo_name: z.string().min(1, "Falta `repo_name`."),
49
- file_path: z.string().min(1, "Falta `file_path`."),
125
+ repo_name: obligatorio("repo_name").describe("Nombre del repositorio (ej. 'repo-a', 'repo-b')."),
126
+ file_path: obligatorio("file_path").describe("Ruta relativa del archivo dentro del repositorio (ej. 'src/main.ts')."),
50
127
  };
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,
128
+ // get_orchestrator_status no recibe nada.
129
+ export const getOrchestratorStatusShape = {};
130
+ // ─── Salida de get_orchestrator_status ──────────────────────────────────────
131
+ // El objeto Diagnostico de diagnostico.ts, campo por campo. Con un outputSchema el cliente recibe
132
+ // además `structuredContent`, y el SDK comprueba antes de enviarlo que cuadra con este shape:
133
+ // si diagnostico.ts cambia y esto no, la tool falla en los tests en vez de mentir al cliente.
134
+ const estadoRuta = z.object({ ruta: z.string().optional(), existe: z.boolean(), error: z.string().optional() });
135
+ export const diagnosticoShape = {
136
+ paquete: z.object({
137
+ version: z.string(),
138
+ ruta: z.string(),
139
+ via: z.enum(["npx", "global", "dependencia", "clon"]),
140
+ }),
141
+ node: z.object({ version: z.string(), plataforma: z.string() }),
142
+ variables: z.array(z.object({
143
+ logica: z.enum(["DOCS_PATH", "REPOS_PATH", "TASKS_SUBDIR", "PROMPTS_PATH", "STATE_PATH", "ELICITATION"]),
144
+ valor: z.string().optional(),
145
+ usada: z.string().nullable(),
146
+ fuente: z.enum(["entorno", ".env", "sin definir"]),
147
+ discrepancia: z.string().nullable(),
148
+ })),
149
+ rutas: z.object({ docs: estadoRuta, repos: estadoRuta }),
150
+ estado: z.object({ archivo: z.string(), existe: z.boolean(), punteros: z.number().int() }),
151
+ capas: z.array(z.object({ ruta: z.string(), etiqueta: z.enum(["fábrica", "propia"]), existe: z.boolean() })),
152
+ reglasGlobales: z.object({ archivo: z.string().nullable(), capa: z.string().nullable(), faltan: z.array(z.string()) }),
153
+ fases: z.array(z.object({
154
+ numero: z.number().int(),
155
+ titulo: z.string(),
156
+ archivo: z.string(),
157
+ capa: z.string(),
158
+ documento: z.string().optional(),
159
+ recibos: z.string().optional(),
160
+ comando: z.string().nullable(),
161
+ })),
162
+ proyectos: z.array(z.object({
163
+ nombre: z.string(),
164
+ tareas: z.number().int(),
165
+ activa: z
166
+ .object({ tarea: z.string(), origen: z.enum(["puntero", "deducida"]), fase: z.string() })
167
+ .nullable(),
168
+ })),
169
+ cliente: z
170
+ .object({
171
+ nombre: z.string(),
172
+ version: z.string(),
173
+ capacidades: z.object({ elicitation: z.boolean(), roots: z.boolean() }),
174
+ })
175
+ .optional(),
176
+ hallazgos: z.array(z.object({ severidad: z.enum(["error", "aviso", "info"]), codigo: z.string(), texto: z.string() })),
60
177
  };
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 };
178
+ export function sinNulos(args) {
179
+ return Object.fromEntries(Object.entries(args).filter(([, valor]) => valor !== null));
104
180
  }
105
181
  //# sourceMappingURL=schemas.js.map
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function crearServidor(): McpServer;
3
+ //# sourceMappingURL=servidor.d.ts.map
@@ -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