mcp-phase-gate 2.0.1 → 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/README.es.md CHANGED
@@ -32,7 +32,7 @@ flowchart TD
32
32
 
33
33
  Y entre una fase y otra hay una **compuerta**, que es lo que le da el nombre al servidor: si pides una fase saltándote la anterior, el servidor lo detecta y avisa ([ver abajo](#-la-compuerta-saltarse-una-fase-deja-de-ser-invisible)).
34
34
 
35
- En la fase 5 la IA solo lee. **Los commits y el PR los haces tú**, siempre.
35
+ En la Fase 4 la IA hace el commit de cada lote solo con tu OK; nunca hace push ni abre el PR. En la Fase 5 solo lee.
36
36
 
37
37
  ## Para quién es
38
38
 
@@ -57,7 +57,10 @@ DOCUMENTACIÓN/
57
57
  │ ├── 01 - Análisis Técnico.md ← Fase 1
58
58
  │ ├── 02 - Decisiones.md ← Fase 2
59
59
  │ ├── 03 - Plan Técnico.md ← Fase 3
60
- │ ├── 04 - Ejecución.md ← Fase 4
60
+ │ ├── 04 - Ejecución/ ← Fase 4, con más de un lote: un recibo por lote
61
+ │ │ ├── L01 - <slug>.md
62
+ │ │ └── L02 - <slug>.md
63
+ │ ├── 04 - Ejecución.md ← Fase 4: el recibo, o el índice de los recibos
61
64
  │ └── 05 - Auditoría.md ← Fase 5
62
65
  ├── PROYECTO-B/
63
66
  │ └── Proyectos/
@@ -69,6 +72,8 @@ DOCUMENTACIÓN/
69
72
 
70
73
  `PROYECTO-A`, `PROYECTO-B` y `PROYECTO-C` son de ejemplo: tus proyectos son las carpetas que tú tengas ahí, con los nombres que tú les pongas. El nivel `Proyectos` es la subcarpeta de tareas, y su nombre también es configurable — ver `ORQUESTADOR_TASKS_SUBDIR` más abajo.
71
74
 
75
+ **Lotes y recibos.** El plan de la Fase 3 parte el trabajo en lotes (`L01`, `L02`…): cada uno es un cambio que se revierte solo y deja la aplicación funcionando. La Fase 4 los ejecuta uno a la vez y cierra cada lote con un **recibo** corto (qué se hizo, qué se verificó, qué espera por ti) y un commit. Con un solo lote, el recibo es directamente `04 - Ejecución.md` y no hay subcarpeta, salvo que ese lote falle o se descarte: entonces su recibo va a `04 - Ejecución/` y la fase no se da por cerrada. Con varios, los recibos van en `04 - Ejecución/` y `04 - Ejecución.md` es el índice que se escribe al cerrar el último: hasta entonces la fase no aparece como terminada. Al pedir la Fase 4 o la 5, el servidor añade al prompt la lista de recibos que ya existen, para retomar en otro chat sin adivinar sus nombres.
76
+
72
77
  > El servidor arma esta ruta **automáticamente**. Tú (o el LLM) solo indican `project`, `task_name` y `file_name`; no hay que escribir la ruta completa a mano.
73
78
 
74
79
  ---
@@ -291,15 +296,15 @@ Lo que hace cada uno:
291
296
  | `/f1` | `start_task` + `get_phase_prompt(1)` | El de la Fase 1 |
292
297
  | `/f2` | `get_phase_prompt(2)`, lee el de la Fase 1 | El de la Fase 2, después de que respondas las preguntas |
293
298
  | `/f3` | `get_phase_prompt(3)`, lee los anteriores | El de la Fase 3 |
294
- | `/f4` | `get_phase_prompt(4)`, lee el plan | El de la Fase 4, corto y al final |
299
+ | `/f4` | `get_phase_prompt(4)`, lee el plan | Un recibo por lote; el documento de la fase al cerrar |
295
300
  | `/f5` | `get_phase_prompt(5)` | El de la Fase 5: la auditoría y la descripción del PR, que además van en el chat |
296
301
 
297
302
  Ningún comando nombra un archivo: el nombre lo declara la cabecera del prompt de cada fase
298
303
  y `get_phase_prompt` se lo entrega al modelo.
299
304
 
300
- `/f1` y `/f5` esperan datos después del comando: `/f1` necesita project, task_name y el
301
- requerimiento completo; `/f5`, cuáles commits auditar. Los otros cuatro arrancan de
302
- `get_active_task`.
305
+ `/f1` espera datos después del comando: project, task_name y el requerimiento completo.
306
+ `/f5` acepta un rango de commits; si no le das ninguno, audita los que registran los recibos
307
+ de la Fase 4, y solo si no hay recibos te pregunta. Los demás arrancan de `get_active_task`.
303
308
 
304
309
  **Dónde ponerlos según el cliente:**
305
310
 
@@ -338,6 +343,19 @@ Tres cosas que **no** hace, a propósito:
338
343
 
339
344
  ---
340
345
 
346
+ ## 🔒 Documentos protegidos
347
+
348
+ Cada fase trabaja sobre los documentos de las anteriores, así que reescribir uno ya superado cambia en silencio la base de lo que vino después. Por eso `write_central_doc` se niega, **sin `force: true`**, a escribir:
349
+
350
+ - **El contexto inicial** (`00 - Contexto Inicial.md`), siempre, incluso para crearlo. Solo `start_task` lo escribe sin `force`.
351
+ - **El documento de una fase congelada.** Una fase se congela en cuanto existe el documento de una fase posterior, o en cuanto la Fase 4 tiene algún recibo en `04 - Ejecución/`. Los recibos cuentan como de la Fase 4: a ellos y a `04 - Ejecución.md` solo los congela la Fase 5.
352
+
353
+ Crear un documento de fase que todavía no existe nunca se rechaza; un recibo nuevo sí, cuando la Fase 5 ya está presente. Los nombres se comparan sin distinguir mayúsculas y en la misma forma Unicode, así que escribirlos de otra manera no esquiva la protección. Si hace falta una corrección legítima —una errata en el análisis con las decisiones ya escritas—, el mensaje de rechazo explica cómo pedirla: la IA solo usa `force` con tu confirmación explícita en el chat, y la respuesta avisa de que el documento se sobrescribió (o se creó, si no existía).
354
+
355
+ Además, `write_central_doc` solo escribe **dentro de la carpeta de una tarea que ya existe**: no crea carpetas sueltas en la Documentación.
356
+
357
+ ---
358
+
341
359
  ## 🛠 Tools Disponibles
342
360
 
343
361
  | Tool | Qué hace |
@@ -348,7 +366,7 @@ Tres cosas que **no** hace, a propósito:
348
366
  | `switch_task` | Cambia cuál es la tarea activa de un proyecto. **No toca ningún archivo**: es la forma correcta de retomar una tarea que ya existe. |
349
367
  | `get_phase_prompt` | Trae las reglas de comportamiento globales según la fase (1–5): 1 Descubrimiento, 2 Decisiones, 3 Plan Técnico, 4 Ejecución, 5 Auditoría / Pre-PR. El texto vive en `prompts/` (ver abajo). Comprueba de paso la **compuerta**: si pasas `project` y te saltaste una fase, el prompt llega con el aviso al principio. |
350
368
  | `read_central_doc` | Lee un archivo de la Documentación. **Recomendado:** pasar `project` + `task_name` + `file_name` (el servidor arma la ruta). También acepta `file_path` relativo. |
351
- | `write_central_doc` | Escribe/sobrescribe un archivo. Mismos parámetros que `read_central_doc` + `content`. |
369
+ | `write_central_doc` | Escribe/sobrescribe un archivo dentro de la carpeta de una tarea. Mismos parámetros que `read_central_doc` + `content`, y `force` para los [documentos protegidos](#-documentos-protegidos). |
352
370
  | `read_cross_repo` | Lee archivos de otros repos locales en tu carpeta de proyectos (ej. desde `repo-a` consultar cómo `repo-b` maneja un endpoint) sin cambiar de ventana. |
353
371
 
354
372
  ### Cómo el servidor arma las rutas
@@ -424,6 +442,7 @@ Tres detalles que importan:
424
442
  - **Se superpone por número de fase, no por nombre de archivo.** Tu `fase-2-a-mi-manera.md` pisa al `fase-2-decisiones.md` del paquete. No tienes que adivinar cómo se llama el original.
425
443
  - **También sirve para añadir.** Un `fase-6-despliegue.md` en tu carpeta crea la fase 6, sin tocar nada más.
426
444
  - **Tu `global-rules.md` sustituye al del paquete, no se suma.** Concatenar dos juegos de reglas hace imposible saber de qué archivo salió la instrucción que estorbó, justo cuando necesitas saberlo. Si quieres las de fábrica y algo más, cópialas y añade.
445
+ **Ojo:** las de fábrica traen las secciones `EVIDENCIA`, `BASE DE CADA DECISIÓN` y `PENDIENTES HEREDADOS`, que las fases citan. Un `global-rules.md` propio que no las incluya deja a las fases citando reglas que no existen; cópialas a tu archivo.
427
446
 
428
447
  Por el camino `npx` esta es la **única** forma de personalizar: los prompts de fábrica viven dentro de `node_modules` y editarlos ahí no sirve, porque la siguiente instalación se los lleva.
429
448
 
package/README.md CHANGED
@@ -34,7 +34,7 @@ flowchart TD
34
34
 
35
35
  And between one phase and the next there is a **gate**, which is what gives the server its name: ask for a phase while skipping the previous one and the server notices and warns you ([see below](#-the-gate-skipping-a-phase-stops-being-invisible)).
36
36
 
37
- In phase 5 the AI only reads. **You always make the commits and open the PR.**
37
+ In phase 4 the AI commits each batch only with your OK; it never pushes or opens the PR. In phase 5 it only reads.
38
38
 
39
39
  ## Who it's for
40
40
 
@@ -59,7 +59,10 @@ DOCUMENTATION/
59
59
  │ ├── 01 - Análisis Técnico.md ← Phase 1
60
60
  │ ├── 02 - Decisiones.md ← Phase 2
61
61
  │ ├── 03 - Plan Técnico.md ← Phase 3
62
- │ ├── 04 - Ejecución.md ← Phase 4
62
+ │ ├── 04 - Ejecución/ ← Phase 4, with more than one batch: one receipt each
63
+ │ │ ├── L01 - <slug>.md
64
+ │ │ └── L02 - <slug>.md
65
+ │ ├── 04 - Ejecución.md ← Phase 4: the receipt, or the index of receipts
63
66
  │ └── 05 - Auditoría.md ← Phase 5
64
67
  ├── PROJECT-B/
65
68
  │ └── Proyectos/
@@ -71,6 +74,8 @@ DOCUMENTATION/
71
74
 
72
75
  The `Proyectos` level is the tasks subfolder, and its name is configurable — see `ORQUESTADOR_TASKS_SUBDIR` below. The document names come from the phase prompts, which is why they are in Spanish by default.
73
76
 
77
+ **Batches and receipts.** The phase 3 plan splits the work into batches (`L01`, `L02`…): each one is a change that can be reverted on its own and leaves the application working. Phase 4 runs them one at a time and closes each batch with a short **receipt** (what was done, what was verified, what is waiting for you) and a commit. With a single batch, the receipt is `04 - Ejecución.md` itself and there is no subfolder, unless that batch fails or is discarded: then its receipt goes in `04 - Ejecución/` and the phase isn't marked as closed. With several, the receipts go in `04 - Ejecución/` and `04 - Ejecución.md` is the index written when the last one closes: until then the phase doesn't show as finished. When you ask for phase 4 or 5, the server appends the list of existing receipts to the prompt, so a new chat can pick up without guessing their names.
78
+
74
79
  > The server builds this path **automatically**. You (or the LLM) only supply `project`, `task_name` and `file_name`; nobody has to type a full path.
75
80
 
76
81
  ---
@@ -287,12 +292,12 @@ You invoke them by typing `/f1`, `/f2`, etc. in the chat. **They are orchestrati
287
292
  | `/f1` | `start_task` + `get_phase_prompt(1)` | Phase 1's |
288
293
  | `/f2` | `get_phase_prompt(2)`, reads phase 1's | Phase 2's, after you answer the questions |
289
294
  | `/f3` | `get_phase_prompt(3)`, reads the previous ones | Phase 3's |
290
- | `/f4` | `get_phase_prompt(4)`, reads the plan | Phase 4's, short and at the end |
295
+ | `/f4` | `get_phase_prompt(4)`, reads the plan | One receipt per batch; the phase document when it closes |
291
296
  | `/f5` | `get_phase_prompt(5)` | Phase 5's: the audit and the PR description, which also go in the chat |
292
297
 
293
298
  No command names a file: the name is declared in each phase prompt's header and `get_phase_prompt` hands it to the model.
294
299
 
295
- `/f1` and `/f5` expect data after the command: `/f1` needs project, task_name and the full requirement; `/f5`, which commits to audit. The other four start from `get_active_task`.
300
+ `/f1` expects data after the command: project, task_name and the full requirement. `/f5` accepts a commit range; without one it audits the commits recorded in the phase 4 receipts, and only asks if there are no receipts. The rest start from `get_active_task`.
296
301
 
297
302
  **Where to put them, per client:**
298
303
 
@@ -329,6 +334,19 @@ Three things it deliberately does **not** do:
329
334
 
330
335
  ---
331
336
 
337
+ ## 🔒 Protected documents
338
+
339
+ Each phase works on top of the previous phases' documents, so rewriting one that's already been passed silently changes the basis of everything that came after. That's why `write_central_doc` refuses, **without `force: true`**, to write:
340
+
341
+ - **The initial context** (`00 - Contexto Inicial.md`), always, even to create it. Only `start_task` writes it without `force`.
342
+ - **The document of a frozen phase.** A phase freezes as soon as a later phase's document exists, or as soon as phase 4 has any receipt in `04 - Ejecución/`. Receipts count as phase 4: they and `04 - Ejecución.md` are frozen only by phase 5.
343
+
344
+ Creating a phase document that doesn't exist yet is never refused; a new receipt is, once phase 5 is present. Names are compared ignoring case and in the same Unicode form, so spelling them differently doesn't get around the protection. If a legitimate fix is needed — a typo in the analysis when the decisions are already written — the refusal message explains how to ask for it: the AI only uses `force` with your explicit confirmation in the chat, and the response warns that the document was overwritten (or created, if it didn't exist).
345
+
346
+ On top of that, `write_central_doc` only writes **inside the folder of a task that already exists**: it doesn't create stray folders in the documentation.
347
+
348
+ ---
349
+
332
350
  ## 🛠 Available tools
333
351
 
334
352
  | Tool | What it does |
@@ -339,7 +357,7 @@ Three things it deliberately does **not** do:
339
357
  | `switch_task` | Changes which task is active for a project. **Touches no files**: it's the correct way to resume an existing task. |
340
358
  | `get_phase_prompt` | Returns the behaviour rules for a phase (1–5), with the global rules prepended and the exact document filenames appended. The text lives in `prompts/`. It also checks the **gate**: pass `project` and, if you skipped a phase, the prompt arrives with the warning on top. |
341
359
  | `read_central_doc` | Reads a file from the central documentation. **Recommended:** pass `project` + `task_name` + `file_name` and let the server build the path. It also accepts a relative `file_path`. |
342
- | `write_central_doc` | Writes/overwrites a file. Same parameters as `read_central_doc` plus `content`. |
360
+ | `write_central_doc` | Writes/overwrites a file inside a task folder. Same parameters as `read_central_doc` plus `content`, and `force` for [protected documents](#-protected-documents). |
343
361
  | `read_cross_repo` | Reads files from your other local repos, so you can check how another codebase solved something without switching windows. |
344
362
 
345
363
  ### How the server builds paths
@@ -411,6 +429,7 @@ Three details that matter:
411
429
  - **Layering is by phase number, not by filename.** Your `fase-2-my-way.md` overrides the package's `fase-2-decisiones.md`. You don't have to guess the original's name.
412
430
  - **It also adds.** A `fase-6-deployment.md` in your folder creates phase 6, nothing else needed.
413
431
  - **Your `global-rules.md` replaces the packaged one, it doesn't stack.** Concatenating two rule sets makes it impossible to tell which file an unwanted instruction came from, exactly when you need to know. If you want the packaged rules plus extras, copy them and add.
432
+ **Watch out:** the packaged rules include the `EVIDENCIA`, `BASE DE CADA DECISIÓN` and `PENDIENTES HEREDADOS` sections, which the phases refer to. A `global-rules.md` of your own without them leaves the phases pointing at rules that don't exist; copy them into your file.
414
433
 
415
434
  On the `npx` route this is the **only** way to customise: the packaged prompts live inside `node_modules`, where editing them achieves nothing because the next install wipes them.
416
435
 
package/build/index.js CHANGED
@@ -11,9 +11,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
11
11
  import * as fs from "fs";
12
12
  import * as path from "path";
13
13
  import { DOCS, REPOS, SERVER_NAME, SERVER_VERSION, requireBasePath, stateFile } from "./config.js";
14
- import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, validarSegmento, } from "./paths.js";
14
+ import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, ubicarEnTarea, validarNombreVisible, validarSegmento, } from "./paths.js";
15
15
  import { describePhases, discoverPhases, loadPhasePrompt, nombreContextoInicial, } from "./phases.js";
16
- import { avisoDeCompuerta, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, tareaActiva, } from "./tasks.js";
16
+ import { avisoDeCompuerta, bloqueDeRecibos, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, motivoDeProteccion, tareaActiva, tareaDelPrompt, } from "./tasks.js";
17
17
  import { validarLlamada } from "./schemas.js";
18
18
  const server = new Server({
19
19
  name: SERVER_NAME,
@@ -116,8 +116,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
116
116
  project: { type: "string", description: `Proyecto, uno de los existentes: ${PROJECTS_DESCRIPTION}. Úsalo junto con task_name y file_name.` },
117
117
  task_name: { type: "string", description: "Nombre de la carpeta de la tarea." },
118
118
  file_name: { type: "string", description: "Nombre (o ruta relativa) del archivo dentro de la carpeta de la tarea (ej. '01 - Análisis Técnico.md')." },
119
- file_path: { type: "string", description: "ALTERNATIVA: ruta relativa dentro de DOCUMENTACIÓN. Solo si no usas project+task_name." },
120
- content: { type: "string", description: "Contenido a escribir" }
119
+ file_path: { type: "string", description: "ALTERNATIVA: ruta relativa dentro de DOCUMENTACIÓN, que tiene que caer dentro de la carpeta de una tarea que ya existe (ej. 'PROYECTO-A/Proyectos/tarea/01 - Análisis Técnico.md'). Los archivos del proyecto no se escriben con esta tool. Solo si no usas project+task_name." },
120
+ content: { type: "string", description: "Contenido a escribir" },
121
+ force: { type: "boolean", description: "Solo con confirmación explícita del usuario en el chat. Permite reescribir un documento congelado o el contexto inicial." }
121
122
  },
122
123
  required: ["content"],
123
124
  },
@@ -159,9 +160,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
159
160
  }
160
161
  // El nombre se vuelve carpeta tal cual: "../evil" la creaba fuera de la Documentación.
161
162
  validarSegmento(proj, "project");
163
+ validarNombreVisible(proj, "project");
162
164
  carpetaProyecto = proj.toUpperCase();
163
165
  estrenado = true;
164
166
  }
167
+ validarNombreVisible(taskName, "task_name");
165
168
  const carpetaTarea = taskFolderPath(carpetaProyecto, taskName);
166
169
  const contextoInicial = path.join(carpetaTarea, nombreContextoInicial());
167
170
  const docsBase = requireBasePath(DOCS);
@@ -194,18 +197,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
194
197
  const prompt = loadPhasePrompt(phase);
195
198
  // La compuerta avisa, no bloquea: el prompt se entrega siempre. Si comprobarla fallara
196
199
  // por lo que sea, entregarlo igual es mejor que dejar al usuario sin fase por un aviso.
200
+ // Lo mismo con la lista de recibos: es una ayuda, no una condición.
197
201
  let aviso = null;
202
+ let recibos = null;
198
203
  try {
199
- aviso = avisoDeCompuerta(phase, discoverPhases(), {
200
- ...(project ? { project } : {}),
201
- ...(task_name ? { taskName: task_name } : {}),
202
- });
204
+ const phases = discoverPhases();
205
+ const opciones = { ...(project ? { project } : {}), ...(task_name ? { taskName: task_name } : {}) };
206
+ aviso = avisoDeCompuerta(phase, phases, opciones);
207
+ const estado = tareaDelPrompt(phases, opciones);
208
+ if (estado && estado !== "ambigua")
209
+ recibos = bloqueDeRecibos(phase, estado, phases);
203
210
  }
204
211
  catch (error) {
205
212
  console.error(`[orquestador] No se pudo comprobar la compuerta: ${error.message}`);
206
213
  }
214
+ const texto = [aviso, prompt, recibos].filter(Boolean).join("\n\n");
207
215
  return {
208
- content: [{ type: "text", text: aviso ? `${aviso}\n\n${prompt}` : prompt }],
216
+ content: [{ type: "text", text: texto }],
209
217
  };
210
218
  }
211
219
  if (llamada.tool === "get_active_task") {
@@ -298,10 +306,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
298
306
  if (!estaDentroReal(docsBase, fullPath)) {
299
307
  throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
300
308
  }
309
+ // Antes del mkdir: con una tarea mal escrita, el mkdir recursivo creaba su carpeta.
310
+ const ubicacion = ubicarEnTarea(fullPath);
311
+ if (!ubicacion) {
312
+ throw new Error("write_central_doc solo escribe dentro de la carpeta de una tarea que ya existe " +
313
+ "(<DOCS>/<PROYECTO>/<subcarpeta de tareas>/<tarea>/). Los archivos del proyecto, como " +
314
+ "phase-gate.json, no se escriben con esta tool. Para una tarea nueva, usa start_task.");
315
+ }
316
+ const motivo = motivoDeProteccion(ubicacion.carpetaTarea, ubicacion.relativo, discoverPhases(), nombreContextoInicial());
317
+ if (motivo && llamada.args.force !== true) {
318
+ throw new Error(`${motivo} Si de verdad hay que reescribirlo, pídele al usuario confirmación explícita ` +
319
+ `y vuelve a llamar con force: true.`);
320
+ }
321
+ const existia = fs.existsSync(fullPath);
301
322
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
302
323
  fs.writeFileSync(fullPath, content, "utf-8");
324
+ // force también crea el contexto inicial cuando falta: no se dice "sobrescrito" si no lo era.
325
+ const forzado = motivo ? (existia ? " (sobrescrito con force)" : " (creado con force)") : "";
303
326
  return {
304
- content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}` }],
327
+ content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}${forzado}` }],
305
328
  };
306
329
  }
307
330
  if (llamada.tool === "read_cross_repo") {
package/build/paths.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export declare function estaDentro(base: string, ruta: string): boolean;
2
2
  export declare function rutaReal(ruta: string): string;
3
3
  export declare function estaDentroReal(base: string, ruta: string): boolean;
4
+ export declare function claveDeNombre(nombre: string): string;
4
5
  export declare function validarSegmento(valor: string, campo: string): string;
6
+ export declare function validarNombreVisible(valor: string, campo: string): string;
5
7
  export declare function discoverProjects(): Map<string, string>;
6
8
  export declare function describeProjects(): string;
7
9
  export declare function findProjectFolder(project: string): string | null;
@@ -16,6 +18,13 @@ export type ArgumentosDeDocumento = {
16
18
  file_path?: string | undefined;
17
19
  };
18
20
  export declare function resolveDocPath(args: ArgumentosDeDocumento): string;
21
+ export type UbicacionEnTarea = {
22
+ carpetaProyecto: string;
23
+ taskName: string;
24
+ carpetaTarea: string;
25
+ relativo: string;
26
+ };
27
+ export declare function ubicarEnTarea(rutaAbsoluta: string): UbicacionEnTarea | null;
19
28
  export declare function resolveRepoPath(repoName: string, filePath: string): string;
20
29
  export declare const LIMITE_LECTURA_BYTES = 1048576;
21
30
  export declare function leerTextoAcotado(ruta: string): string;
package/build/paths.js CHANGED
@@ -38,6 +38,13 @@ export function rutaReal(ruta) {
38
38
  export function estaDentroReal(base, ruta) {
39
39
  return estaDentro(rutaReal(base), rutaReal(ruta));
40
40
  }
41
+ // Clave para comparar nombres de documento: dos nombres con la misma clave se ven iguales, y en
42
+ // Windows y macOS abren el mismo archivo. rutaReal solo corrige las mayúsculas de lo que ya
43
+ // existe, así que lo que todavía no existe se compara por aquí. Sin distinguir mayúsculas en
44
+ // todos los sistemas, para que el comportamiento no dependa del equipo.
45
+ export function claveDeNombre(nombre) {
46
+ return nombre.normalize("NFC").toLowerCase();
47
+ }
41
48
  // Un nombre de proyecto, tarea o repo es UN segmento de ruta, portable entre sistemas:
42
49
  // la Documentación viaja entre equipos con Windows, macOS y Linux. Lo que se rechaza es lo
43
50
  // que hoy ya era peligroso (separadores, "..") o lo que Windows no puede guardar y rompe la
@@ -69,6 +76,17 @@ export function validarSegmento(valor, campo) {
69
76
  }
70
77
  return valor;
71
78
  }
79
+ // Un proyecto o una tarea que start_task va a crear tampoco puede empezar por punto: las carpetas
80
+ // con punto no son proyecto ni tarea, así que no aparecería en list_tasks y write_central_doc no
81
+ // podría escribir en ella. No va en validarSegmento porque read_central_doc también lo usa, y la
82
+ // lectura no cambia.
83
+ export function validarNombreVisible(valor, campo) {
84
+ if (valor.startsWith(".")) {
85
+ throw new Error(`${campo} ${JSON.stringify(valor)} no es válido: no puede empezar por punto. Las carpetas ` +
86
+ `con punto no cuentan como proyecto ni como tarea, así que no se vería después.`);
87
+ }
88
+ return valor;
89
+ }
72
90
  // ─── Proyectos ──────────────────────────────────────────────────────────────
73
91
  // Los proyectos no se declaran en ninguna parte: son las carpetas que existen. Antes había
74
92
  // una tabla fija con los proyectos de quien escribió el código, que no restringía nada
@@ -78,12 +96,13 @@ export function validarSegmento(valor, campo) {
78
96
  // Es proyecto la carpeta que contenga la subcarpeta de tareas. Esa regla deja fuera sola a
79
97
  // las carpetas que no son proyectos, y da de alta uno nuevo con solo crearle su subcarpeta.
80
98
  // Si la subcarpeta está vacía la regla no se puede aplicar y vale cualquier carpeta.
99
+ // Las carpetas con punto nunca son proyecto: .obsidian o .git en la raíz de la Documentación.
81
100
  export function discoverProjects() {
82
101
  const proyectos = new Map(); // clave en minúsculas -> nombre real de la carpeta
83
102
  const base = requireBasePath(DOCS);
84
103
  const subdir = tasksSubdir();
85
104
  for (const entrada of fs.readdirSync(base, { withFileTypes: true })) {
86
- if (!entrada.isDirectory())
105
+ if (!entrada.isDirectory() || entrada.name.startsWith("."))
87
106
  continue;
88
107
  if (subdir && !fs.existsSync(path.join(base, entrada.name, subdir)))
89
108
  continue;
@@ -143,10 +162,10 @@ function validarRutaRelativa(valor, campo, relativaA) {
143
162
  // la base: antes "../otra-tarea/x.md" escribía en otra tarea y la comprobación de la tool,
144
163
  // que solo miraba la base, lo dejaba pasar.
145
164
  //
146
- // Con file_path basta con no salir de la base (la restricción a carpetas de tarea llega en la
147
- // Etapa 1), pero sus segmentos también tienen que ser portables: antes creaba dentro de la
148
- // Documentación un "CON", un "nul.md" o un flujo alterno "nota.md:secreto", que el Explorador
149
- // y OneDrive no saben manejar.
165
+ // Con file_path aquí basta con no salir de la base (write_central_doc además exige que caiga en
166
+ // una tarea: ubicarEnTarea), pero sus segmentos también tienen que ser portables: antes creaba
167
+ // dentro de la Documentación un "CON", un "nul.md" o un flujo alterno "nota.md:secreto", que el
168
+ // Explorador y OneDrive no saben manejar.
150
169
  export function resolveDocPath(args) {
151
170
  const { project = "", task_name: taskName = "", file_name: fileName = "", file_path: filePath = "" } = args;
152
171
  if (project && taskName && fileName) {
@@ -169,6 +188,50 @@ export function resolveDocPath(args) {
169
188
  }
170
189
  throw new Error("Debes proporcionar (project + task_name + file_name) o bien file_path.");
171
190
  }
191
+ // Segmentos de `ruta` dentro de `base`, o null si no cae dentro. Las dos ya vienen resueltas.
192
+ function segmentosDentro(base, ruta) {
193
+ const relativa = path.relative(base, ruta);
194
+ if (relativa === "")
195
+ return [];
196
+ if (relativa.startsWith("..") || path.isAbsolute(relativa))
197
+ return null;
198
+ return relativa.split(/[\\/]/);
199
+ }
200
+ // write_central_doc solo escribe dentro de la carpeta de una tarea que ya existe. Con file_path
201
+ // se podía escribir en cualquier lugar de la Documentación, incluido el phase-gate.json del
202
+ // proyecto, y con una tarea mal escrita el mkdir recursivo creaba una carpeta suelta.
203
+ //
204
+ // Se mide sobre rutas reales, como la contención: un enlace interno que lleva a una tarea vale,
205
+ // porque lo que se escribe acaba dentro de ella. La subcarpeta de tareas se compara con la que
206
+ // hay en disco y no con la variable: en Windows y macOS pueden diferir en mayúsculas, y
207
+ // discoverProjects ya da el proyecto por bueno.
208
+ //
209
+ // Devuelve null si la ruta no cae dentro de la carpeta de una tarea existente, o si algún
210
+ // segmento dentro de la tarea empieza por punto (.git, .phase-gate no son documentos).
211
+ export function ubicarEnTarea(rutaAbsoluta) {
212
+ const docsReal = rutaReal(requireBasePath(DOCS));
213
+ const segmentos = segmentosDentro(docsReal, rutaReal(rutaAbsoluta));
214
+ if (!segmentos || segmentos.length === 0)
215
+ return null;
216
+ // El nombre que da el disco tiene que ser el del proyecto, no solo parecerse en mayúsculas.
217
+ const [proyecto = "", ...resto] = segmentos;
218
+ const carpetaProyecto = findProjectFolder(proyecto);
219
+ if (carpetaProyecto !== proyecto)
220
+ return null;
221
+ const raizProyecto = path.join(docsReal, carpetaProyecto);
222
+ const subdir = segmentosDentro(raizProyecto, rutaReal(tasksRootPath(carpetaProyecto)));
223
+ if (!subdir || subdir.some((segmento, i) => resto[i] !== segmento))
224
+ return null;
225
+ const [taskName, ...archivo] = resto.slice(subdir.length);
226
+ if (!taskName || taskName.startsWith(".") || archivo.length === 0)
227
+ return null;
228
+ if (archivo.some((segmento) => segmento.startsWith(".")))
229
+ return null;
230
+ const carpetaTarea = path.join(raizProyecto, ...subdir, taskName);
231
+ if (!fs.statSync(carpetaTarea, { throwIfNoEntry: false })?.isDirectory())
232
+ return null;
233
+ return { carpetaProyecto, taskName, carpetaTarea, relativo: archivo.join("/") };
234
+ }
172
235
  // ─── Repos ──────────────────────────────────────────────────────────────────
173
236
  // Ruta de un archivo dentro de UN repo. Antes la comprobación miraba la carpeta de repos
174
237
  // entera, y desde repoA "../repoB/.env" pasaba: estaba dentro de la carpeta de repos. El repo
package/build/phases.d.ts CHANGED
@@ -4,7 +4,10 @@ export type PhaseFile = {
4
4
  title: string;
5
5
  documento?: string | undefined;
6
6
  documentoInicial?: string | undefined;
7
+ recibos?: string | undefined;
7
8
  };
9
+ export declare const MARCADOR_RANURA: RegExp;
10
+ export declare function quitarMarcadores(texto: string): string;
8
11
  export declare function parsePhaseFile(fullPath: string): {
9
12
  meta: Record<string, string>;
10
13
  cuerpo: string;
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.");
@@ -28,6 +28,7 @@ export declare const readCentralDocShape: {
28
28
  };
29
29
  export declare const writeCentralDocShape: {
30
30
  content: z.ZodString;
31
+ force: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
31
32
  project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
33
  task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
33
34
  file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -67,6 +68,7 @@ declare const SHAPES: {
67
68
  };
68
69
  write_central_doc: {
69
70
  content: z.ZodString;
71
+ force: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
70
72
  project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
71
73
  task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
72
74
  file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
package/build/schemas.js CHANGED
@@ -43,6 +43,9 @@ export const readCentralDocShape = {
43
43
  export const writeCentralDocShape = {
44
44
  ...readCentralDocShape,
45
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(),
46
49
  };
47
50
  export const readCrossRepoShape = {
48
51
  repo_name: z.string().min(1, "Falta `repo_name`."),
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