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,394 @@
1
+ // Radiografía de la instalación: qué versión corre y desde dónde, de dónde salió cada variable,
2
+ // qué archivo de prompt gana en cada fase, qué proyectos hay y en qué va su tarea activa, y los
3
+ // avisos que se deducen de todo eso.
4
+ //
5
+ // Es una sola fuente para tres salidas: `npx mcp-phase-gate doctor`, la tool
6
+ // `get_orchestrator_status` y el recurso `phase-gate://estado`. Si cada una armara su propio
7
+ // diagnóstico, dirían cosas distintas justo el día en que alguien las compare para entender por
8
+ // qué algo no funciona.
9
+ //
10
+ // Solo lee. No escribe nada ni lanza por configuración incompleta: diagnosticar una instalación
11
+ // rota es precisamente para lo que existe, así que cada parte captura su propio error y lo
12
+ // convierte en un hallazgo.
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import { DOCS, MODULE_DIR, NOMBRES_LOGICOS, REPOS, SERVER_NAME, SERVER_VERSION, TASKS_SUBDIR_POR_DEFECTO, errorDeEnv, leerVariable, promptsLayers, requireBasePath, stateFile, } from "./config.js";
16
+ import { discoverProjects, estaDentro, findProjectFolder } from "./paths.js";
17
+ import { discoverPhases, esCarpeta, leerPromptNormalizado, resolveCommandFile, resolveGlobalRules } from "./phases.js";
18
+ import { describeEstado, leerPunteros, listarTareas, tareaActiva } from "./tasks.js";
19
+ // Las secciones de las reglas globales que las fases citan por su nombre. Un global-rules.md
20
+ // propio sustituye al de fábrica (no se suma), así que si le falta una, las fases citan una regla
21
+ // que no existe. Es el riesgo E1-R1, y el aviso que pedía la Etapa 1 para cuando hubiera doctor.
22
+ export const SECCIONES_GLOBALES = ["EVIDENCIA", "BASE DE CADA DECISIÓN", "PENDIENTES HEREDADOS"];
23
+ const ORDEN_SEVERIDAD = { error: 0, aviso: 1, info: 2 };
24
+ // ─── Cómo se ejecuta el paquete ─────────────────────────────────────────────
25
+ // Solo como información, nunca como decisión: la deducción puede equivocarse (riesgo E2-R7) y
26
+ // nada depende de ella. Sirve para que quien lea el diagnóstico sepa qué copia está corriendo,
27
+ // que es la primera pregunta cuando "cambié el prompt y no se nota".
28
+ export function deducirVia(ruta, entorno = process.env) {
29
+ if (/[\\/]_npx[\\/]/.test(ruta))
30
+ return "npx";
31
+ const prefijo = entorno.npm_config_prefix?.trim();
32
+ if (prefijo) {
33
+ const raizGlobal = process.platform === "win32" ? path.join(prefijo, "node_modules") : path.join(prefijo, "lib", "node_modules");
34
+ if (estaDentro(raizGlobal, ruta))
35
+ return "global";
36
+ }
37
+ if (fs.existsSync(path.join(ruta, ".git")))
38
+ return "clon";
39
+ return "dependencia";
40
+ }
41
+ function mensajeDe(error) {
42
+ return error instanceof Error ? error.message : String(error);
43
+ }
44
+ function estadoLegible(archivo) {
45
+ try {
46
+ const crudo = JSON.parse(fs.readFileSync(archivo, "utf-8"));
47
+ return crudo !== null && typeof crudo === "object" && !Array.isArray(crudo);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function estadoRuta(requerida) {
54
+ try {
55
+ return { ruta: requireBasePath(requerida), existe: true };
56
+ }
57
+ catch (error) {
58
+ const { valor } = leerVariable(requerida.logica);
59
+ return { ...(valor ? { ruta: path.resolve(valor) } : {}), existe: false, error: mensajeDe(error) };
60
+ }
61
+ }
62
+ // ─── Capas y fases ──────────────────────────────────────────────────────────
63
+ // También las usa el recurso phase-gate://fases, que enseña lo mismo sin el resto del diagnóstico.
64
+ export function capasDePrompts() {
65
+ return promptsLayers().map((ruta, i) => ({
66
+ ruta,
67
+ etiqueta: (i === 0 ? "fábrica" : "propia"),
68
+ existe: esCarpeta(ruta),
69
+ }));
70
+ }
71
+ // La capa de un archivo es la más alta que lo contiene.
72
+ export function capaDeArchivo(archivo, capas) {
73
+ let encontrada = null;
74
+ for (const capa of capas)
75
+ if (estaDentro(capa.ruta, archivo))
76
+ encontrada = capa.etiqueta;
77
+ return encontrada;
78
+ }
79
+ export function fasesConCapa(capas = capasDePrompts(), phases = discoverPhases(capas.map((capa) => capa.ruta))) {
80
+ const rutasCapas = capas.map((capa) => capa.ruta);
81
+ return [...phases.entries()]
82
+ .sort((a, b) => a[0] - b[0])
83
+ .map(([numero, datos]) => ({
84
+ numero,
85
+ titulo: datos.title,
86
+ archivo: datos.file,
87
+ capa: capaDeArchivo(datos.file, capas) ?? "desconocida",
88
+ ...(datos.documento ? { documento: datos.documento } : {}),
89
+ ...(datos.recibos ? { recibos: datos.recibos } : {}),
90
+ comando: resolveCommandFile(numero, rutasCapas),
91
+ }));
92
+ }
93
+ // ─── El diagnóstico ─────────────────────────────────────────────────────────
94
+ export function diagnosticar(opciones = {}) {
95
+ const hallazgos = [];
96
+ const hallar = (severidad, codigo, texto) => {
97
+ hallazgos.push({ severidad, codigo, texto });
98
+ };
99
+ const rutaPaquete = path.resolve(MODULE_DIR, "..");
100
+ // Variables y rutas obligatorias.
101
+ const variables = NOMBRES_LOGICOS.map((logica) => leerVariable(logica));
102
+ for (const { discrepancia } of variables) {
103
+ if (discrepancia)
104
+ hallar("aviso", "VAR-DISCREPA", discrepancia);
105
+ }
106
+ const delEnv = errorDeEnv();
107
+ if (delEnv)
108
+ hallar("error", /UTF-16/.test(delEnv) ? "ENV-UTF16" : "ENV-ILEGIBLE", delEnv);
109
+ const rutas = { docs: estadoRuta(DOCS), repos: estadoRuta(REPOS) };
110
+ for (const estado of [rutas.docs, rutas.repos]) {
111
+ if (!estado.existe)
112
+ hallar("error", "VAR-FALTA", estado.error ?? "Falta una variable obligatoria.");
113
+ }
114
+ // Estado local.
115
+ const archivoEstado = stateFile();
116
+ let punteros = {};
117
+ try {
118
+ punteros = leerPunteros();
119
+ }
120
+ catch {
121
+ // leerPunteros ya tolera un archivo ilegible; esto solo cubre una carpeta inaccesible.
122
+ }
123
+ const estado = { archivo: archivoEstado, existe: fs.existsSync(archivoEstado), punteros: Object.keys(punteros).length };
124
+ // leerPunteros calla un archivo corrupto (para no dejar sin servicio), y doctor lo contaba como
125
+ // "0 punteros". Aquí se dice: la tarea activa de cada proyecto se está deduciendo.
126
+ if (estado.existe && !estadoLegible(archivoEstado)) {
127
+ hallar("aviso", "ESTADO-ILEGIBLE", `${archivoEstado} no es un JSON con la forma esperada: se ignora y la tarea activa de cada proyecto se deduce. ` +
128
+ `Un switch_task lo vuelve a escribir.`);
129
+ }
130
+ // Capas de prompts.
131
+ const capas = capasDePrompts();
132
+ const rutasCapas = capas.map((capa) => capa.ruta);
133
+ for (const capa of capas) {
134
+ if (capa.etiqueta === "propia" && !capa.existe) {
135
+ const { usada } = leerVariable("PROMPTS_PATH");
136
+ if (fs.existsSync(capa.ruta)) {
137
+ hallar("aviso", "CAPA-NO-ES-CARPETA", `${usada} apunta a "${capa.ruta}", que no es una carpeta: se usan solo los prompts de fábrica.`);
138
+ }
139
+ else {
140
+ hallar("aviso", "CAPA-NO-EXISTE", `${usada} apunta a "${capa.ruta}", que no existe: se usan solo los prompts de fábrica.`);
141
+ }
142
+ }
143
+ }
144
+ const capaDe = (archivo) => capaDeArchivo(archivo, capas);
145
+ // Reglas globales.
146
+ const archivoGlobales = resolveGlobalRules(rutasCapas);
147
+ const capaGlobales = archivoGlobales ? capaDe(archivoGlobales) : null;
148
+ let faltan = [];
149
+ if (archivoGlobales) {
150
+ try {
151
+ const lineas = new Set(leerPromptNormalizado(archivoGlobales).split("\n").map((linea) => linea.trimEnd().normalize("NFC")));
152
+ faltan = SECCIONES_GLOBALES.filter((seccion) => !lineas.has(`## ${seccion}`));
153
+ }
154
+ catch {
155
+ faltan = [...SECCIONES_GLOBALES];
156
+ }
157
+ }
158
+ if (archivoGlobales && capaGlobales !== "fábrica" && faltan.length) {
159
+ hallar("aviso", "GLOBAL-SIN-BASE", `Tu global-rules.md (${archivoGlobales}) sustituye al de fábrica y no tiene ` +
160
+ `${faltan.map((seccion) => `"## ${seccion}"`).join(", ")}. Las fases citan esas secciones: ` +
161
+ `cópialas desde el de fábrica.`);
162
+ }
163
+ // Fases.
164
+ // discoverPhases ya salta las capas que no son carpeta; esto cubre lo que quede (una carpeta
165
+ // sin permiso de lectura, por ejemplo). El diagnóstico no lanza nunca: lo convierte en hallazgo.
166
+ let phases = new Map();
167
+ try {
168
+ phases = discoverPhases(rutasCapas);
169
+ }
170
+ catch (error) {
171
+ hallar("error", "PROMPTS-ILEGIBLES", `No se pudieron leer las carpetas de prompts: ${mensajeDe(error)}`);
172
+ }
173
+ const fases = fasesConCapa(capas, phases);
174
+ for (const fase of fases) {
175
+ if (!fase.documento) {
176
+ hallar("aviso", "FASE-SIN-DOC", `La Fase ${fase.numero} (${fase.titulo}) no declara \`documento\` en su cabecera: no se puede deducir su avance.`);
177
+ }
178
+ if (!fase.comando) {
179
+ hallar("info", "FASE-SIN-COMANDO", `La Fase ${fase.numero} (${fase.titulo}) no tiene \`comandos/f${fase.numero}.md\` en ninguna capa de ` +
180
+ `prompts: su comando /f${fase.numero} usa el texto genérico.`);
181
+ }
182
+ }
183
+ // Proyectos y punteros. Sin DOCS_PATH no hay nada que listar, y eso ya es un VAR-FALTA.
184
+ const proyectos = [];
185
+ if (rutas.docs.existe) {
186
+ try {
187
+ for (const nombre of [...discoverProjects().values()].sort()) {
188
+ const tareas = listarTareas(nombre, phases);
189
+ const activa = tareaActiva(nombre, phases);
190
+ proyectos.push({
191
+ nombre,
192
+ tareas: tareas.length,
193
+ activa: activa
194
+ ? { tarea: activa.estado.taskName, origen: activa.origen, fase: describeEstado(activa.estado, phases) }
195
+ : null,
196
+ });
197
+ }
198
+ for (const [clave, tarea] of Object.entries(punteros)) {
199
+ const carpeta = findProjectFolder(clave);
200
+ if (!carpeta) {
201
+ hallar("info", "PUNTERO-HUERFANO", `Hay un puntero guardado para el proyecto "${clave}", que ya no existe.`);
202
+ }
203
+ else if (!listarTareas(carpeta, phases).some((t) => t.taskName === tarea)) {
204
+ hallar("info", "PUNTERO-HUERFANO", `El puntero de ${carpeta} apunta a la tarea "${tarea}", que ya no existe: se toma la más reciente.`);
205
+ }
206
+ }
207
+ }
208
+ catch (error) {
209
+ hallar("error", "DOCS-ILEGIBLE", `No se pudo leer la Documentación Central: ${mensajeDe(error)}`);
210
+ }
211
+ }
212
+ hallazgos.sort((a, b) => ORDEN_SEVERIDAD[a.severidad] - ORDEN_SEVERIDAD[b.severidad]);
213
+ return {
214
+ paquete: { version: SERVER_VERSION, ruta: rutaPaquete, via: deducirVia(rutaPaquete) },
215
+ node: { version: process.version, plataforma: process.platform },
216
+ variables,
217
+ rutas,
218
+ estado,
219
+ capas,
220
+ reglasGlobales: { archivo: archivoGlobales, capa: capaGlobales, faltan },
221
+ fases,
222
+ proyectos,
223
+ ...(opciones.cliente ? { cliente: opciones.cliente } : {}),
224
+ hallazgos,
225
+ };
226
+ }
227
+ function filasDeVariables(d) {
228
+ return d.variables.map((lectura) => {
229
+ const origen = lectura.usada ? `${lectura.usada} · ${lectura.fuente}` : "";
230
+ const nombre = lectura.logica;
231
+ if (nombre === "DOCS_PATH" || nombre === "REPOS_PATH") {
232
+ const ruta = nombre === "DOCS_PATH" ? d.rutas.docs : d.rutas.repos;
233
+ return { nombre, marca: ruta.existe ? "✓" : "✗", valor: ruta.ruta ?? "sin definir", origen };
234
+ }
235
+ if (lectura.valor === undefined || (lectura.valor === "" && nombre !== "TASKS_SUBDIR")) {
236
+ if (nombre === "TASKS_SUBDIR")
237
+ return { nombre, marca: "·", valor: TASKS_SUBDIR_POR_DEFECTO, origen: "(por defecto)" };
238
+ if (nombre === "STATE_PATH")
239
+ return { nombre, marca: "·", valor: `sin definir → ${path.dirname(d.estado.archivo)}`, origen };
240
+ return { nombre, marca: "·", valor: "sin definir", origen };
241
+ }
242
+ if (nombre === "PROMPTS_PATH") {
243
+ const propia = d.capas.find((capa) => capa.etiqueta === "propia");
244
+ const existe = propia?.existe ?? false;
245
+ return { nombre, marca: existe ? "✓" : "✗", valor: propia?.ruta ?? lectura.valor, origen };
246
+ }
247
+ if (nombre === "TASKS_SUBDIR" && lectura.valor === "") {
248
+ return { nombre, marca: "·", valor: "(vacía: las tareas cuelgan del proyecto)", origen };
249
+ }
250
+ const valor = nombre === "STATE_PATH" ? path.dirname(d.estado.archivo) : lectura.valor;
251
+ return { nombre, marca: "·", valor, origen };
252
+ });
253
+ }
254
+ function lineaDeCliente(cliente) {
255
+ const si = (valor) => (valor ? "sí" : "no");
256
+ return (`${cliente.nombre} ${cliente.version} · elicitation: ${si(cliente.capacidades.elicitation)} · ` +
257
+ `roots: ${si(cliente.capacidades.roots)}`);
258
+ }
259
+ function archivosDeFase(fase) {
260
+ if (!fase.documento)
261
+ return "sin documento";
262
+ return fase.recibos ? `${fase.documento} + ${fase.recibos}/` : fase.documento;
263
+ }
264
+ function lineaDeProyecto(proyecto) {
265
+ const tareas = `${proyecto.tareas} ${proyecto.tareas === 1 ? "tarea" : "tareas"}`;
266
+ if (!proyecto.activa)
267
+ return tareas;
268
+ return `${tareas} · activa: ${proyecto.activa.tarea} (${proyecto.activa.origen}) · ${proyecto.activa.fase}`;
269
+ }
270
+ function formatearTexto(d) {
271
+ const lineas = [];
272
+ const columna = (texto, ancho) => texto.padEnd(ancho);
273
+ lineas.push(`${SERVER_NAME} ${d.paquete.version} · vía ${d.paquete.via}`);
274
+ lineas.push(`${columna("Paquete", 13)}${d.paquete.ruta}`);
275
+ lineas.push(`${columna("Node", 13)}${d.node.version} · ${d.node.plataforma}`);
276
+ if (d.cliente)
277
+ lineas.push(`${columna("Cliente", 13)}${lineaDeCliente(d.cliente)}`);
278
+ lineas.push("", "Variables");
279
+ const filas = filasDeVariables(d);
280
+ const anchoNombre = Math.max(...filas.map((fila) => fila.nombre.length)) + 2;
281
+ const anchoValor = Math.max(...filas.map((fila) => fila.valor.length)) + 4;
282
+ for (const fila of filas) {
283
+ const resto = fila.origen ? `${columna(fila.valor, anchoValor)}${fila.origen}` : fila.valor;
284
+ lineas.push(` ${columna(fila.nombre, anchoNombre)}${fila.marca} ${resto}`.trimEnd());
285
+ }
286
+ const punteros = `${d.estado.punteros} ${d.estado.punteros === 1 ? "puntero" : "punteros"}`;
287
+ lineas.push("", `${columna("Estado", 15)}${d.estado.archivo} · ${d.estado.existe ? punteros : "todavía no existe"}`);
288
+ lineas.push("", "Prompts (de menor a mayor prioridad)");
289
+ for (const capa of d.capas) {
290
+ lineas.push(` ${columna(capa.etiqueta, 13)}${capa.ruta}${capa.existe ? "" : " (no existe)"}`);
291
+ }
292
+ if (d.reglasGlobales.archivo) {
293
+ const faltan = d.reglasGlobales.faltan.length ? ` (le faltan: ${d.reglasGlobales.faltan.join(", ")})` : "";
294
+ lineas.push(` reglas globales ← ${d.reglasGlobales.capa ?? "desconocida"}${faltan}`);
295
+ }
296
+ else {
297
+ lineas.push(" reglas globales (ninguna)");
298
+ }
299
+ const anchoFase = Math.max(0, ...d.fases.map((fase) => `Fase ${fase.numero} ${fase.titulo}`.length)) + 3;
300
+ for (const fase of d.fases) {
301
+ const comando = fase.comando ? ` · /f${fase.numero}` : ` · /f${fase.numero} (texto genérico)`;
302
+ lineas.push(` ${columna(`Fase ${fase.numero} ${fase.titulo}`, anchoFase)}← ${columna(fase.capa, 9)}· ${archivosDeFase(fase)}${comando}`);
303
+ }
304
+ lineas.push("", "Proyectos");
305
+ if (!d.rutas.docs.existe) {
306
+ lineas.push(" (no se pueden listar sin DOCS_PATH)");
307
+ }
308
+ else if (d.proyectos.length === 0) {
309
+ lineas.push(" (ninguno todavía)");
310
+ }
311
+ else {
312
+ const anchoProyecto = Math.max(...d.proyectos.map((p) => p.nombre.length)) + 3;
313
+ for (const proyecto of d.proyectos)
314
+ lineas.push(` ${columna(proyecto.nombre, anchoProyecto)}${lineaDeProyecto(proyecto)}`);
315
+ }
316
+ lineas.push("", "Hallazgos");
317
+ if (d.hallazgos.length === 0) {
318
+ lineas.push(" ninguno");
319
+ }
320
+ else {
321
+ const anchoCodigo = Math.max(...d.hallazgos.map((h) => h.codigo.length)) + 2;
322
+ for (const h of d.hallazgos)
323
+ lineas.push(` ${columna(h.severidad, 7)}${columna(h.codigo, anchoCodigo)}${h.texto}`);
324
+ }
325
+ return lineas.join("\n");
326
+ }
327
+ // Una celda de tabla Markdown: sin saltos de línea y con las barras escapadas.
328
+ function celda(texto) {
329
+ return texto.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
330
+ }
331
+ function codigo(texto) {
332
+ return texto ? `\`${celda(texto)}\`` : "";
333
+ }
334
+ function tabla(encabezados, filas) {
335
+ return [
336
+ `| ${encabezados.join(" | ")} |`,
337
+ `|${encabezados.map(() => "---").join("|")}|`,
338
+ ...filas.map((fila) => `| ${fila.join(" | ")} |`),
339
+ ];
340
+ }
341
+ function formatearMarkdown(d) {
342
+ const lineas = [];
343
+ lineas.push(`### ${SERVER_NAME} ${d.paquete.version} · vía ${d.paquete.via}`, "");
344
+ lineas.push(`- **Paquete:** ${codigo(d.paquete.ruta)}`);
345
+ lineas.push(`- **Node:** ${d.node.version} · ${d.node.plataforma}`);
346
+ if (d.cliente)
347
+ lineas.push(`- **Cliente:** ${celda(lineaDeCliente(d.cliente))}`);
348
+ lineas.push("", "### Variables", "");
349
+ lineas.push(...tabla(["Variable", "", "Valor", "Nombre usado · fuente"], filasDeVariables(d).map((fila) => [fila.nombre, fila.marca, codigo(fila.valor), celda(fila.origen)])));
350
+ const punteros = `${d.estado.punteros} ${d.estado.punteros === 1 ? "puntero" : "punteros"}`;
351
+ lineas.push("", "### Estado", "", `${codigo(d.estado.archivo)} · ${d.estado.existe ? punteros : "todavía no existe"}`);
352
+ lineas.push("", "### Prompts", "", "Capas, de menor a mayor prioridad:", "");
353
+ lineas.push(...tabla(["Capa", "Ruta", "Existe"], d.capas.map((capa) => [capa.etiqueta, codigo(capa.ruta), capa.existe ? "sí" : "no"])));
354
+ const faltan = d.reglasGlobales.faltan.length ? ` · le faltan: ${d.reglasGlobales.faltan.join(", ")}` : "";
355
+ lineas.push("", d.reglasGlobales.archivo
356
+ ? `Reglas globales: ${codigo(d.reglasGlobales.archivo)} (${d.reglasGlobales.capa ?? "desconocida"})${faltan}`
357
+ : "Reglas globales: ninguna", "");
358
+ lineas.push(...tabla(["Fase", "Título", "Capa", "Documento", "Recibos", "Comando", "Archivo"], d.fases.map((fase) => [
359
+ String(fase.numero),
360
+ celda(fase.titulo),
361
+ fase.capa,
362
+ codigo(fase.documento ?? ""),
363
+ codigo(fase.recibos ? `${fase.recibos}/` : ""),
364
+ fase.comando ? `/f${fase.numero}` : `/f${fase.numero} (texto genérico)`,
365
+ codigo(fase.archivo),
366
+ ])));
367
+ lineas.push("", "### Proyectos", "");
368
+ if (!d.rutas.docs.existe) {
369
+ lineas.push("No se pueden listar sin `DOCS_PATH`.");
370
+ }
371
+ else if (d.proyectos.length === 0) {
372
+ lineas.push("Ninguno todavía.");
373
+ }
374
+ else {
375
+ lineas.push(...tabla(["Proyecto", "Tareas", "Tarea activa", "Fase"], d.proyectos.map((p) => [
376
+ celda(p.nombre),
377
+ String(p.tareas),
378
+ p.activa ? `${celda(p.activa.tarea)} (${p.activa.origen})` : "",
379
+ p.activa ? celda(p.activa.fase) : "",
380
+ ])));
381
+ }
382
+ lineas.push("", "### Hallazgos", "");
383
+ if (d.hallazgos.length === 0) {
384
+ lineas.push("Ninguno.");
385
+ }
386
+ else {
387
+ lineas.push(...tabla(["Severidad", "Código", "Detalle"], d.hallazgos.map((h) => [h.severidad, h.codigo, celda(h.texto)])));
388
+ }
389
+ return lineas.join("\n");
390
+ }
391
+ export function formatearDiagnostico(d, formato) {
392
+ return formato === "texto" ? formatearTexto(d) : formatearMarkdown(d);
393
+ }
394
+ //# sourceMappingURL=diagnostico.js.map