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.
- package/README.es.md +125 -57
- package/README.md +109 -50
- package/build/cli.d.ts +14 -0
- package/build/cli.js +98 -0
- package/build/config.d.ts +27 -2
- package/build/config.js +151 -22
- package/build/confirmacion.d.ts +15 -0
- package/build/confirmacion.js +100 -0
- package/build/diagnostico.d.ts +83 -0
- package/build/diagnostico.js +394 -0
- package/build/index.js +28 -388
- package/build/log.d.ts +6 -0
- package/build/log.js +27 -0
- package/build/paths.js +3 -3
- package/build/phases.d.ts +10 -1
- package/build/phases.js +48 -17
- package/build/prompts-mcp.d.ts +33 -0
- package/build/prompts-mcp.js +194 -0
- package/build/recursos.d.ts +8 -0
- package/build/recursos.js +306 -0
- package/build/schemas.d.ts +105 -51
- package/build/schemas.js +151 -78
- package/build/servidor.d.ts +3 -0
- package/build/servidor.js +372 -0
- package/build/tasks.d.ts +2 -0
- package/build/tasks.js +114 -2
- package/package.json +1 -2
- package/prompts/comandos/f1.md +14 -0
- package/prompts/comandos/f2.md +12 -0
- package/prompts/comandos/f3.md +9 -0
- package/prompts/comandos/f4.md +10 -0
- package/prompts/comandos/f5.md +12 -0
package/build/phases.js
CHANGED
|
@@ -4,9 +4,37 @@
|
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { promptsLayers } from "./config.js";
|
|
7
|
+
import { log } from "./log.js";
|
|
7
8
|
import { claveDeNombre, validarSegmento } from "./paths.js";
|
|
8
9
|
// Las reglas globales se anteponen a cada fase.
|
|
9
|
-
const GLOBAL_RULES_FILE = "global-rules.md";
|
|
10
|
+
export const GLOBAL_RULES_FILE = "global-rules.md";
|
|
11
|
+
// Comando de cada fase (`/fN`): `comandos/fN.md` dentro de una capa de prompts. Gana la capa más
|
|
12
|
+
// alta que lo tenga, igual que las fases; sin ninguno, la fase usa un texto genérico.
|
|
13
|
+
export const CARPETA_COMANDOS = "comandos";
|
|
14
|
+
export function resolveCommandFile(numero, capas = promptsLayers()) {
|
|
15
|
+
for (const base of [...capas].reverse()) {
|
|
16
|
+
const ruta = path.join(base, CARPETA_COMANDOS, `f${numero}.md`);
|
|
17
|
+
if (tipoDe(ruta) === "archivo")
|
|
18
|
+
return ruta;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// statSync con throwIfNoEntry solo calla el ENOENT: si un tramo de la ruta es un archivo
|
|
23
|
+
// (ENOTDIR) o no hay permiso, lanza igual. Para preguntar "¿es una carpeta?" eso no es un error.
|
|
24
|
+
function tipoDe(ruta) {
|
|
25
|
+
try {
|
|
26
|
+
const info = fs.statSync(ruta, { throwIfNoEntry: false });
|
|
27
|
+
if (!info)
|
|
28
|
+
return null;
|
|
29
|
+
return info.isDirectory() ? "carpeta" : info.isFile() ? "archivo" : "otro";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function esCarpeta(ruta) {
|
|
36
|
+
return tipoDe(ruta) === "carpeta";
|
|
37
|
+
}
|
|
10
38
|
// Acepta "fase-1-descubrimiento.md" y también "phase-1-discovery.md",
|
|
11
39
|
// para que un juego de prompts en inglés funcione sin tocar el código.
|
|
12
40
|
export const PHASE_FILE_PATTERN = /^(?:fase|phase)-(\d+)[-.]/i;
|
|
@@ -20,7 +48,7 @@ const PHASE_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
|
20
48
|
// tests va siempre en LF y sin BOM. Con el BOM delante, el frontmatter no se reconocía y la fase
|
|
21
49
|
// se quedaba sin documento.
|
|
22
50
|
function aLF(texto) {
|
|
23
|
-
return texto.replace(
|
|
51
|
+
return texto.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
24
52
|
}
|
|
25
53
|
// Quita los marcadores de ranura y conserva su contenido. El modelo nunca los ve.
|
|
26
54
|
// Una ranura marca el contenido de una sección que un preset podrá sustituir en la Etapa 3:
|
|
@@ -39,7 +67,7 @@ const RESTO_DE_MARCADOR = /<!--[^\n]*?ranura/i;
|
|
|
39
67
|
function entregable(texto, archivo) {
|
|
40
68
|
const limpio = quitarMarcadores(texto);
|
|
41
69
|
if (RESTO_DE_MARCADOR.test(limpio)) {
|
|
42
|
-
|
|
70
|
+
log("warning", `${archivo} tiene un marcador de ranura que no se reconoce: llega tal cual al modelo.`);
|
|
43
71
|
}
|
|
44
72
|
return limpio.trim();
|
|
45
73
|
}
|
|
@@ -56,7 +84,7 @@ function leerRecibos(valor, archivo) {
|
|
|
56
84
|
return segmento;
|
|
57
85
|
}
|
|
58
86
|
catch (error) {
|
|
59
|
-
|
|
87
|
+
log("warning", `Se ignora el frontmatter recibos de ${archivo}: ${error.message}`);
|
|
60
88
|
return undefined;
|
|
61
89
|
}
|
|
62
90
|
}
|
|
@@ -73,14 +101,19 @@ function descartarRecibosQueChocan(phases) {
|
|
|
73
101
|
}
|
|
74
102
|
for (const datos of phases.values()) {
|
|
75
103
|
if (datos.recibos && documentos.has(claveDeNombre(datos.recibos))) {
|
|
76
|
-
|
|
104
|
+
log("warning", `Se ignora el frontmatter recibos de ${datos.file}: ` +
|
|
77
105
|
`"${datos.recibos}" es el nombre de un documento de fase.`);
|
|
78
106
|
datos.recibos = undefined;
|
|
79
107
|
}
|
|
80
108
|
}
|
|
81
109
|
}
|
|
82
110
|
export function parsePhaseFile(fullPath) {
|
|
83
|
-
|
|
111
|
+
return separarFrontmatter(fs.readFileSync(fullPath, "utf-8"));
|
|
112
|
+
}
|
|
113
|
+
// La cabecera "clave: valor" de un texto ya leído, y el resto. Sirve igual para un prompt que
|
|
114
|
+
// para un recibo de lote, que usan el mismo formato.
|
|
115
|
+
export function separarFrontmatter(texto) {
|
|
116
|
+
const crudo = aLF(texto);
|
|
84
117
|
const encontrada = crudo.match(PHASE_FRONTMATTER);
|
|
85
118
|
if (!encontrada)
|
|
86
119
|
return { meta: {}, cuerpo: crudo };
|
|
@@ -109,7 +142,9 @@ export function parsePhaseFile(fullPath) {
|
|
|
109
142
|
export function discoverPhases(capas = promptsLayers()) {
|
|
110
143
|
const phases = new Map();
|
|
111
144
|
for (const base of capas) {
|
|
112
|
-
|
|
145
|
+
// Una capa que no existe o que no es una carpeta (PHASE_GATE_PROMPTS_PATH apuntando a un
|
|
146
|
+
// archivo) se salta: doctor la avisa, y las fases de las demás capas siguen sirviendo.
|
|
147
|
+
if (!esCarpeta(base))
|
|
113
148
|
continue;
|
|
114
149
|
// Se resuelve capa por capa: dentro de una, gana el primero por orden alfabético; entre
|
|
115
150
|
// capas, gana la de más arriba. Sin este mapa intermedio la regla alfabética de la capa
|
|
@@ -186,7 +221,7 @@ export function loadPhasePrompt(phase, capas = promptsLayers()) {
|
|
|
186
221
|
// fábrica, no se suman: concatenar dos juegos de reglas hace imposible saber de qué archivo
|
|
187
222
|
// salió la instrucción que estorbó cuando algo sale raro, que es justo el momento en que hay
|
|
188
223
|
// que saberlo. Si quieres las de fábrica y algo más, las copias y añades.
|
|
189
|
-
function resolveGlobalRules(capas) {
|
|
224
|
+
export function resolveGlobalRules(capas = promptsLayers()) {
|
|
190
225
|
for (const base of [...capas].reverse()) {
|
|
191
226
|
const ruta = path.join(base, GLOBAL_RULES_FILE);
|
|
192
227
|
if (fs.existsSync(ruta))
|
|
@@ -194,6 +229,11 @@ function resolveGlobalRules(capas) {
|
|
|
194
229
|
}
|
|
195
230
|
return null;
|
|
196
231
|
}
|
|
232
|
+
// El texto de un prompt propio tal como lo ve el modelo: en LF y sin BOM. Lo usan quienes leen
|
|
233
|
+
// un archivo de prompts fuera de este módulo, como el diagnóstico.
|
|
234
|
+
export function leerPromptNormalizado(ruta) {
|
|
235
|
+
return aLF(fs.readFileSync(ruta, "utf-8"));
|
|
236
|
+
}
|
|
197
237
|
// Bloque que se añade al final del prompt con los nombres exactos de los documentos: el que
|
|
198
238
|
// produce esta fase y los de las anteriores, que son los que hay que leer, y la subcarpeta de
|
|
199
239
|
// recibos de la fase que la declare. Va aquí y no en el
|
|
@@ -229,13 +269,4 @@ export function describePhaseFiles(available, phase) {
|
|
|
229
269
|
}
|
|
230
270
|
return lineas.join("\n");
|
|
231
271
|
}
|
|
232
|
-
export function describePhases() {
|
|
233
|
-
const available = discoverPhases();
|
|
234
|
-
if (available.size === 0)
|
|
235
|
-
return "No se encontraron prompts de fase.";
|
|
236
|
-
return [...available.entries()]
|
|
237
|
-
.sort((a, b) => a[0] - b[0])
|
|
238
|
-
.map(([number, { title }]) => `${number}: ${title}`)
|
|
239
|
-
.join(", ");
|
|
240
|
-
}
|
|
241
272
|
//# sourceMappingURL=phases.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare function argumentosDeFase(numero: number): {
|
|
4
|
+
project: z.ZodOptional<z.ZodString>;
|
|
5
|
+
task_name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
contexto: z.ZodOptional<z.ZodString>;
|
|
7
|
+
commits?: never;
|
|
8
|
+
} | {
|
|
9
|
+
project: z.ZodOptional<z.ZodString>;
|
|
10
|
+
commits: z.ZodOptional<z.ZodString>;
|
|
11
|
+
task_name?: never;
|
|
12
|
+
contexto?: never;
|
|
13
|
+
} | {
|
|
14
|
+
project: z.ZodOptional<z.ZodString>;
|
|
15
|
+
task_name?: never;
|
|
16
|
+
contexto?: never;
|
|
17
|
+
commits?: never;
|
|
18
|
+
};
|
|
19
|
+
export declare function tituloDePrompt(numero: number, titulo: string): string;
|
|
20
|
+
export declare function descripcionDePrompt(numero: number, titulo: string): string;
|
|
21
|
+
export declare function textoGenerico(numero: number, titulo: string): string;
|
|
22
|
+
type Argumentos = {
|
|
23
|
+
project?: string | undefined;
|
|
24
|
+
task_name?: string | undefined;
|
|
25
|
+
commits?: string | undefined;
|
|
26
|
+
contexto?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
export declare function textoDelComando(numero: number, titulo: string, args: Argumentos, capas?: string[]): string;
|
|
29
|
+
export declare function sincronizarPrompts(mcp: McpServer): void;
|
|
30
|
+
export declare function asegurarCapacidadDePrompts(mcp: McpServer): void;
|
|
31
|
+
export declare function vigilarCapas(mcp: McpServer, esperaMs?: number): () => void;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=prompts-mcp.d.ts.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Los comandos de fase (/f1…/fN) servidos como MCP Prompts. Antes había que copiar cada comando a
|
|
2
|
+
// mano en la carpeta de cada cliente y de cada equipo, y las copias se desincronizaban; ahora el
|
|
3
|
+
// servidor los ofrece a cualquier cliente que muestre prompts, y llegan con cada versión.
|
|
4
|
+
//
|
|
5
|
+
// El texto de cada comando vive en `comandos/fN.md` dentro de una capa de prompts (los de fábrica
|
|
6
|
+
// en prompts/comandos/), y se resuelve por capas como las fases: gana la más alta que lo tenga.
|
|
7
|
+
// Una fase sin comando, como una fase-6 propia, recibe un texto genérico que funciona igual.
|
|
8
|
+
//
|
|
9
|
+
// El prompt NO incluye el prompt de la fase: sigue siendo orquestación ("llama a
|
|
10
|
+
// get_phase_prompt(N)…"). Devolver ya el texto de la fase ahorraría una llamada, pero crearía dos
|
|
11
|
+
// caminos para recibir el mismo prompt, y en uno la compuerta no recibiría el `project`.
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { promptsLayers } from "./config.js";
|
|
15
|
+
import { log } from "./log.js";
|
|
16
|
+
import { discoverPhases, parsePhaseFile, resolveCommandFile } from "./phases.js";
|
|
17
|
+
// Todos los argumentos son opcionales: el texto de cada comando ya pide lo que falte, y así el
|
|
18
|
+
// prompt sirve también en clientes que parten los argumentos por espacios (un contexto largo puede
|
|
19
|
+
// ir pegado después del comando, como texto normal del chat).
|
|
20
|
+
const PROJECT = z.string().optional().describe("Proyecto en el que estás trabajando.");
|
|
21
|
+
export function argumentosDeFase(numero) {
|
|
22
|
+
if (numero === 1) {
|
|
23
|
+
return {
|
|
24
|
+
project: PROJECT,
|
|
25
|
+
task_name: z.string().optional().describe("Nombre corto de la tarea nueva."),
|
|
26
|
+
contexto: z.string().optional().describe("El correo, mensaje o requerimiento completo."),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (numero === 5) {
|
|
30
|
+
return {
|
|
31
|
+
project: PROJECT,
|
|
32
|
+
commits: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Rango de commits a auditar. Si lo omites, se toman de los recibos de la Fase 4."),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { project: PROJECT };
|
|
39
|
+
}
|
|
40
|
+
export function tituloDePrompt(numero, titulo) {
|
|
41
|
+
return `Fase ${numero} · ${titulo}`;
|
|
42
|
+
}
|
|
43
|
+
export function descripcionDePrompt(numero, titulo) {
|
|
44
|
+
return `Arranca la Fase ${numero} (${titulo}) del flujo de mcp-phase-gate.`;
|
|
45
|
+
}
|
|
46
|
+
export function textoGenerico(numero, titulo) {
|
|
47
|
+
return [
|
|
48
|
+
`Vas a iniciar la FASE ${numero} (${titulo}) del flujo del mcp-phase-gate.`,
|
|
49
|
+
"",
|
|
50
|
+
"Pasos:",
|
|
51
|
+
"1. Si no sabes cuál es la tarea activa, llama a `get_active_task` (o pídeme project + task_name).",
|
|
52
|
+
`2. Llama a \`get_phase_prompt(${numero})\` indicándole también el \`project\` en el que estoy trabajando, y asume ese rol.`,
|
|
53
|
+
"3. Lee los documentos de las fases anteriores con `read_central_doc`; `get_phase_prompt` te dio sus nombres exactos.",
|
|
54
|
+
"4. Sigue el prompt de la fase y guarda su documento con `write_central_doc`, con el nombre que te indicó `get_phase_prompt`.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
// La sección con lo que vino como argumento. Solo aparecen las líneas de los que vinieron, y sin
|
|
58
|
+
// ninguno la sección no existe: así el texto queda igual que el comando escrito a mano.
|
|
59
|
+
function datosDeInvocacion(args) {
|
|
60
|
+
const presente = (valor) => typeof valor === "string" && valor.trim() !== "";
|
|
61
|
+
const lineas = [];
|
|
62
|
+
// project, task_name y commits son de una línea: los saltos se aplanan, para que un valor no
|
|
63
|
+
// pueda abrir secciones nuevas dentro del texto. El contexto sí es multilínea por naturaleza, y
|
|
64
|
+
// va tal cual: lo escribe la persona, igual que si lo pegara en el chat.
|
|
65
|
+
const enUnaLinea = (valor) => valor.replace(/\s+/g, " ").trim();
|
|
66
|
+
if (presente(args.project))
|
|
67
|
+
lineas.push(`- project: ${enUnaLinea(args.project)}`);
|
|
68
|
+
if (presente(args.task_name))
|
|
69
|
+
lineas.push(`- task_name: ${enUnaLinea(args.task_name)}`);
|
|
70
|
+
if (presente(args.commits))
|
|
71
|
+
lineas.push(`- commits: ${enUnaLinea(args.commits)}`);
|
|
72
|
+
if (presente(args.contexto))
|
|
73
|
+
lineas.push("- initial_context:", "", args.contexto.trim());
|
|
74
|
+
return lineas.length ? ["## Datos de esta invocación", ...lineas].join("\n") : null;
|
|
75
|
+
}
|
|
76
|
+
// El texto se lee en cada llamada, como los prompts de fase: editar un comando no exige reiniciar.
|
|
77
|
+
export function textoDelComando(numero, titulo, args, capas = promptsLayers()) {
|
|
78
|
+
const archivo = resolveCommandFile(numero, capas);
|
|
79
|
+
let cuerpo;
|
|
80
|
+
if (archivo) {
|
|
81
|
+
try {
|
|
82
|
+
cuerpo = parsePhaseFile(archivo).cuerpo.trim();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
log("warning", `No se pudo leer ${archivo}; se usa el texto genérico: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
cuerpo = textoGenerico(numero, titulo);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
cuerpo = textoGenerico(numero, titulo);
|
|
91
|
+
}
|
|
92
|
+
const datos = datosDeInvocacion(args);
|
|
93
|
+
return datos ? `${cuerpo}\n\n${datos}` : cuerpo;
|
|
94
|
+
}
|
|
95
|
+
const registros = new WeakMap();
|
|
96
|
+
function registrar(mcp, numero, titulo) {
|
|
97
|
+
return mcp.registerPrompt(`f${numero}`, {
|
|
98
|
+
title: tituloDePrompt(numero, titulo),
|
|
99
|
+
description: descripcionDePrompt(numero, titulo),
|
|
100
|
+
argsSchema: argumentosDeFase(numero),
|
|
101
|
+
}, async (args) => {
|
|
102
|
+
// El título se toma del registro vigente: puede haber cambiado desde que se registró.
|
|
103
|
+
const vigente = registros.get(mcp)?.get(numero)?.titulo ?? titulo;
|
|
104
|
+
return {
|
|
105
|
+
description: descripcionDePrompt(numero, vigente),
|
|
106
|
+
messages: [{ role: "user", content: { type: "text", text: textoDelComando(numero, vigente, args) } }],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Nunca lanza: se llama tras cada tool, y un prompt que no se pudo registrar no debe convertir en
|
|
111
|
+
// error la respuesta de una tool que salió bien.
|
|
112
|
+
export function sincronizarPrompts(mcp) {
|
|
113
|
+
try {
|
|
114
|
+
const capas = promptsLayers();
|
|
115
|
+
const fases = discoverPhases(capas);
|
|
116
|
+
let actuales = registros.get(mcp);
|
|
117
|
+
if (!actuales) {
|
|
118
|
+
actuales = new Map();
|
|
119
|
+
registros.set(mcp, actuales);
|
|
120
|
+
}
|
|
121
|
+
for (const [numero, registro] of [...actuales]) {
|
|
122
|
+
if (!fases.has(numero)) {
|
|
123
|
+
registro.prompt.remove();
|
|
124
|
+
actuales.delete(numero);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const [numero, datos] of fases) {
|
|
128
|
+
const comando = resolveCommandFile(numero, capas);
|
|
129
|
+
const registro = actuales.get(numero);
|
|
130
|
+
if (!registro) {
|
|
131
|
+
actuales.set(numero, { prompt: registrar(mcp, numero, datos.title), titulo: datos.title, comando });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (registro.titulo !== datos.title || registro.comando !== comando) {
|
|
135
|
+
registro.titulo = datos.title;
|
|
136
|
+
registro.comando = comando;
|
|
137
|
+
registro.prompt.update({
|
|
138
|
+
title: tituloDePrompt(numero, datos.title),
|
|
139
|
+
description: descripcionDePrompt(numero, datos.title),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
log("warning", `No se pudieron sincronizar los prompts /fN: ${error instanceof Error ? error.message : String(error)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// La capacidad `prompts` solo se puede declarar antes de conectar. Si al arrancar no hay ninguna
|
|
149
|
+
// fase (una carpeta de prompts vacía o mal configurada), el primer /fN que apareciera después
|
|
150
|
+
// intentaría declararla con la conexión abierta y el SDK lo rechazaría. Registrar y quitar un
|
|
151
|
+
// prompt de paso deja la capacidad declarada sin que la lista tenga nada.
|
|
152
|
+
export function asegurarCapacidadDePrompts(mcp) {
|
|
153
|
+
if ((registros.get(mcp)?.size ?? 0) > 0)
|
|
154
|
+
return;
|
|
155
|
+
// Un nombre que ninguna fase puede tener: las fases se registran como f<número>.
|
|
156
|
+
mcp.registerPrompt("phase-gate-provisional", { description: "provisional" }, async () => ({ messages: [] })).remove();
|
|
157
|
+
}
|
|
158
|
+
// ─── Vigilancia de las capas ────────────────────────────────────────────────
|
|
159
|
+
// Mejora oportunista: con fs.watch, un comando o una fase nueva aparece sin esperar a la siguiente
|
|
160
|
+
// llamada a una tool. En OneDrive y en algunas unidades de red fs.watch no avisa, y por eso lo que
|
|
161
|
+
// garantiza el resultado es la sincronización tras cada tool, no esto. Los vigilantes no mantienen
|
|
162
|
+
// vivo el proceso (unref) y se cierran con el servidor.
|
|
163
|
+
export function vigilarCapas(mcp, esperaMs = 300) {
|
|
164
|
+
const vigilantes = [];
|
|
165
|
+
let temporizador = null;
|
|
166
|
+
const programar = () => {
|
|
167
|
+
if (temporizador)
|
|
168
|
+
clearTimeout(temporizador);
|
|
169
|
+
temporizador = setTimeout(() => {
|
|
170
|
+
temporizador = null;
|
|
171
|
+
sincronizarPrompts(mcp);
|
|
172
|
+
}, esperaMs);
|
|
173
|
+
temporizador.unref();
|
|
174
|
+
};
|
|
175
|
+
for (const capa of promptsLayers()) {
|
|
176
|
+
if (!fs.statSync(capa, { throwIfNoEntry: false })?.isDirectory())
|
|
177
|
+
continue;
|
|
178
|
+
try {
|
|
179
|
+
const vigilante = fs.watch(capa, { recursive: true, persistent: false }, programar);
|
|
180
|
+
vigilante.on("error", () => vigilante.close());
|
|
181
|
+
vigilantes.push(vigilante);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Sin vigilancia en esta capa: la sincronización tras cada tool sigue cubriéndola.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return () => {
|
|
188
|
+
if (temporizador)
|
|
189
|
+
clearTimeout(temporizador);
|
|
190
|
+
for (const vigilante of vigilantes)
|
|
191
|
+
vigilante.close();
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=prompts-mcp.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export declare const URI_ESTADO = "phase-gate://estado";
|
|
3
|
+
export declare const URI_FASES = "phase-gate://fases";
|
|
4
|
+
export declare function uriDeDocumento(proyecto: string, tarea: string, documento: string): string;
|
|
5
|
+
export declare function uriDeRecibo(proyecto: string, tarea: string, recibo: string): string;
|
|
6
|
+
export declare function tablaDeFases(): string;
|
|
7
|
+
export declare function registrarRecursos(mcp: McpServer): void;
|
|
8
|
+
//# sourceMappingURL=recursos.d.ts.map
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Los documentos de las tareas, expuestos como MCP Resources de solo lectura. Un cliente que los
|
|
2
|
+
// muestra (con "@" en Claude Code, Cursor o VS Code) deja a la persona adjuntar al chat el plan
|
|
3
|
+
// técnico o un recibo sin pedírselo al modelo, y al modelo leerlos sin gastar una llamada a
|
|
4
|
+
// read_central_doc.
|
|
5
|
+
//
|
|
6
|
+
// phase-gate://estado el diagnóstico de la instalación
|
|
7
|
+
// phase-gate://fases las fases, con su documento y su capa
|
|
8
|
+
// phase-gate://tareas/{proyecto}/{tarea}/{documento} un archivo de la carpeta de una tarea
|
|
9
|
+
// phase-gate://tareas/{proyecto}/{tarea}/recibos/{recibo} un recibo de lote de la Fase 4
|
|
10
|
+
//
|
|
11
|
+
// Solo lectura, y con las mismas defensas que read_central_doc: cada variable se decodifica y se
|
|
12
|
+
// valida como un segmento portable antes de armar ninguna ruta, la ruta final tiene que quedar
|
|
13
|
+
// dentro de la carpeta de la tarea, y la lectura está acotada a 1 MB de texto.
|
|
14
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { diagnosticar, fasesConCapa, formatearDiagnostico } from "./diagnostico.js";
|
|
19
|
+
import { discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, taskFolderPath, validarSegmento, } from "./paths.js";
|
|
20
|
+
import { discoverPhases, esCarpeta, nombreContextoInicial } from "./phases.js";
|
|
21
|
+
import { listarTareas, tareaActiva } from "./tasks.js";
|
|
22
|
+
export const URI_ESTADO = "phase-gate://estado";
|
|
23
|
+
export const URI_FASES = "phase-gate://fases";
|
|
24
|
+
const MIME = "text/markdown";
|
|
25
|
+
const AUDIENCIA = ["assistant", "user"];
|
|
26
|
+
export function uriDeDocumento(proyecto, tarea, documento) {
|
|
27
|
+
return `phase-gate://tareas/${encodeURIComponent(proyecto)}/${encodeURIComponent(tarea)}/${encodeURIComponent(documento)}`;
|
|
28
|
+
}
|
|
29
|
+
export function uriDeRecibo(proyecto, tarea, recibo) {
|
|
30
|
+
return `phase-gate://tareas/${encodeURIComponent(proyecto)}/${encodeURIComponent(tarea)}/recibos/${encodeURIComponent(recibo)}`;
|
|
31
|
+
}
|
|
32
|
+
// La subcarpeta de recibos la declara una fase en su cabecera (con los prompts de fábrica, la 4).
|
|
33
|
+
function subcarpetaDeRecibos(phases) {
|
|
34
|
+
const conRecibos = [...phases.entries()].sort((a, b) => a[0] - b[0]).find(([, datos]) => datos.recibos);
|
|
35
|
+
return conRecibos?.[1].recibos ?? null;
|
|
36
|
+
}
|
|
37
|
+
// statSync con throwIfNoEntry solo calla el ENOENT; un tramo de la ruta que es un archivo
|
|
38
|
+
// (ENOTDIR) o un permiso denegado no son un error para preguntar "¿es un archivo?".
|
|
39
|
+
function esArchivo(ruta) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(ruta, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Lo que se lista o se autocompleta pasa por la misma contención que la lectura: un enlace dentro
|
|
48
|
+
// de la tarea que lleva fuera de ella no se lee, y tampoco se enseña su nombre ni su fecha.
|
|
49
|
+
function dentroDeLaTarea(carpetaTarea, ruta) {
|
|
50
|
+
try {
|
|
51
|
+
return estaDentroReal(carpetaTarea, ruta);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Los errores de lectura llegan al cliente con un código que dice qué pasó, no como un error
|
|
58
|
+
// interno: -32002 si el recurso no existe (el que propone la especificación de MCP), -32602 si el
|
|
59
|
+
// URI o lo que apunta no es válido, y -32603 solo para un fallo de E/S inesperado.
|
|
60
|
+
const RECURSO_NO_ENCONTRADO = -32002;
|
|
61
|
+
function conCodigo(leerRecurso) {
|
|
62
|
+
try {
|
|
63
|
+
return leerRecurso();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof McpError)
|
|
67
|
+
throw error;
|
|
68
|
+
const mensaje = error instanceof Error ? error.message : String(error);
|
|
69
|
+
if (/^No existe/.test(mensaje))
|
|
70
|
+
throw new McpError(RECURSO_NO_ENCONTRADO, mensaje);
|
|
71
|
+
if (typeof error?.code === "string")
|
|
72
|
+
throw new McpError(ErrorCode.InternalError, mensaje);
|
|
73
|
+
throw new McpError(ErrorCode.InvalidParams, mensaje);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// El SDK entrega las variables tal como venían en el URI, con su codificación porcentual. Se
|
|
77
|
+
// decodifican aquí y se validan DESPUÉS: un %2F decodificado a "/" lo rechaza validarSegmento.
|
|
78
|
+
function variable(variables, nombre) {
|
|
79
|
+
const crudo = variables[nombre];
|
|
80
|
+
const valor = Array.isArray(crudo) ? crudo.join(",") : String(crudo ?? "");
|
|
81
|
+
try {
|
|
82
|
+
return decodeURIComponent(valor);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error(`URI mal codificada: la parte "${nombre}" (${valor}) no es una codificación porcentual válida.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Ruta de un archivo dentro de una tarea, a partir de las variables ya decodificadas. Las carpetas
|
|
89
|
+
// y archivos con punto no son tareas ni documentos, igual que para write_central_doc.
|
|
90
|
+
function archivoDeTarea(proyecto, tarea, partes) {
|
|
91
|
+
validarSegmento(proyecto, "proyecto");
|
|
92
|
+
validarSegmento(tarea, "tarea");
|
|
93
|
+
for (const parte of partes)
|
|
94
|
+
validarSegmento(parte, "documento");
|
|
95
|
+
if (tarea.startsWith(".") || partes.some((parte) => parte.startsWith("."))) {
|
|
96
|
+
throw new Error("Los archivos y carpetas que empiezan por punto no se exponen como recursos.");
|
|
97
|
+
}
|
|
98
|
+
const carpetaProyecto = requireProjectFolder(proyecto);
|
|
99
|
+
const carpetaTarea = taskFolderPath(carpetaProyecto, tarea);
|
|
100
|
+
if (!esCarpeta(carpetaTarea)) {
|
|
101
|
+
throw new Error(`No existe la tarea "${tarea}" en ${carpetaProyecto}.`);
|
|
102
|
+
}
|
|
103
|
+
const ruta = path.join(carpetaTarea, ...partes);
|
|
104
|
+
if (!estaDentroReal(carpetaTarea, ruta)) {
|
|
105
|
+
throw new Error(`Acceso denegado: el recurso queda fuera de la carpeta de la tarea ${carpetaTarea}.`);
|
|
106
|
+
}
|
|
107
|
+
if (!esArchivo(ruta)) {
|
|
108
|
+
throw new Error(`No existe "${partes.join("/")}" en la tarea "${tarea}" de ${carpetaProyecto}.`);
|
|
109
|
+
}
|
|
110
|
+
return { ruta, tarea: carpetaTarea };
|
|
111
|
+
}
|
|
112
|
+
function leer(uri, ruta) {
|
|
113
|
+
return { contents: [{ uri: uri.href, mimeType: MIME, text: leerTextoAcotado(ruta) }] };
|
|
114
|
+
}
|
|
115
|
+
function recurso(uri, nombre, titulo, ruta) {
|
|
116
|
+
let modificado;
|
|
117
|
+
try {
|
|
118
|
+
modificado = fs.statSync(ruta).mtime.toISOString();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
modificado = undefined;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
uri,
|
|
125
|
+
name: nombre,
|
|
126
|
+
title: titulo,
|
|
127
|
+
mimeType: MIME,
|
|
128
|
+
annotations: { audience: AUDIENCIA, ...(modificado ? { lastModified: modificado } : {}) },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// ─── Listado ────────────────────────────────────────────────────────────────
|
|
132
|
+
// Solo los documentos que existen de la tarea ACTIVA de cada proyecto. Listar todas las tareas de
|
|
133
|
+
// todos los proyectos inundaría el selector de "@" del cliente; el resto se alcanza por plantilla,
|
|
134
|
+
// con autocompletado. Si la Documentación no está configurada no hay nada que listar, y el listado
|
|
135
|
+
// no debe fallar por eso: los recursos fijos siguen sirviendo para diagnosticarlo.
|
|
136
|
+
function tareasActivas() {
|
|
137
|
+
try {
|
|
138
|
+
const phases = discoverPhases();
|
|
139
|
+
return [...discoverProjects().values()].sort().flatMap((proyecto) => {
|
|
140
|
+
const activa = tareaActiva(proyecto, phases);
|
|
141
|
+
return activa ? [{ proyecto, tarea: activa.estado.taskName, carpeta: activa.estado.folderPath, phases }] : [];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function listarDocumentos() {
|
|
149
|
+
const lista = [];
|
|
150
|
+
for (const { proyecto, tarea, carpeta, phases } of tareasActivas()) {
|
|
151
|
+
const nombres = [
|
|
152
|
+
nombreContextoInicial(),
|
|
153
|
+
...[...phases.entries()].sort((a, b) => a[0] - b[0]).flatMap(([, datos]) => (datos.documento ? [datos.documento] : [])),
|
|
154
|
+
];
|
|
155
|
+
for (const documento of nombres) {
|
|
156
|
+
const ruta = path.join(carpeta, documento);
|
|
157
|
+
if (!esArchivo(ruta) || !dentroDeLaTarea(carpeta, ruta))
|
|
158
|
+
continue;
|
|
159
|
+
lista.push(recurso(uriDeDocumento(proyecto, tarea, documento), `${proyecto}/${tarea}/${documento}`, `${tarea} · ${documento}`, ruta));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return lista;
|
|
163
|
+
}
|
|
164
|
+
function recibosDe(carpetaTarea, phases) {
|
|
165
|
+
const subcarpeta = subcarpetaDeRecibos(phases);
|
|
166
|
+
if (!subcarpeta)
|
|
167
|
+
return [];
|
|
168
|
+
const carpeta = path.join(carpetaTarea, subcarpeta);
|
|
169
|
+
if (!esCarpeta(carpeta) || !dentroDeLaTarea(carpetaTarea, carpeta))
|
|
170
|
+
return [];
|
|
171
|
+
return fs
|
|
172
|
+
.readdirSync(carpeta, { withFileTypes: true })
|
|
173
|
+
.filter((entrada) => entrada.isFile() && entrada.name.toLowerCase().endsWith(".md") && !entrada.name.startsWith("."))
|
|
174
|
+
.map((entrada) => entrada.name)
|
|
175
|
+
.filter((nombre) => dentroDeLaTarea(carpetaTarea, path.join(carpeta, nombre)))
|
|
176
|
+
.sort();
|
|
177
|
+
}
|
|
178
|
+
function listarRecibos() {
|
|
179
|
+
const lista = [];
|
|
180
|
+
for (const { proyecto, tarea, carpeta, phases } of tareasActivas()) {
|
|
181
|
+
const subcarpeta = subcarpetaDeRecibos(phases);
|
|
182
|
+
for (const recibo of recibosDe(carpeta, phases)) {
|
|
183
|
+
lista.push(recurso(uriDeRecibo(proyecto, tarea, recibo), `${proyecto}/${tarea}/${subcarpeta}/${recibo}`, `${tarea} · ${recibo}`, path.join(carpeta, subcarpeta, recibo)));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return lista;
|
|
187
|
+
}
|
|
188
|
+
// ─── Autocompletado ─────────────────────────────────────────────────────────
|
|
189
|
+
// Lo que ya se escribió filtra la lista, sin distinguir mayúsculas. Un fallo (Documentación sin
|
|
190
|
+
// configurar, proyecto que no existe) devuelve una lista vacía: autocompletar no es el sitio
|
|
191
|
+
// para explicar errores, eso lo hace la lectura.
|
|
192
|
+
function filtrar(valores, escrito) {
|
|
193
|
+
const prefijo = escrito.toLowerCase();
|
|
194
|
+
return valores.filter((valor) => valor.toLowerCase().startsWith(prefijo));
|
|
195
|
+
}
|
|
196
|
+
function sinFallar(calcular) {
|
|
197
|
+
try {
|
|
198
|
+
return calcular();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function completarProyecto(escrito) {
|
|
205
|
+
return sinFallar(() => filtrar([...discoverProjects().values()].sort(), escrito));
|
|
206
|
+
}
|
|
207
|
+
function completarTarea(escrito, contexto) {
|
|
208
|
+
return sinFallar(() => {
|
|
209
|
+
const proyecto = findProjectFolder(contexto?.arguments?.proyecto ?? "");
|
|
210
|
+
if (!proyecto)
|
|
211
|
+
return [];
|
|
212
|
+
return filtrar(listarTareas(proyecto, discoverPhases()).map((tarea) => tarea.taskName), escrito);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function carpetaDeTareaPedida(contexto) {
|
|
216
|
+
const proyecto = findProjectFolder(contexto?.arguments?.proyecto ?? "");
|
|
217
|
+
const tarea = contexto?.arguments?.tarea ?? "";
|
|
218
|
+
if (!proyecto || !tarea || tarea.startsWith("."))
|
|
219
|
+
return null;
|
|
220
|
+
validarSegmento(tarea, "tarea");
|
|
221
|
+
const carpeta = taskFolderPath(proyecto, tarea);
|
|
222
|
+
return esCarpeta(carpeta) ? carpeta : null;
|
|
223
|
+
}
|
|
224
|
+
function completarDocumento(escrito, contexto) {
|
|
225
|
+
return sinFallar(() => {
|
|
226
|
+
const carpeta = carpetaDeTareaPedida(contexto);
|
|
227
|
+
if (!carpeta)
|
|
228
|
+
return [];
|
|
229
|
+
const documentos = fs
|
|
230
|
+
.readdirSync(carpeta, { withFileTypes: true })
|
|
231
|
+
.filter((entrada) => entrada.isFile() && entrada.name.toLowerCase().endsWith(".md") && !entrada.name.startsWith("."))
|
|
232
|
+
.map((entrada) => entrada.name)
|
|
233
|
+
.filter((nombre) => dentroDeLaTarea(carpeta, path.join(carpeta, nombre)))
|
|
234
|
+
.sort();
|
|
235
|
+
return filtrar(documentos, escrito);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function completarRecibo(escrito, contexto) {
|
|
239
|
+
return sinFallar(() => {
|
|
240
|
+
const carpeta = carpetaDeTareaPedida(contexto);
|
|
241
|
+
return carpeta ? filtrar(recibosDe(carpeta, discoverPhases()), escrito) : [];
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// ─── La tabla de fases ──────────────────────────────────────────────────────
|
|
245
|
+
function celda(texto) {
|
|
246
|
+
return texto.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
|
|
247
|
+
}
|
|
248
|
+
export function tablaDeFases() {
|
|
249
|
+
const fases = fasesConCapa();
|
|
250
|
+
if (fases.length === 0)
|
|
251
|
+
return "No se encontró ningún prompt de fase.";
|
|
252
|
+
return [
|
|
253
|
+
"| Fase | Título | Documento | Recibos | Capa | Archivo |",
|
|
254
|
+
"|---|---|---|---|---|---|",
|
|
255
|
+
...fases.map((fase) => `| ${fase.numero} | ${celda(fase.titulo)} | ${fase.documento ? `\`${celda(fase.documento)}\`` : "—"} | ` +
|
|
256
|
+
`${fase.recibos ? `\`${celda(fase.recibos)}/\`` : "—"} | ${fase.capa} | \`${celda(fase.archivo)}\` |`),
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
// ─── Registro ───────────────────────────────────────────────────────────────
|
|
260
|
+
export function registrarRecursos(mcp) {
|
|
261
|
+
mcp.registerResource("estado", URI_ESTADO, {
|
|
262
|
+
title: "Estado de mcp-phase-gate",
|
|
263
|
+
description: "Radiografía de la instalación: variables, prompts, proyectos con su tarea activa y avisos.",
|
|
264
|
+
mimeType: MIME,
|
|
265
|
+
annotations: { audience: AUDIENCIA },
|
|
266
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: MIME, text: formatearDiagnostico(diagnosticar(), "markdown") }] }));
|
|
267
|
+
mcp.registerResource("fases", URI_FASES, {
|
|
268
|
+
title: "Fases disponibles",
|
|
269
|
+
description: "Número, título, documento, subcarpeta de recibos y archivo de prompt que gana en cada fase.",
|
|
270
|
+
mimeType: MIME,
|
|
271
|
+
annotations: { audience: AUDIENCIA },
|
|
272
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: MIME, text: tablaDeFases() }] }));
|
|
273
|
+
// El de recibos va antes que el de documentos: los dos empiezan igual, y el SDK usa la primera
|
|
274
|
+
// plantilla que encaja. Con variables que no cruzan "/" no se pisan, pero el orden lo deja claro.
|
|
275
|
+
mcp.registerResource("recibo", new ResourceTemplate("phase-gate://tareas/{proyecto}/{tarea}/recibos/{recibo}", {
|
|
276
|
+
list: async () => ({ resources: listarRecibos() }),
|
|
277
|
+
complete: { proyecto: completarProyecto, tarea: completarTarea, recibo: completarRecibo },
|
|
278
|
+
}), {
|
|
279
|
+
title: "Recibo de lote",
|
|
280
|
+
description: "Un recibo de lote de la Fase 4, en la subcarpeta que declara su prompt.",
|
|
281
|
+
mimeType: MIME,
|
|
282
|
+
}, async (uri, variables) => conCodigo(() => {
|
|
283
|
+
const subcarpeta = subcarpetaDeRecibos(discoverPhases());
|
|
284
|
+
if (!subcarpeta)
|
|
285
|
+
throw new Error("No existe ninguna fase que declare una subcarpeta de recibos.");
|
|
286
|
+
const { ruta } = archivoDeTarea(variable(variables, "proyecto"), variable(variables, "tarea"), [
|
|
287
|
+
subcarpeta,
|
|
288
|
+
variable(variables, "recibo"),
|
|
289
|
+
]);
|
|
290
|
+
return leer(uri, ruta);
|
|
291
|
+
}));
|
|
292
|
+
mcp.registerResource("documento", new ResourceTemplate("phase-gate://tareas/{proyecto}/{tarea}/{documento}", {
|
|
293
|
+
list: async () => ({ resources: listarDocumentos() }),
|
|
294
|
+
complete: { proyecto: completarProyecto, tarea: completarTarea, documento: completarDocumento },
|
|
295
|
+
}), {
|
|
296
|
+
title: "Documento de una tarea",
|
|
297
|
+
description: "Un archivo de la carpeta de una tarea: el contexto inicial o el documento de una fase.",
|
|
298
|
+
mimeType: MIME,
|
|
299
|
+
}, async (uri, variables) => conCodigo(() => {
|
|
300
|
+
const { ruta } = archivoDeTarea(variable(variables, "proyecto"), variable(variables, "tarea"), [
|
|
301
|
+
variable(variables, "documento"),
|
|
302
|
+
]);
|
|
303
|
+
return leer(uri, ruta);
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=recursos.js.map
|