@saulwade/swl-ses 1.4.2 → 1.5.1

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.
@@ -1,187 +1,214 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * swl-mcp-server — Servidor MCP **EXPERIMENTAL** para exponer la memoria
6
- * de swl-ses a clientes MCP externos (Cursor, Gemini CLI, OpenCode, etc.).
7
- *
8
- * **NO PRODUCCIÓNSTUB EXPERIMENTAL**.
9
- * Ver `scripts/mcp-server/README.md` para limitaciones detalladas.
10
- *
11
- * Modo de transporte: stdio (JSON-RPC sobre stdin/stdout).
12
- * No HTTP, no auth, no rate limiting.
13
- *
14
- * Uso (cliente MCP):
15
- * - Configurar el cliente para ejecutar `node /path/to/swl-ses/bin/swl-mcp-server.js`
16
- * con stdio.
17
- * - Los handlers leen el cwd del proceso para localizar `.planning/`,
18
- * `instintos/`, `APRENDIZAJES.md`. Por defecto usa `process.cwd()`.
19
- * - Override con env var `SWL_MCP_BASE_DIR` si el cliente arranca el server
20
- * desde otro directorio.
21
- *
22
- * Protocolo MCP soportado (subset):
23
- * - initialize / initialized
24
- * - tools/list
25
- * - tools/call
26
- *
27
- * NO soporta:
28
- * - resources/list, prompts/list
29
- * - logging, sampling
30
- * - cancellation, progress
31
- * - HTTP transport
32
- *
33
- * Trigger documentado para implementación completa: "uso ≥2 runtimes
34
- * diferentes (Cursor + Claude Code o similar) consistentemente por
35
- * ≥1 mes". Hoy: 0 instalaciones reportadas.
36
- */
37
-
38
- const path = require('path');
39
-
40
- const { HANDLERS } = require('../scripts/mcp-server/handlers');
41
-
42
- const SERVER_NAME = 'swl-mcp-server';
43
- const SERVER_VERSION = '0.1.0-experimental';
44
- const PROTOCOL_VERSION = '2024-11-05';
45
-
46
- const baseDir = process.env.SWL_MCP_BASE_DIR || process.cwd();
47
-
48
- // ── logging ───────────────────────────────────────────────────────────────────
49
-
50
- // Stderr para evitar contaminar stdout (que es JSON-RPC).
51
- function log(level, msg, data) {
52
- const linea = JSON.stringify({
53
- timestamp: new Date().toISOString(),
54
- level,
55
- msg,
56
- ...(data ? { data } : {}),
57
- });
58
- process.stderr.write(linea + '\n');
59
- }
60
-
61
- // ── JSON-RPC helpers ──────────────────────────────────────────────────────────
62
-
63
- function respuesta(id, result) {
64
- return JSON.stringify({ jsonrpc: '2.0', id, result });
65
- }
66
-
67
- function errorResp(id, code, message) {
68
- return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
69
- }
70
-
71
- // ── routing ───────────────────────────────────────────────────────────────────
72
-
73
- function manejarInitialize(request) {
74
- return respuesta(request.id, {
75
- protocolVersion: PROTOCOL_VERSION,
76
- capabilities: {
77
- tools: { listChanged: false },
78
- },
79
- serverInfo: {
80
- name: SERVER_NAME,
81
- version: SERVER_VERSION,
82
- },
83
- });
84
- }
85
-
86
- function manejarToolsList(request) {
87
- const tools = Object.entries(HANDLERS).map(([name, def]) => ({
88
- name,
89
- description: def.description,
90
- inputSchema: def.inputSchema,
91
- }));
92
- return respuesta(request.id, { tools });
93
- }
94
-
95
- function manejarToolsCall(request) {
96
- const { name, arguments: args } = request.params || {};
97
- const def = HANDLERS[name];
98
- if (!def) {
99
- return errorResp(request.id, -32601, `Tool no encontrado: ${name}`);
100
- }
101
- try {
102
- const result = def.handler(baseDir, args || {});
103
- return respuesta(request.id, {
104
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
105
- });
106
- } catch (err) {
107
- log('error', `Excepción en handler ${name}`, { error: err.message });
108
- return errorResp(request.id, -32603, `Error interno: ${err.message}`);
109
- }
110
- }
111
-
112
- function rutear(request) {
113
- switch (request.method) {
114
- case 'initialize':
115
- return manejarInitialize(request);
116
- case 'initialized':
117
- case 'notifications/initialized':
118
- return null; // notification sin respuesta
119
- case 'tools/list':
120
- return manejarToolsList(request);
121
- case 'tools/call':
122
- return manejarToolsCall(request);
123
- case 'ping':
124
- return respuesta(request.id, {});
125
- default:
126
- return errorResp(request.id, -32601, `Método no soportado: ${request.method}`);
127
- }
128
- }
129
-
130
- // ── loop principal ────────────────────────────────────────────────────────────
131
-
132
- function arrancar() {
133
- log('warn', '⚠ swl-mcp-server stub experimental — NO usar en producción');
134
- log('info', `Server iniciando`, { name: SERVER_NAME, version: SERVER_VERSION, baseDir });
135
-
136
- let buffer = '';
137
-
138
- process.stdin.setEncoding('utf8');
139
- process.stdin.on('data', (chunk) => {
140
- buffer += chunk;
141
-
142
- // Cada mensaje JSON-RPC termina con \n
143
- let nlIndex;
144
- while ((nlIndex = buffer.indexOf('\n')) >= 0) {
145
- const linea = buffer.slice(0, nlIndex).trim();
146
- buffer = buffer.slice(nlIndex + 1);
147
-
148
- if (!linea) continue;
149
-
150
- let request;
151
- try {
152
- request = JSON.parse(linea);
153
- } catch (err) {
154
- log('error', 'JSON inválido recibido', { error: err.message, linea: linea.slice(0, 100) });
155
- process.stdout.write(errorResp(null, -32700, 'Parse error') + '\n');
156
- continue;
157
- }
158
-
159
- const respuestaStr = rutear(request);
160
- if (respuestaStr) {
161
- process.stdout.write(respuestaStr + '\n');
162
- }
163
- }
164
- });
165
-
166
- process.stdin.on('end', () => {
167
- log('info', 'stdin cerrado, server termina');
168
- process.exit(0);
169
- });
170
-
171
- // Manejo de errores no capturados — nunca crashear silenciosamente
172
- process.on('uncaughtException', (err) => {
173
- log('error', 'uncaughtException', { error: err.message, stack: err.stack });
174
- });
175
- }
176
-
177
- if (require.main === module) {
178
- arrancar();
179
- }
180
-
181
- module.exports = {
182
- rutear,
183
- arrancar,
184
- SERVER_NAME,
185
- SERVER_VERSION,
186
- PROTOCOL_VERSION,
187
- };
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * swl-mcp-server — Servidor MCP de solo lectura para exponer la memoria
6
+ * de swl-ses a clientes MCP externos (Cursor, Codex CLI, Gemini CLI, etc.).
7
+ *
8
+ * v1.0.0 (ADR-0019 Sub-fase 3) promovido de stub experimental a versión
9
+ * estable con auth opt-in, caching mtime-based, telemetría JSONL y schema
10
+ * versioning. Mantiene compatibilidad total con clientes existentes que
11
+ * conectan sin auth (si `SWL_MCP_API_KEY` no está set, comportamiento idéntico
12
+ * al stub v0.1.x).
13
+ *
14
+ * Modo de transporte: stdio (JSON-RPC sobre stdin/stdout).
15
+ *
16
+ * Uso (cliente MCP):
17
+ * - Configurar el cliente para ejecutar `node /path/to/swl-ses/bin/swl-mcp-server.js`
18
+ * con stdio.
19
+ * - El cwd del proceso determina baseDir (override con `SWL_MCP_BASE_DIR`).
20
+ *
21
+ * Variables opt-in (env del server):
22
+ * - SWL_MCP_API_KEY — Si set, requiere params._auth en cada tools/call.
23
+ * - SWL_MCP_CACHE_TTL_MS TTL del cache mtime-based (default 60000).
24
+ * - SWL_MCP_METRICS — Si "1" o "true", persiste metrics en
25
+ * .planning/evolucion/mcp-metrics.jsonl.
26
+ *
27
+ * Schema versioning:
28
+ * - Cada handler declara `schemaVersion` en su definición.
29
+ * - El cliente puede inspeccionarlo via `tools/list` (campo `_schemaVersion`).
30
+ *
31
+ * Protocolo MCP soportado (subset):
32
+ * - initialize / initialized
33
+ * - tools/list
34
+ * - tools/call
35
+ * - ping
36
+ */
37
+
38
+ const path = require('path');
39
+
40
+ const { HANDLERS } = require('../scripts/mcp-server/handlers');
41
+ const { construirValidador } = require('../scripts/mcp-server/auth');
42
+ const { construirTelemetria } = require('../scripts/mcp-server/telemetry');
43
+
44
+ const SERVER_NAME = 'swl-mcp-server';
45
+ const SERVER_VERSION = '1.0.0';
46
+ const PROTOCOL_VERSION = '2024-11-05';
47
+
48
+ const baseDir = process.env.SWL_MCP_BASE_DIR || process.cwd();
49
+ const authValidator = construirValidador();
50
+ const telemetry = construirTelemetria({ baseDir });
51
+
52
+ // ── logging ───────────────────────────────────────────────────────────────────
53
+
54
+ // Stderr para evitar contaminar stdout (que es JSON-RPC).
55
+ function log(level, msg, data) {
56
+ const linea = JSON.stringify({
57
+ timestamp: new Date().toISOString(),
58
+ level,
59
+ msg,
60
+ ...(data ? { data } : {}),
61
+ });
62
+ process.stderr.write(linea + '\n');
63
+ }
64
+
65
+ // ── JSON-RPC helpers ──────────────────────────────────────────────────────────
66
+
67
+ function respuesta(id, result) {
68
+ return JSON.stringify({ jsonrpc: '2.0', id, result });
69
+ }
70
+
71
+ function errorResp(id, code, message) {
72
+ return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
73
+ }
74
+
75
+ // ── routing ───────────────────────────────────────────────────────────────────
76
+
77
+ function manejarInitialize(request) {
78
+ return respuesta(request.id, {
79
+ protocolVersion: PROTOCOL_VERSION,
80
+ capabilities: {
81
+ tools: { listChanged: false },
82
+ },
83
+ serverInfo: {
84
+ name: SERVER_NAME,
85
+ version: SERVER_VERSION,
86
+ authRequired: authValidator.requerida,
87
+ telemetryEnabled: telemetry.habilitada,
88
+ },
89
+ });
90
+ }
91
+
92
+ function manejarToolsList(request) {
93
+ const tools = Object.entries(HANDLERS).map(([name, def]) => ({
94
+ name,
95
+ description: def.description,
96
+ inputSchema: def.inputSchema,
97
+ _schemaVersion: def.schemaVersion || '1.0.0',
98
+ }));
99
+ return respuesta(request.id, { tools });
100
+ }
101
+
102
+ function manejarToolsCall(request) {
103
+ const { name, arguments: args } = request.params || {};
104
+ const def = HANDLERS[name];
105
+ if (!def) {
106
+ return errorResp(request.id, -32601, `Tool no encontrado: ${name}`);
107
+ }
108
+ const inicio = Date.now();
109
+ try {
110
+ const result = def.handler(baseDir, args || {});
111
+ const duracionMs = Date.now() - inicio;
112
+ telemetry.registrar({ tool: name, durationMs: duracionMs, ok: true, baseDir });
113
+ return respuesta(request.id, {
114
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
115
+ });
116
+ } catch (err) {
117
+ const duracionMs = Date.now() - inicio;
118
+ log('error', `Excepción en handler ${name}`, { error: err.message });
119
+ telemetry.registrar({ tool: name, durationMs: duracionMs, ok: false, error: err.message, baseDir });
120
+ return errorResp(request.id, -32603, `Error interno: ${err.message}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Punto de entrada del routing — público para tests.
126
+ *
127
+ * @param {object} request - Mensaje JSON-RPC parseado.
128
+ * @returns {string|null} - String JSON-RPC respuesta o null (notification).
129
+ */
130
+ function rutear(request) {
131
+ // Auth gate: si SWL_MCP_API_KEY está set, validar antes de routing.
132
+ const auth = authValidator.validar(request);
133
+ if (!auth.ok) {
134
+ return errorResp(request.id, auth.code, auth.message);
135
+ }
136
+
137
+ switch (request.method) {
138
+ case 'initialize':
139
+ return manejarInitialize(request);
140
+ case 'initialized':
141
+ case 'notifications/initialized':
142
+ return null; // notification sin respuesta
143
+ case 'tools/list':
144
+ return manejarToolsList(request);
145
+ case 'tools/call':
146
+ return manejarToolsCall(request);
147
+ case 'ping':
148
+ return respuesta(request.id, {});
149
+ default:
150
+ return errorResp(request.id, -32601, `Método no soportado: ${request.method}`);
151
+ }
152
+ }
153
+
154
+ // ── loop principal ────────────────────────────────────────────────────────────
155
+
156
+ function arrancar() {
157
+ log('info', `${SERVER_NAME} v${SERVER_VERSION} iniciando`, {
158
+ baseDir,
159
+ authRequired: authValidator.requerida,
160
+ telemetryEnabled: telemetry.habilitada,
161
+ });
162
+
163
+ let buffer = '';
164
+
165
+ process.stdin.setEncoding('utf8');
166
+ process.stdin.on('data', (chunk) => {
167
+ buffer += chunk;
168
+
169
+ // Cada mensaje JSON-RPC termina con \n
170
+ let nlIndex;
171
+ while ((nlIndex = buffer.indexOf('\n')) >= 0) {
172
+ const linea = buffer.slice(0, nlIndex).trim();
173
+ buffer = buffer.slice(nlIndex + 1);
174
+
175
+ if (!linea) continue;
176
+
177
+ let request;
178
+ try {
179
+ request = JSON.parse(linea);
180
+ } catch (err) {
181
+ log('error', 'JSON inválido recibido', { error: err.message, linea: linea.slice(0, 100) });
182
+ process.stdout.write(errorResp(null, -32700, 'Parse error') + '\n');
183
+ continue;
184
+ }
185
+
186
+ const respuestaStr = rutear(request);
187
+ if (respuestaStr) {
188
+ process.stdout.write(respuestaStr + '\n');
189
+ }
190
+ }
191
+ });
192
+
193
+ process.stdin.on('end', () => {
194
+ log('info', 'stdin cerrado, server termina');
195
+ process.exit(0);
196
+ });
197
+
198
+ // Manejo de errores no capturados — nunca crashear silenciosamente
199
+ process.on('uncaughtException', (err) => {
200
+ log('error', 'uncaughtException', { error: err.message, stack: err.stack });
201
+ });
202
+ }
203
+
204
+ if (require.main === module) {
205
+ arrancar();
206
+ }
207
+
208
+ module.exports = {
209
+ rutear,
210
+ arrancar,
211
+ SERVER_NAME,
212
+ SERVER_VERSION,
213
+ PROTOCOL_VERSION,
214
+ };
package/bin/swl-ses.js CHANGED
@@ -336,6 +336,35 @@ function main() {
336
336
  }
337
337
  }
338
338
 
339
+ // ADR-0019 Sub-fase 2.5: Multi-target install/update/uninstall
340
+ //
341
+ // Si `--target=a,b,c` (CSV) o `--all-runtimes` está activo, iterar el comando
342
+ // por cada target. Solo aplica a comandos que aceptan `--target`. Para el resto,
343
+ // se pasa el comando tal cual.
344
+ const COMANDOS_MULTI_TARGET = ['install', 'update', 'uninstall'];
345
+ if (COMANDOS_MULTI_TARGET.includes(comando)) {
346
+ const { expandirTargets: expandir } = require('../scripts/lib/expandir-targets');
347
+ const expansion = expandir(opciones);
348
+ if (expansion.errores.length > 0) {
349
+ for (const e of expansion.errores) console.error(`[swl-ses] ${e}`);
350
+ process.exit(1);
351
+ }
352
+ const targets = expansion.targets;
353
+ if (targets.length > 1) {
354
+ ejecutarMultiTarget(comando, opciones, targets).catch(err => {
355
+ console.error(`Error en multi-target ${comando}: ${err.message}`);
356
+ if (opciones.verbose) console.error(err.stack);
357
+ process.exit(1);
358
+ });
359
+ return;
360
+ }
361
+ // Si targets.length === 1, ya viene normalizado y continuamos al flujo normal.
362
+ if (targets.length === 1) {
363
+ opciones.target = targets[0];
364
+ opciones.objetivo = targets[0];
365
+ }
366
+ }
367
+
339
368
  try {
340
369
  const modulo = require(COMANDOS[comando]);
341
370
  const resultado = modulo(opciones);
@@ -357,4 +386,49 @@ function main() {
357
386
  }
358
387
  }
359
388
 
389
+ /**
390
+ * Ejecuta un comando (install/update/uninstall) sobre múltiples targets en serie.
391
+ *
392
+ * Atomicidad por target (ADR-0019 punto 9): si un target falla, los anteriores
393
+ * quedan instalados y se reporta el error sin rollback. Los targets siguientes
394
+ * igual se intentan — el usuario decide qué hacer después.
395
+ *
396
+ * @param {string} comando - install | update | uninstall
397
+ * @param {object} opcionesBase - Opciones parseadas (se clonan por target)
398
+ * @param {string[]} targets - Lista de target IDs ≥1
399
+ */
400
+ async function ejecutarMultiTarget(comando, opcionesBase, targets) {
401
+ console.log(`\n[swl-ses] Multi-target ${comando}: ${targets.join(', ')}`);
402
+ console.log('='.repeat(60));
403
+
404
+ const resultados = [];
405
+ for (const t of targets) {
406
+ console.log(`\n[swl-ses] ▶ Target: ${t}`);
407
+ console.log('-'.repeat(60));
408
+ const opciones = { ...opcionesBase, target: t, objetivo: t };
409
+ try {
410
+ const modulo = require(COMANDOS[comando]);
411
+ const r = modulo(opciones);
412
+ if (r && typeof r.then === 'function') await r;
413
+ resultados.push({ target: t, ok: true });
414
+ } catch (err) {
415
+ console.error(`[swl-ses] ✘ Falló ${comando} para target "${t}": ${err.message}`);
416
+ if (opciones.verbose) console.error(err.stack);
417
+ resultados.push({ target: t, ok: false, error: err.message });
418
+ }
419
+ }
420
+
421
+ console.log('\n' + '='.repeat(60));
422
+ console.log(`[swl-ses] Resumen multi-target ${comando}:`);
423
+ for (const r of resultados) {
424
+ const marca = r.ok ? '✓' : '✘';
425
+ const detalle = r.error ? ` — ${r.error}` : '';
426
+ console.log(` ${marca} ${r.target}${detalle}`);
427
+ }
428
+ const fallidos = resultados.filter(r => !r.ok);
429
+ if (fallidos.length > 0) {
430
+ process.exitCode = 1;
431
+ }
432
+ }
433
+
360
434
  main();
@@ -4,20 +4,41 @@ description: Recibe el número de una fase y la implementa siguiendo el PLAN.md.
4
4
  allowed_tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
5
5
  ---
6
6
 
7
- # /swl:ejecutar-fase <n> — Ejecutar implementación de una fase
7
+ # /swl:ejecutar-fase <n> [--iterative] — Ejecutar implementación de una fase
8
8
 
9
9
  Eres el coordinador de ejecución SWL. Orquestas la implementación real del código para una fase, delegando al agente implementador-swl, verificando cada slice y manteniendo el estado del proyecto actualizado.
10
10
 
11
11
  ## Uso
12
12
 
13
13
  ```
14
- /swl:ejecutar-fase 1
15
- /swl:ejecutar-fase 2
14
+ /swl:ejecutar-fase 1 # modo default — oleadas paralelas
15
+ /swl:ejecutar-fase 2 --iterative # modo iterativo — 1 tarea, review per-task, auto-debug
16
16
  ```
17
17
 
18
+ ### Cuándo usar `--iterative` (modo opt-in)
19
+
20
+ Activar el flag `--iterative` cuando:
21
+
22
+ - La fase tiene **dependencias secuenciales fuertes** entre tareas.
23
+ - El módulo es **crítico** (dinero, permisos, datos irreversibles) y cada tarea merece revisión adversarial inmediata.
24
+ - La fase tiene **>12 tareas** y el modo paralelo arriesga acumular deuda silenciosa.
25
+ - El usuario pide explícitamente "tarea por tarea con revisión".
26
+
27
+ NO usar `--iterative` para fases pequeñas (<5 tareas) o cuando las tareas son
28
+ independientes y de bajo riesgo — el overhead per-task supera el beneficio.
29
+
18
30
  ## Paso 0 — Carga de habilidades
19
31
 
20
- Carga obligatoria:
32
+ Si `--iterative` está presente, carga:
33
+ ```
34
+ Skill("ejecutar-task-iterativo")
35
+ ```
36
+
37
+ El skill iterativo redirige el flujo: 1 tarea por iteración, contexto fresco
38
+ por implementer, review task-local con `protocolo-revision-swl`, auto-debug
39
+ en BLOCKED con `depurador-swl`, y commits atómicos estrictos por tarea.
40
+
41
+ Si NO hay `--iterative`, carga el flujo default:
21
42
  ```
22
43
  Skill("ejecutar-fase")
23
44
  ```
@@ -31,6 +52,14 @@ Luego carga habilidades específicas del stack detectado en PLAN.md o PROYECTO.m
31
52
  - Autenticación: `Skill("auth-patrones")`
32
53
  - Siempre: `Skill("manejo-errores")`
33
54
 
55
+ ### Si `--iterative` está activo
56
+
57
+ Tras cargar `ejecutar-task-iterativo`, sigue el protocolo definido ahí
58
+ (loop per-task con Pasos 1-8 internos del skill). El resto de pasos de
59
+ este comando se delegan al skill iterativo. Saltar los Pasos 2-7 de este
60
+ comando. El Paso 8 (Reporte final) y Reglas de comportamiento siguen
61
+ aplicando en ambos modos.
62
+
34
63
  ## Paso 1 — Verificación de prerrequisitos
35
64
 
36
65
  Verifica en orden:
@@ -21,6 +21,7 @@ costo estimado, herramientas más usadas y estado del presupuesto configurado.
21
21
  ```
22
22
  /swl:metricas — Resumen de métricas de la sesión
23
23
  /swl:metricas detalle — Desglose completo por herramienta y timeline
24
+ /swl:metricas fases — Progreso de fases del proyecto desde feature-list.json
24
25
  /swl:metricas historico — Abre el dashboard histórico multi-sesión (alias de /swl:dashboard)
25
26
  ```
26
27
 
@@ -246,6 +247,77 @@ EOF
246
247
 
247
248
  ---
248
249
 
250
+ ## Subcomando: `fases` — Progreso de fases del proyecto
251
+
252
+ Si el usuario invoca `/swl:metricas fases`, muestra el estado actual de las
253
+ fases del proyecto leyendo el JSON derivado de `HOJA-RUTA.md`.
254
+
255
+ ### Generación y lectura del estado
256
+
257
+ ```bash
258
+ # Regenerar el JSON desde HOJA-RUTA.md (fuente de verdad markdown)
259
+ node scripts/derivar-feature-list.js
260
+
261
+ # Leer el JSON generado en .planning/feature-list.json
262
+ cat .planning/feature-list.json | python3 -c "
263
+ import json, sys
264
+ data = json.load(sys.stdin)
265
+ t = data['totales']
266
+ total = t['fases'] or 1
267
+ pct = (t['completadas'] / total) * 100
268
+
269
+ print(f\"\\nSWL — Progreso de fases del proyecto\")
270
+ print('─' * 60)
271
+ print(f\" Total de fases: {t['fases']}\")
272
+ print(f\" Completadas: {t['completadas']} ({pct:.0f}%)\")
273
+ print(f\" En progreso: {t['en_progreso']}\")
274
+ print(f\" Pendientes: {t['pendientes']}\")
275
+ print(f\" Bloqueadas: {t['bloqueadas']}\")
276
+ print(f\" Spec lista: {t['spec_listas']}\")
277
+ print('─' * 60)
278
+ print()
279
+ for f in data['fases']:
280
+ estado = f['estado']
281
+ marca = {'completado': '✓', 'en_progreso': '◐', 'pendiente': '○',
282
+ 'bloqueado': '✗', 'spec_listo': '◔'}.get(estado, '?')
283
+ nombre = f['nombre'][:48]
284
+ art = f.get('artefactos') or {}
285
+ plan = ' [PLAN]' if art.get('plan_md') else ''
286
+ resumen = ' [RESUMEN]' if art.get('resumen_md') else ''
287
+ print(f\" {marca} Fase {f['numero']:>2}: {nombre:<50}{plan}{resumen}\")
288
+ print()
289
+ print(f\" Fuente: {data['fuente']} · Generado: {data['generado_en']}\")
290
+ "
291
+ ```
292
+
293
+ ### Lo que se reporta
294
+
295
+ - **Totales**: cuántas fases hay y cuántas en cada estado canónico (`completado`,
296
+ `en_progreso`, `pendiente`, `bloqueado`, `spec_listo`).
297
+ - **Lista de fases** con marca visual de estado y artefactos presentes
298
+ (`[PLAN]` si existe `0N-PLAN.md`, `[RESUMEN]` si existe `0N-RESUMEN.md`).
299
+ - **Timestamp** de generación para detectar staleness.
300
+
301
+ ### Cuándo usar
302
+
303
+ - Antes de retomar trabajo en una fase, para ver el estado actualizado.
304
+ - Al cerrar una sesión productiva, para verificar progreso vs HOJA-RUTA.md.
305
+ - Para auditoría programática (consumir el JSON desde un hook o dashboard externo).
306
+
307
+ ### Detección de drift
308
+
309
+ Si `HOJA-RUTA.md` se modifica pero `feature-list.json` no se regenera, los datos
310
+ quedan desactualizados. Para verificar:
311
+
312
+ ```bash
313
+ node scripts/derivar-feature-list.js --check
314
+ # exit 0 = sincronizado, exit 2 = drift detectado
315
+ ```
316
+
317
+ `/swl:metricas fases` siempre regenera primero, así que ve estado fresco.
318
+
319
+ ---
320
+
249
321
  ## Subcomando: `historico`
250
322
 
251
323
  Si el usuario invoca `/swl:metricas historico`, redirigir inmediatamente a: