mcp-phase-gate 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/phases.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import { promptsLayers } from "./config.js";
7
+ import { claveDeNombre, validarSegmento } from "./paths.js";
7
8
  // Las reglas globales se anteponen a cada fase.
8
9
  const GLOBAL_RULES_FILE = "global-rules.md";
9
10
  // Acepta "fase-1-descubrimiento.md" y también "phase-1-discovery.md",
@@ -14,8 +15,72 @@ export const PHASE_FILE_PATTERN = /^(?:fase|phase)-(\d+)[-.]/i;
14
15
  // describe ese documento —no en un archivo de configuración aparte— para que el nombre exista
15
16
  // en un solo lugar y se lea en vivo, sin recompilar, igual que el resto del prompt.
16
17
  const PHASE_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
18
+ // Los prompts propios, escritos por ejemplo con el Bloc de notas, llegan en CRLF, y a veces con
19
+ // BOM al principio. Se normalizan al leer: lo que se entrega al modelo y lo que se compara en los
20
+ // tests va siempre en LF y sin BOM. Con el BOM delante, el frontmatter no se reconocía y la fase
21
+ // se quedaba sin documento.
22
+ function aLF(texto) {
23
+ return texto.replace(/^/, "").replace(/\r\n/g, "\n");
24
+ }
25
+ // Quita los marcadores de ranura y conserva su contenido. El modelo nunca los ve.
26
+ // Una ranura marca el contenido de una sección que un preset podrá sustituir en la Etapa 3:
27
+ // <!-- ranura: verificacion -->
28
+ // contenido por defecto
29
+ // <!-- /ranura -->
30
+ // Solo cuenta un marcador que ocupa su propia línea, y la línea se quita entera. Un marcador con
31
+ // texto detrás, o dos en la misma línea, no se toca: lo avisa loadPhasePrompt.
32
+ export const MARCADOR_RANURA = /^[ \t]*<!--[ \t]*\/?ranura(?:[ \t]*:[ \t]*[\w-]+)?[ \t]*-->[ \t]*\r?$\n?/gm;
33
+ export function quitarMarcadores(texto) {
34
+ return texto.replace(MARCADOR_RANURA, "");
35
+ }
36
+ // Lo que parece un marcador de ranura y sobrevivió a quitarMarcadores: mal escrito (con acento,
37
+ // en mayúsculas, con texto detrás). Llegaría tal cual al modelo, así que queda en el log.
38
+ const RESTO_DE_MARCADOR = /<!--[^\n]*?ranura/i;
39
+ function entregable(texto, archivo) {
40
+ const limpio = quitarMarcadores(texto);
41
+ if (RESTO_DE_MARCADOR.test(limpio)) {
42
+ console.error(`[orquestador] ${archivo} tiene un marcador de ranura que no se reconoce: llega tal cual al modelo.`);
43
+ }
44
+ return limpio.trim();
45
+ }
46
+ // La subcarpeta de recibos acaba dentro de un file_name, así que tiene que ser un solo segmento
47
+ // portable, y visible: write_central_doc no escribe en segmentos que empiezan por punto. Un valor
48
+ // inválido no tumba la fase: se ignora y queda en el log.
49
+ function leerRecibos(valor, archivo) {
50
+ if (!valor)
51
+ return undefined;
52
+ try {
53
+ const segmento = validarSegmento(valor, "recibos");
54
+ if (segmento.startsWith("."))
55
+ throw new Error("no puede empezar por punto.");
56
+ return segmento;
57
+ }
58
+ catch (error) {
59
+ console.error(`[orquestador] Se ignora el frontmatter recibos de ${archivo}: ${error.message}`);
60
+ return undefined;
61
+ }
62
+ }
63
+ // Una subcarpeta de recibos con el nombre de un documento rompe el flujo: si se llama como el
64
+ // documento de la fase, la carpeta hace que la fase cuente como cerrada al primer recibo. Se
65
+ // comprueba al final, con las fases de todas las capas ya resueltas.
66
+ function descartarRecibosQueChocan(phases) {
67
+ const documentos = new Set();
68
+ for (const datos of phases.values()) {
69
+ for (const nombre of [datos.documento, datos.documentoInicial]) {
70
+ if (nombre)
71
+ documentos.add(claveDeNombre(nombre));
72
+ }
73
+ }
74
+ for (const datos of phases.values()) {
75
+ if (datos.recibos && documentos.has(claveDeNombre(datos.recibos))) {
76
+ console.error(`[orquestador] Se ignora el frontmatter recibos de ${datos.file}: ` +
77
+ `"${datos.recibos}" es el nombre de un documento de fase.`);
78
+ datos.recibos = undefined;
79
+ }
80
+ }
81
+ }
17
82
  export function parsePhaseFile(fullPath) {
18
- const crudo = fs.readFileSync(fullPath, "utf-8");
83
+ const crudo = aLF(fs.readFileSync(fullPath, "utf-8"));
19
84
  const encontrada = crudo.match(PHASE_FRONTMATTER);
20
85
  if (!encontrada)
21
86
  return { meta: {}, cuerpo: crudo };
@@ -71,11 +136,13 @@ export function discoverPhases(capas = promptsLayers()) {
71
136
  title: readPhaseTitle(cuerpo, file),
72
137
  documento: meta.documento || undefined,
73
138
  documentoInicial: meta.documento_inicial || undefined,
139
+ recibos: leerRecibos(meta.recibos, fullPath),
74
140
  });
75
141
  }
76
142
  for (const [numero, datos] of deEstaCapa)
77
143
  phases.set(numero, datos);
78
144
  }
145
+ descartarRecibosQueChocan(phases);
79
146
  return phases;
80
147
  }
81
148
  // El título es el primer encabezado "# ..." del cuerpo; si no lo hay, se deriva del nombre
@@ -107,9 +174,9 @@ export function loadPhasePrompt(phase, capas = promptsLayers()) {
107
174
  const parts = [];
108
175
  const globales = resolveGlobalRules(capas);
109
176
  if (globales) {
110
- parts.push(fs.readFileSync(globales, "utf-8").trim());
177
+ parts.push(entregable(aLF(fs.readFileSync(globales, "utf-8")), globales));
111
178
  }
112
- parts.push(parsePhaseFile(entry.file).cuerpo.trim());
179
+ parts.push(entregable(parsePhaseFile(entry.file).cuerpo, entry.file));
113
180
  const archivos = describePhaseFiles(available, phase);
114
181
  if (archivos)
115
182
  parts.push(archivos);
@@ -128,7 +195,8 @@ function resolveGlobalRules(capas) {
128
195
  return null;
129
196
  }
130
197
  // Bloque que se añade al final del prompt con los nombres exactos de los documentos: el que
131
- // produce esta fase y los de las anteriores, que son los que hay que leer. Va aquí y no en el
198
+ // produce esta fase y los de las anteriores, que son los que hay que leer, y la subcarpeta de
199
+ // recibos de la fase que la declare. Va aquí y no en el
132
200
  // comando del cliente para que el nombre viva en un solo sitio; así afinar un prompt no obliga
133
201
  // a editar además un comando en cada dispositivo.
134
202
  export function describePhaseFiles(available, phase) {
@@ -136,7 +204,10 @@ export function describePhaseFiles(available, phase) {
136
204
  const previos = [...available.entries()]
137
205
  .filter(([numero, datos]) => numero < phase && datos.documento)
138
206
  .sort((a, b) => a[0] - b[0])
139
- .map(([numero, datos]) => `- Fase ${numero}: \`${datos.documento}\``);
207
+ .map(([numero, datos]) => {
208
+ const recibos = datos.recibos ? ` (recibos de lote en \`${datos.recibos}/\`)` : "";
209
+ return `- Fase ${numero}: \`${datos.documento}\`${recibos}`;
210
+ });
140
211
  if (!propio && previos.length === 0)
141
212
  return "";
142
213
  const lineas = ["## ARCHIVOS DE ESTA TAREA (nombres exactos)"];
@@ -146,6 +217,12 @@ export function describePhaseFiles(available, phase) {
146
217
  if (propio) {
147
218
  lineas.push("", `El documento de esta fase se llama exactamente \`${propio}\`. Guárdalo con ` +
148
219
  "`write_central_doc` en la carpeta de la tarea activa, con ese nombre y sin cambiarlo.");
220
+ const recibos = available.get(phase)?.recibos;
221
+ if (recibos) {
222
+ lineas.push("", `Los recibos de lote van en la subcarpeta \`${recibos}/\`, uno por lote: ` +
223
+ `\`${recibos}/L01 - <slug>.md\`. Con un solo lote no hay subcarpeta: el recibo es el ` +
224
+ "documento de esta fase.");
225
+ }
149
226
  }
150
227
  else {
151
228
  lineas.push("", "Esta fase no deja documento en la Documentación Central: su salida va en el chat.");
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ export declare const startTaskShape: {
3
+ project: z.ZodString;
4
+ task_name: z.ZodString;
5
+ initial_context: z.ZodString;
6
+ crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
7
+ };
8
+ export declare const getPhasePromptShape: {
9
+ phase: z.ZodNumber;
10
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
11
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
12
+ };
13
+ export declare const getActiveTaskShape: {
14
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
15
+ };
16
+ export declare const listTasksShape: {
17
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
+ };
19
+ export declare const switchTaskShape: {
20
+ project: z.ZodString;
21
+ task_name: z.ZodString;
22
+ };
23
+ export declare const readCentralDocShape: {
24
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
27
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ };
29
+ export declare const writeCentralDocShape: {
30
+ content: z.ZodString;
31
+ force: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
32
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
33
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
34
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
35
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
+ };
37
+ export declare const readCrossRepoShape: {
38
+ repo_name: z.ZodString;
39
+ file_path: z.ZodString;
40
+ };
41
+ declare const SHAPES: {
42
+ start_task: {
43
+ project: z.ZodString;
44
+ task_name: z.ZodString;
45
+ initial_context: z.ZodString;
46
+ crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
47
+ };
48
+ get_phase_prompt: {
49
+ phase: z.ZodNumber;
50
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
51
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
52
+ };
53
+ get_active_task: {
54
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
55
+ };
56
+ list_tasks: {
57
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
58
+ };
59
+ switch_task: {
60
+ project: z.ZodString;
61
+ task_name: z.ZodString;
62
+ };
63
+ read_central_doc: {
64
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
65
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
66
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
67
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
68
+ };
69
+ write_central_doc: {
70
+ content: z.ZodString;
71
+ force: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
72
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
73
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
74
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
75
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
76
+ };
77
+ read_cross_repo: {
78
+ repo_name: z.ZodString;
79
+ file_path: z.ZodString;
80
+ };
81
+ };
82
+ type NombreTool = keyof typeof SHAPES;
83
+ type SinNulos<T> = {
84
+ [K in keyof T]: Exclude<T[K], null>;
85
+ };
86
+ export type LlamadaValidada = {
87
+ [K in NombreTool]: {
88
+ tool: K;
89
+ args: SinNulos<z.output<z.ZodObject<(typeof SHAPES)[K]>>>;
90
+ };
91
+ }[NombreTool];
92
+ export declare function validarLlamada(tool: string, args: unknown): LlamadaValidada;
93
+ export {};
94
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1,108 @@
1
+ // Los argumentos de cada tool, declarados una sola vez con zod.
2
+ //
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.
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).
9
+ //
10
+ // Los opcionales son .nullish() y null cuenta como ausente: algunos clientes con modelos en
11
+ // modo estricto mandan null en todo campo opcional. Un número en texto (phase: "3") no se
12
+ // 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í.
14
+ import { z } from "zod";
15
+ 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(),
20
+ };
21
+ export const getPhasePromptShape = {
22
+ phase: z.number().int(),
23
+ project: z.string().nullish(),
24
+ task_name: z.string().nullish(),
25
+ };
26
+ export const getActiveTaskShape = {
27
+ project: z.string().nullish(),
28
+ };
29
+ export const listTasksShape = {
30
+ project: z.string().nullish(),
31
+ };
32
+ export const switchTaskShape = {
33
+ project: z.string().trim().min(1, "Falta `project`."),
34
+ task_name: z.string().min(1, "Falta `task_name`."),
35
+ };
36
+ // Los cuatro son opcionales por separado: qué combinación vale lo decide resolveDocPath.
37
+ 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(),
42
+ };
43
+ export const writeCentralDocShape = {
44
+ ...readCentralDocShape,
45
+ content: z.string(),
46
+ // Solo con confirmación explícita del usuario en el chat. Permite reescribir un documento
47
+ // congelado o el contexto inicial.
48
+ force: z.boolean().nullish(),
49
+ };
50
+ export const readCrossRepoShape = {
51
+ repo_name: z.string().min(1, "Falta `repo_name`."),
52
+ file_path: z.string().min(1, "Falta `file_path`."),
53
+ };
54
+ const SHAPES = {
55
+ start_task: startTaskShape,
56
+ get_phase_prompt: getPhasePromptShape,
57
+ get_active_task: getActiveTaskShape,
58
+ list_tasks: listTasksShape,
59
+ switch_task: switchTaskShape,
60
+ read_central_doc: readCentralDocShape,
61
+ write_central_doc: writeCentralDocShape,
62
+ read_cross_repo: readCrossRepoShape,
63
+ };
64
+ // Lo que se espera de cada tipo, dicho como se le diría al modelo para que corrija la llamada.
65
+ // El único número es `phase`, de ahí el ejemplo.
66
+ const TIPOS = {
67
+ string: "un texto",
68
+ number: "un número, por ejemplo 3",
69
+ int: "un número entero, por ejemplo 3",
70
+ boolean: "true o false",
71
+ object: "un objeto",
72
+ };
73
+ function describirProblema(issue, recibidos) {
74
+ const campo = issue.path.map(String).join(".");
75
+ if (!campo)
76
+ return "los argumentos tienen que ser un objeto.";
77
+ const nombre = `\`${campo}\``;
78
+ // null también es "falta": en un opcional se tolera, en un obligatorio es lo mismo que no venir.
79
+ if (issue.code === "invalid_type") {
80
+ return recibidos[campo] == null
81
+ ? `Falta ${nombre}.`
82
+ : `${nombre} debe ser ${TIPOS[issue.expected] ?? issue.expected}.`;
83
+ }
84
+ // Los mínimos llevan su mensaje en español en el propio shape ("Falta `task_name`.").
85
+ if (issue.code === "too_small")
86
+ return issue.message;
87
+ // Los máximos no: el único que puede saltar es el del entero seguro de `phase`.
88
+ if (issue.code === "too_big")
89
+ return `${nombre} es demasiado grande.`;
90
+ return `${nombre}: ${issue.message}.`;
91
+ }
92
+ export function validarLlamada(tool, args) {
93
+ if (!Object.hasOwn(SHAPES, tool)) {
94
+ throw new Error(`Unknown tool: ${tool}`);
95
+ }
96
+ const resultado = z.object(SHAPES[tool]).safeParse(args ?? {});
97
+ if (!resultado.success) {
98
+ const recibidos = (args && typeof args === "object" ? args : {});
99
+ const problemas = resultado.error.issues.map((issue) => describirProblema(issue, recibidos));
100
+ throw new Error(`Argumentos no válidos. ${problemas.join(" ")}`);
101
+ }
102
+ // null cuenta como ausente: se quita aquí, una vez, para que ninguna tool tenga que
103
+ // distinguir entre un campo que no vino y uno que vino vacío.
104
+ const sinNulos = Object.fromEntries(Object.entries(resultado.data).filter(([, valor]) => valor !== null));
105
+ // El cast es seguro: los datos los acaba de producir el shape de esta misma tool.
106
+ return { tool, args: sinNulos };
107
+ }
108
+ //# sourceMappingURL=schemas.js.map
package/build/tasks.d.ts CHANGED
@@ -12,6 +12,7 @@ export type EstadoTarea = {
12
12
  ultimoCambio: number | null;
13
13
  };
14
14
  export declare function estadoDeTarea(taskName: string, folderPath: string, phases: Map<number, PhaseFile>): EstadoTarea;
15
+ export declare function motivoDeProteccion(carpetaTarea: string, relativo: string, phases: Map<number, PhaseFile>, contextoInicial: string): string | null;
15
16
  export declare function describeEstado(estado: EstadoTarea, phases: Map<number, PhaseFile>): string;
16
17
  export declare function listarTareas(carpetaProyecto: string, phases: Map<number, PhaseFile>): EstadoTarea[];
17
18
  export type TareaActiva = {
@@ -28,9 +29,12 @@ export type Salto = {
28
29
  }[];
29
30
  };
30
31
  export declare function detectarSalto(fasePedida: number, estado: EstadoTarea, phases: Map<number, PhaseFile>): Salto | null;
31
- export declare function avisoDeCompuerta(fasePedida: number, phases: Map<number, PhaseFile>, opciones?: {
32
+ export type OpcionesDeTarea = {
32
33
  project?: string;
33
34
  taskName?: string;
34
- }): string | null;
35
+ };
36
+ export declare function tareaDelPrompt(phases: Map<number, PhaseFile>, opciones?: OpcionesDeTarea): EstadoTarea | "ambigua" | null;
37
+ export declare function avisoDeCompuerta(fasePedida: number, phases: Map<number, PhaseFile>, opciones?: OpcionesDeTarea): string | null;
38
+ export declare function bloqueDeRecibos(fasePedida: number, estado: EstadoTarea, phases: Map<number, PhaseFile>): string | null;
35
39
  export declare function avisoDeSalto(salto: Salto, taskName: string, phases: Map<number, PhaseFile>): string;
36
40
  //# sourceMappingURL=tasks.d.ts.map
package/build/tasks.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { MODULE_DIR, stateDir, stateFile } from "./config.js";
17
- import { findProjectFolder, requireProjectFolder, tasksRootPath } from "./paths.js";
17
+ import { claveDeNombre, findProjectFolder, requireProjectFolder, tasksRootPath } from "./paths.js";
18
18
  export function leerPunteros() {
19
19
  const archivo = stateFile();
20
20
  if (!fs.existsSync(archivo))
@@ -120,6 +120,63 @@ export function estadoDeTarea(taskName, folderPath, phases) {
120
120
  ultimoCambio,
121
121
  };
122
122
  }
123
+ // ─── Documentos congelados ──────────────────────────────────────────────────
124
+ // Un documento de fase se congela en cuanto una fase posterior está presente: reescribirlo
125
+ // después cambiaría la base sobre la que esa fase ya trabajó, sin que nada lo avise. Se puede
126
+ // reescribir con force, que el modelo solo usa con la confirmación explícita del usuario.
127
+ //
128
+ // Una fase está presente si existe su documento, o si declara recibos y su subcarpeta tiene al
129
+ // menos un .md: con varios lotes, la Fase 4 está en curso mucho antes de escribir su documento.
130
+ function presenciaDeFase(carpetaTarea, datos) {
131
+ if (datos.documento && fs.existsSync(path.join(carpetaTarea, datos.documento))) {
132
+ return `existe \`${datos.documento}\``;
133
+ }
134
+ if (datos.recibos) {
135
+ const subcarpeta = path.join(carpetaTarea, datos.recibos);
136
+ const entradas = fs.existsSync(subcarpeta) ? fs.readdirSync(subcarpeta, { withFileTypes: true }) : [];
137
+ if (entradas.some((entrada) => entrada.isFile() && entrada.name.toLowerCase().endsWith(".md"))) {
138
+ return `existen recibos en \`${datos.recibos}/\``;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ // Los nombres protegidos se comparan por su clave (claveDeNombre) y no como texto exacto. Sin
144
+ // ella un archivo nuevo esquivaba la protección: `00 - contexto inicial.md` o
145
+ // `04 - ejecución/L01 - x.md` en Windows y macOS, donde abren lo mismo que el nombre protegido, y
146
+ // en cualquier sistema un nombre en Unicode NFD, que se ve igual que el documento. En Linux, un
147
+ // nombre que solo difiere en mayúsculas de un documento protegido también pide force; no hace
148
+ // falta nunca.
149
+ const clave = claveDeNombre;
150
+ // Devuelve el motivo del rechazo, o null si se puede escribir sin force. `relativo` es la ruta
151
+ // dentro de la tarea, con "/" como separador (la de ubicarEnTarea).
152
+ export function motivoDeProteccion(carpetaTarea, relativo, phases, contextoInicial) {
153
+ const buscado = clave(relativo);
154
+ // Ni siquiera para crearlo: solo start_task lo escribe.
155
+ if (buscado === clave(contextoInicial)) {
156
+ return "El contexto inicial está siempre protegido: es el único documento que no se puede regenerar leyendo el código.";
157
+ }
158
+ const fases = [...phases.entries()].sort((a, b) => a[0] - b[0]);
159
+ const propia = fases.find(([, datos]) => (datos.documento && clave(datos.documento) === buscado) ||
160
+ (datos.recibos && buscado.startsWith(`${clave(datos.recibos)}/`)));
161
+ if (!propia)
162
+ return null; // no es un archivo de fase
163
+ // Crear un documento nunca pisa nada, ni siquiera cuando rellena un hueco. Se mira el nombre
164
+ // declarado y no el que llegó, que puede venir en otras mayúsculas. Un recibo nuevo sí se
165
+ // congela: con la Fase 5 presente, la ejecución ya se auditó.
166
+ const [numero, datos] = propia;
167
+ const esDocumento = datos.documento !== undefined && clave(datos.documento) === buscado;
168
+ if (esDocumento && !fs.existsSync(path.join(carpetaTarea, datos.documento)))
169
+ return null;
170
+ for (const [posterior, datosPosterior] of fases) {
171
+ if (posterior <= numero)
172
+ continue;
173
+ const presencia = presenciaDeFase(carpetaTarea, datosPosterior);
174
+ if (presencia) {
175
+ return `\`${relativo}\` está congelado: la Fase ${posterior} ya está en curso o cerrada (${presencia}).`;
176
+ }
177
+ }
178
+ return null;
179
+ }
123
180
  // Frase corta para el chat. Se le pasa el mapa de fases para poder nombrar la fase siguiente
124
181
  // con su título real en vez de un número suelto.
125
182
  export function describeEstado(estado, phases) {
@@ -140,13 +197,15 @@ export function describeEstado(estado, phases) {
140
197
  // ─── Listado ────────────────────────────────────────────────────────────────
141
198
  // Las tareas son las carpetas que hay bajo la subcarpeta de tareas del proyecto. Se listan
142
199
  // del disco, así que también aparecen las que alguien creó a mano sin pasar por start_task.
200
+ // Las carpetas con punto no son tareas: con la subcarpeta vacía, .phase-gate/ o .git/ del
201
+ // proyecto cuelgan del mismo sitio que ellas.
143
202
  export function listarTareas(carpetaProyecto, phases) {
144
203
  const raiz = tasksRootPath(carpetaProyecto);
145
204
  if (!fs.existsSync(raiz))
146
205
  return [];
147
206
  return fs
148
207
  .readdirSync(raiz, { withFileTypes: true })
149
- .filter((entrada) => entrada.isDirectory())
208
+ .filter((entrada) => entrada.isDirectory() && !entrada.name.startsWith("."))
150
209
  .map((entrada) => estadoDeTarea(entrada.name, path.join(raiz, entrada.name), phases))
151
210
  .sort((a, b) => (b.ultimoCambio ?? 0) - (a.ultimoCambio ?? 0));
152
211
  }
@@ -181,34 +240,20 @@ export function detectarSalto(fasePedida, estado, phases) {
181
240
  .map(([numero, datos]) => ({ fase: numero, documento: datos.documento }));
182
241
  return { fasePedida, siguienteFase: estado.siguienteFase, faltan };
183
242
  }
184
- // El aviso va al principio del prompt, no en un campo aparte, porque lo tiene que leer el
185
- // modelo como parte de sus instrucciones. Avisa y entrega igual: bloquear estorbaría el día
186
- // que haga falta saltarse una fase a propósito, y hoy no hay dato de cuántas veces pasa.
187
- // Resuelve de qué tarea se está hablando y devuelve el aviso, o null si no hay nada que decir.
188
- //
189
- // Quién hace este trabajo es la decisión de diseño de la compuerta: lo hace el servidor, no el
190
- // modelo. El puntero por proyecto ya está guardado aquí, así que pedírselo al modelo sería
191
- // trabajo de más y una vía de fricción — si no lograra deducirlo acabaría preguntándoselo al
192
- // usuario en cada fase, y escribir "/f4" para que te contesten "¿en qué tarea estamos?" es peor
193
- // que no tener compuerta.
194
- //
195
- // `project` y `taskName` son solo anulación manual, para comprobar contra una tarea distinta de
196
- // la activa. Sin ellos se usa el puntero; y cuando de verdad no se puede saber se dice en voz
197
- // alta, en vez de callar como si todo estuviera en orden.
198
- export function avisoDeCompuerta(fasePedida, phases, opciones = {}) {
243
+ // La tarea de la que habla una llamada a get_phase_prompt: la indicada, o la activa del proyecto.
244
+ // Sin proyecto solo se puede resolver si hay exactamente un candidato con tarea activa; con
245
+ // varios, se devuelve "ambigua" para que quien llama lo diga en vez de callar.
246
+ export function tareaDelPrompt(phases, opciones = {}) {
199
247
  let carpetaProyecto;
200
248
  if (opciones.project) {
201
249
  carpetaProyecto = requireProjectFolder(opciones.project);
202
250
  }
203
251
  else {
204
- // Sin proyecto solo se puede resolver si hay exactamente un candidato con tarea activa.
205
252
  const conPuntero = Object.keys(leerPunteros());
206
253
  if (conPuntero.length === 0)
207
254
  return null; // Nada empezado: no hay nada que comprobar.
208
- if (conPuntero.length > 1) {
209
- return ("> Nota: no se comprobó la compuerta de fase porque no se indicó `project` y hay varios " +
210
- "proyectos con tarea activa. Pasa `project` para que se compruebe.");
211
- }
255
+ if (conPuntero.length > 1)
256
+ return "ambigua";
212
257
  const encontrada = findProjectFolder(conPuntero[0]);
213
258
  if (!encontrada)
214
259
  return null; // Puntero de un proyecto que ya no existe: se ignora.
@@ -217,11 +262,48 @@ export function avisoDeCompuerta(fasePedida, phases, opciones = {}) {
217
262
  const estado = opciones.taskName
218
263
  ? listarTareas(carpetaProyecto, phases).find((tarea) => tarea.taskName === opciones.taskName)
219
264
  : tareaActiva(carpetaProyecto, phases)?.estado;
265
+ return estado ?? null; // null: proyecto sin tareas, o un nombre de tarea que no existe.
266
+ }
267
+ export function avisoDeCompuerta(fasePedida, phases, opciones = {}) {
268
+ const estado = tareaDelPrompt(phases, opciones);
269
+ if (estado === "ambigua") {
270
+ return ("> Nota: no se comprobó la compuerta de fase porque no se indicó `project` y hay varios " +
271
+ "proyectos con tarea activa. Pasa `project` para que se compruebe.");
272
+ }
220
273
  if (!estado)
221
- return null; // Proyecto sin tareas, o un nombre de tarea que no existe.
274
+ return null;
222
275
  const salto = detectarSalto(fasePedida, estado, phases);
223
276
  return salto ? avisoDeSalto(salto, estado.taskName, phases) : null;
224
277
  }
278
+ // ─── Recibos que ya existen ─────────────────────────────────────────────────
279
+ // Ninguna tool lista una carpeta, así que al retomar la Fase 4 en otro chat, o al auditar en la
280
+ // Fase 5 sin índice, el modelo no tenía cómo saber qué recibos hay ni cómo se llaman sus slugs.
281
+ // Se los da el servidor al final del prompt de la fase que los declara y de las posteriores.
282
+ export function bloqueDeRecibos(fasePedida, estado, phases) {
283
+ const lineas = [];
284
+ for (const [numero, datos] of [...phases.entries()].sort((a, b) => a[0] - b[0])) {
285
+ if (!datos.recibos || numero > fasePedida)
286
+ continue;
287
+ const subcarpeta = path.join(estado.folderPath, datos.recibos);
288
+ if (!fs.existsSync(subcarpeta))
289
+ continue;
290
+ const recibos = fs
291
+ .readdirSync(subcarpeta, { withFileTypes: true })
292
+ .filter((entrada) => entrada.isFile() && entrada.name.toLowerCase().endsWith(".md"))
293
+ .map((entrada) => entrada.name)
294
+ .sort();
295
+ for (const recibo of recibos)
296
+ lineas.push(`- Fase ${numero}: \`${datos.recibos}/${recibo}\``);
297
+ }
298
+ if (lineas.length === 0)
299
+ return null;
300
+ return [
301
+ `## RECIBOS QUE YA EXISTEN EN LA TAREA "${estado.taskName}"`,
302
+ "",
303
+ "Léelos con `read_central_doc` usando estas rutas tal cual:",
304
+ ...lineas,
305
+ ].join("\n");
306
+ }
225
307
  export function avisoDeSalto(salto, taskName, phases) {
226
308
  const titulo = phases.get(salto.siguienteFase)?.title;
227
309
  const vaEn = titulo ? `Fase ${salto.siguienteFase} (${titulo})` : `Fase ${salto.siguienteFase}`;
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
- {
2
- "name": "mcp-phase-gate",
3
- "version": "2.0.0",
4
- "description": "Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada una deje su documento en una documentación central.",
5
- "main": "build/index.js",
6
- "bin": {
7
- "mcp-phase-gate": "build/index.js"
8
- },
9
- "files": [
10
- "build",
11
- "prompts",
12
- "!build/**/*.map"
13
- ],
14
- "engines": {
15
- "node": ">=22"
16
- },
17
- "scripts": {
18
- "build": "tsc",
19
- "start": "node build/index.js",
20
- "dev": "ts-node src/index.ts",
21
- "test": "tsc -p tsconfig.test.json && node --test \"build-test/test/**/*.test.js\"",
22
- "prepublishOnly": "npm run build && npm test"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/Montse2308/mcp-phase-gate.git"
27
- },
28
- "keywords": [
29
- "mcp",
30
- "model-context-protocol",
31
- "phase-gate",
32
- "workflow",
33
- "documentation",
34
- "cursor",
35
- "claude-code"
36
- ],
37
- "author": "Montserrat Ximena Hernández Gallegos",
38
- "license": "MIT",
39
- "type": "module",
40
- "bugs": {
41
- "url": "https://github.com/Montse2308/mcp-phase-gate/issues"
42
- },
43
- "homepage": "https://github.com/Montse2308/mcp-phase-gate#readme",
44
- "dependencies": {
45
- "@modelcontextprotocol/sdk": "^1.29.0",
46
- "dotenv": "^17.4.2"
47
- },
48
- "devDependencies": {
49
- "@types/node": "^25.9.1",
50
- "ts-node": "^10.9.2",
51
- "typescript": "^6.0.3"
52
- }
53
- }
1
+ {
2
+ "name": "mcp-phase-gate",
3
+ "version": "2.1.0",
4
+ "description": "Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada una deje su documento en una documentación central.",
5
+ "main": "build/index.js",
6
+ "bin": {
7
+ "mcp-phase-gate": "build/index.js"
8
+ },
9
+ "files": [
10
+ "build",
11
+ "prompts",
12
+ "!build/**/*.map"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "start": "node build/index.js",
20
+ "dev": "tsc --watch",
21
+ "test": "tsc -p tsconfig.test.json && node --test \"build-test/test/**/*.test.js\"",
22
+ "prepublishOnly": "npm run build && npm test"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Montse2308/mcp-phase-gate.git"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "phase-gate",
32
+ "workflow",
33
+ "documentation",
34
+ "cursor",
35
+ "claude-code"
36
+ ],
37
+ "author": "Montserrat Ximena Hernández Gallegos",
38
+ "license": "MIT",
39
+ "type": "module",
40
+ "bugs": {
41
+ "url": "https://github.com/Montse2308/mcp-phase-gate/issues"
42
+ },
43
+ "homepage": "https://github.com/Montse2308/mcp-phase-gate#readme",
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.32.1",
46
+ "dotenv": "^17.4.2",
47
+ "zod": "^4.4.3"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^25.9.1",
51
+ "typescript": "^6.0.3"
52
+ }
53
+ }