@saulwade/swl-ses 1.5.1 → 1.6.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.
Files changed (155) hide show
  1. package/CLAUDE.md +225 -209
  2. package/README.md +578 -561
  3. package/agentes/arquitecto-swl.md +33 -1
  4. package/agentes/nemesis-auditor-swl.md +59 -19
  5. package/bin/swl-mcp-server.js +214 -214
  6. package/bin/swl-ses.js +49 -7
  7. package/comandos/swl/.evolved.json +22 -22
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/nemesis.md +230 -56
  10. package/gateway/lib/event-channel.js +191 -191
  11. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  12. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  13. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  14. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  15. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  16. package/habilidades/ejecutar-task-iterativo/SKILL.md +278 -278
  17. package/habilidades/eval-framework/SKILL.md +212 -212
  18. package/habilidades/feynman-auditor-swl/SKILL.md +123 -123
  19. package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -108
  20. package/habilidades/harness-claude-code/SKILL.md +299 -299
  21. package/habilidades/infra-github-actions/SKILL.md +166 -166
  22. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  23. package/habilidades/manejo-errores/.evolved.json +8 -8
  24. package/habilidades/meta-skills-estandar/SKILL.md +207 -4
  25. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  26. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  27. package/habilidades/nemesis-evaluacion-json/SKILL.md +266 -0
  28. package/habilidades/nemesis-redistribuir/SKILL.md +341 -0
  29. package/habilidades/node-experto/SKILL.md +94 -4
  30. package/habilidades/patrones-python/SKILL.md +229 -229
  31. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  32. package/habilidades/planear-fase/SKILL.md +319 -319
  33. package/habilidades/protocolo-revision-swl/SKILL.md +350 -276
  34. package/habilidades/release-semver/.evolved.json +8 -8
  35. package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -166
  36. package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -147
  37. package/habilidades/tdd-workflow/SKILL.md +121 -4
  38. package/habilidades/testing-python/SKILL.md +340 -340
  39. package/habilidades/web-fetcher-routing/SKILL.md +75 -75
  40. package/hooks/check-update.js +31 -3
  41. package/hooks/claudemd-bloat-detector.js +161 -161
  42. package/hooks/extraccion-aprendizajes.js +11 -0
  43. package/hooks/lib/agent-routing.js +107 -107
  44. package/hooks/lib/auto-consolidator.js +335 -335
  45. package/hooks/lib/error-classifier.js +308 -308
  46. package/hooks/lib/merkle-audit.js +96 -96
  47. package/hooks/lib/provenance-tracker.js +191 -191
  48. package/hooks/lib/rate-limit-tracker.js +253 -253
  49. package/hooks/lib/resource-quota.js +122 -122
  50. package/hooks/lib/retry-jitter.js +165 -165
  51. package/hooks/lib/security-net.js +201 -201
  52. package/hooks/lib/skill-auditor.js +588 -588
  53. package/hooks/lib/sync-status.js +228 -228
  54. package/hooks/lib/taint-tracker.js +107 -107
  55. package/hooks/lib/text-similarity.js +241 -241
  56. package/hooks/lib/toon-compressor.js +245 -245
  57. package/hooks/registro-turnos.js +209 -209
  58. package/hooks/sugerir-regenerar-inventario.js +170 -170
  59. package/hooks/validar-formato-post-subagente.js +140 -140
  60. package/hooks/validar-memoria-hook.js +218 -218
  61. package/instintos/prompt-appendices.yaml +57 -57
  62. package/manifiestos/agent-output-schemas.json +57 -57
  63. package/manifiestos/modulos.json +1324 -1321
  64. package/manifiestos/skills-lock.json +1142 -1114
  65. package/package.json +5 -4
  66. package/plantillas/auditor-veto-template.md +105 -105
  67. package/plantillas/github-workflows/README.md +47 -47
  68. package/plantillas/github-workflows/release-please.yml +44 -44
  69. package/plantillas/github-workflows/swl-ci.yml +107 -107
  70. package/plantillas/github-workflows/swl-security.yml +51 -51
  71. package/plugin.json +355 -351
  72. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  73. package/reglas/arreglar-al-detectar.md +147 -147
  74. package/reglas/fragmentos-compartidos.md +152 -152
  75. package/reglas/harness-claude-code.md +213 -213
  76. package/reglas/registro-componentes-nuevos.md +192 -0
  77. package/reglas/usar-context7.md +226 -226
  78. package/schemas/diary-entry.schema.json +80 -80
  79. package/scripts/actualizar.js +110 -1
  80. package/scripts/audit-tools/audit-history.js +330 -330
  81. package/scripts/audit-tools/bundle-tracker.js +290 -290
  82. package/scripts/audit-tools/canary-monitor.js +352 -352
  83. package/scripts/audit-tools/code-profiler.js +605 -605
  84. package/scripts/audit-tools/dep-doctor.js +320 -320
  85. package/scripts/audit-tools/env-validator.js +206 -206
  86. package/scripts/audit-tools/lib/fs-walk.js +48 -48
  87. package/scripts/audit-tools/lib/output.js +23 -23
  88. package/scripts/audit-tools/migration-checker.js +392 -392
  89. package/scripts/audit-tools/pentest-scanner.js +1436 -1436
  90. package/scripts/benchmark-memoria.js +167 -167
  91. package/scripts/configurar-branch-protection.js +418 -418
  92. package/scripts/derivar-feature-list.js +489 -489
  93. package/scripts/desinstalar.js +105 -24
  94. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  95. package/scripts/doctor.js +27 -0
  96. package/scripts/field-report.js +199 -199
  97. package/scripts/generar-checklists-consolidados.js +273 -273
  98. package/scripts/generar-inventario.js +420 -420
  99. package/scripts/generar-matriz-lenguajes.js +271 -271
  100. package/scripts/instalador.js +55 -4
  101. package/scripts/lib/artefactos-python.js +43 -43
  102. package/scripts/lib/benchmark-metrics.js +160 -160
  103. package/scripts/lib/budget-enforcer.js +252 -252
  104. package/scripts/lib/configurar-ci.js +380 -380
  105. package/scripts/lib/contadores-inventario.js +217 -217
  106. package/scripts/lib/detectar-stack-detallado.js +307 -307
  107. package/scripts/lib/diary-entry.js +234 -234
  108. package/scripts/lib/eval-metrics-store.js +218 -218
  109. package/scripts/lib/eval-quality.js +171 -171
  110. package/scripts/lib/eval-schemas.js +144 -144
  111. package/scripts/lib/eval-self-correct.js +106 -106
  112. package/scripts/lib/eval-validator.js +185 -185
  113. package/scripts/lib/expandir-targets.js +71 -71
  114. package/scripts/lib/jaccard-similarity.js +98 -98
  115. package/scripts/lib/longmemeval-runner.js +125 -125
  116. package/scripts/lib/mcp_config.py +127 -0
  117. package/scripts/lib/npm-version.js +261 -261
  118. package/scripts/lib/paquetes-conocidos.js +50 -50
  119. package/scripts/lib/parsear-opciones.js +3 -0
  120. package/scripts/lib/prompt-builder.js +264 -264
  121. package/scripts/lib/rrf-fusion.js +175 -175
  122. package/scripts/lib/scoring-instintos.js +277 -277
  123. package/scripts/lib/semantic-search.js +252 -252
  124. package/scripts/lib/toml-merge.js +204 -204
  125. package/scripts/lib/transformadores/codex.js +375 -375
  126. package/scripts/lib/transformadores/cursor.js +359 -359
  127. package/scripts/lib/ui.js +148 -22
  128. package/scripts/limpiar-artefactos-python.js +131 -131
  129. package/scripts/mcp-orchestrator.py +8 -18
  130. package/scripts/mcp-pool-manager.py +12 -23
  131. package/scripts/mcp-server/README.md +170 -170
  132. package/scripts/mcp-server/auth.js +105 -105
  133. package/scripts/mcp-server/cache.js +106 -106
  134. package/scripts/mcp-server/telemetry.js +78 -78
  135. package/scripts/migrar-csv-a-array.js +168 -168
  136. package/scripts/migrar-fase-dominio.js +201 -201
  137. package/scripts/publicar.js +511 -511
  138. package/scripts/run-eval.js +141 -141
  139. package/scripts/tui/componentes/selector-multi.js +189 -0
  140. package/scripts/tui/componentes/selector-unico.js +158 -0
  141. package/scripts/tui/ejecutores.js +375 -0
  142. package/scripts/tui/index.js +162 -0
  143. package/scripts/tui/lib/colores.js +129 -0
  144. package/scripts/tui/lib/render.js +264 -0
  145. package/scripts/tui/lib/teclas.js +113 -0
  146. package/scripts/tui/pantallas/inspect.js +173 -0
  147. package/scripts/tui/pantallas/install-wizard.js +334 -0
  148. package/scripts/tui/pantallas/menu-principal.js +52 -0
  149. package/scripts/tui/pantallas/progreso.js +274 -0
  150. package/scripts/tui/pantallas/resumen.js +132 -0
  151. package/scripts/tui/pantallas/uninstall-wizard.js +208 -0
  152. package/scripts/tui/pantallas/update-wizard.js +232 -0
  153. package/scripts/tui/pantallas/welcome.js +187 -0
  154. package/scripts/validar-userland-vacio.js +110 -110
  155. package/scripts/verificar-docs-vs-codigo.js +425 -0
@@ -1,489 +1,489 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * derivar-feature-list.js — Sub-fase 5 (Opción B desde análisis cc-sdd)
6
- *
7
- * Genera `.planning/feature-list.json` como **state machine machine-readable**
8
- * derivado del Markdown libre de `.planning/HOJA-RUTA.md` y los PLAN.md/
9
- * RESUMEN.md de cada fase en `.planning/fases/`.
10
- *
11
- * El HOJA-RUTA.md es la fuente de verdad human-friendly; este script produce
12
- * una proyección JSON consumible por:
13
- * - `/swl:dashboard` (vista de progreso de fases)
14
- * - `/swl:metricas` (cálculo de velocity, lead time, completitud)
15
- * - Hooks de observabilidad
16
- * - Auditoría externa programática
17
- *
18
- * NO reemplaza HOJA-RUTA.md. Si entran en conflicto, HOJA-RUTA.md gana —
19
- * este JSON es una derivación cache, regenerable.
20
- *
21
- * Inspirado en `feature_list.json` de harness-sdd-main, adaptado al modelo
22
- * por-fase de swl-ses (no por-feature).
23
- *
24
- * Uso:
25
- * node scripts/derivar-feature-list.js
26
- * node scripts/derivar-feature-list.js --out custom/path.json
27
- * node scripts/derivar-feature-list.js --check # exit 1 si JSON está stale
28
- * node scripts/derivar-feature-list.js --verbose
29
- *
30
- * Exit codes:
31
- * 0 OK / generado / sincronizado
32
- * 1 HOJA-RUTA.md ausente o .planning/ no inicializado
33
- * 2 --check detectó drift entre HOJA-RUTA.md y feature-list.json
34
- * 3 Error de parsing
35
- */
36
-
37
- const fs = require('fs');
38
- const path = require('path');
39
-
40
- const CWD = process.cwd();
41
- const PLANNING_DIR = path.join(CWD, '.planning');
42
- const HOJA_RUTA = path.join(PLANNING_DIR, 'HOJA-RUTA.md');
43
- const FASES_DIR = path.join(PLANNING_DIR, 'fases');
44
- const DEFAULT_OUT = path.join(PLANNING_DIR, 'feature-list.json');
45
-
46
- const ESTADOS_VALIDOS = new Set([
47
- 'pendiente',
48
- 'en_progreso',
49
- 'spec_listo',
50
- 'completado',
51
- 'bloqueado',
52
- ]);
53
-
54
- // ─── Parsing del HOJA-RUTA.md ────────────────────────────────────────────────
55
-
56
- /**
57
- * Extrae fases del HOJA-RUTA.md.
58
- *
59
- * Reconoce dos formatos:
60
- *
61
- * 1. Tabla resumen al inicio:
62
- * | Fase | Nombre | Objetivo | Duración estimada | Estado |
63
- * | 1 | Bootstrap | ... | 2 semanas | Completado |
64
- *
65
- * 2. Secciones detalladas:
66
- * ## Fase N: Nombre
67
- * **Objetivo**: ...
68
- * **Estado**: Pendiente | En progreso | Completado
69
- * **Fecha inicio**: YYYY-MM-DD
70
- * **Fecha fin real**: YYYY-MM-DD
71
- *
72
- * Si solo existe la tabla, se usa esa. Si existen ambos, las secciones
73
- * detalladas ganan (más metadata).
74
- */
75
- function parsearHojaRuta(contenido) {
76
- const fases = new Map(); // numero → fase
77
-
78
- // Parsear tabla resumen
79
- parsearTablaResumen(contenido, fases);
80
-
81
- // Parsear secciones detalladas (sobreescriben con más detalle)
82
- parsearSecciones(contenido, fases);
83
-
84
- return Array.from(fases.values()).sort((a, b) => a.numero - b.numero);
85
- }
86
-
87
- function parsearTablaResumen(contenido, fases) {
88
- // Detecta tabla cuyo encabezado contiene "Fase" o "Phase" + "Nombre" + "Estado"
89
- const lineas = contenido.split('\n');
90
- let enTabla = false;
91
- let idxFase = -1;
92
- let idxNombre = -1;
93
- let idxObjetivo = -1;
94
- let idxEstado = -1;
95
-
96
- for (const linea of lineas) {
97
- const trim = linea.trim();
98
- if (!trim.startsWith('|')) {
99
- if (enTabla && trim === '') enTabla = false;
100
- continue;
101
- }
102
-
103
- const celdas = trim.split('|').map(c => c.trim()).filter(c => c !== '');
104
-
105
- // Detectar header
106
- if (idxFase === -1) {
107
- const lowered = celdas.map(c => c.toLowerCase());
108
- const fa = lowered.findIndex(c => c === 'fase' || c === 'phase' || c === '#');
109
- const no = lowered.findIndex(c => c === 'nombre' || c === 'name');
110
- const ob = lowered.findIndex(c => c === 'objetivo' || c === 'objective');
111
- const es = lowered.findIndex(c => c === 'estado' || c === 'status');
112
- if (fa !== -1 && no !== -1 && es !== -1) {
113
- idxFase = fa;
114
- idxNombre = no;
115
- idxObjetivo = ob;
116
- idxEstado = es;
117
- enTabla = true;
118
- }
119
- continue;
120
- }
121
-
122
- // Línea separadora (|---|---|)
123
- if (celdas.every(c => /^-+$/.test(c.replace(/:/g, '')))) continue;
124
-
125
- if (!enTabla) continue;
126
-
127
- const numStr = celdas[idxFase];
128
- const numero = parseInt(numStr, 10);
129
- if (!Number.isFinite(numero)) continue;
130
-
131
- fases.set(numero, {
132
- numero,
133
- nombre: celdas[idxNombre] || `Fase ${numero}`,
134
- objetivo: idxObjetivo !== -1 ? (celdas[idxObjetivo] || null) : null,
135
- estado: normalizarEstado(celdas[idxEstado]),
136
- estado_raw: celdas[idxEstado],
137
- fecha_inicio: null,
138
- fecha_fin_estimada: null,
139
- fecha_fin_real: null,
140
- entregables: [],
141
- criterios_verificacion: [],
142
- fuente: 'tabla-resumen',
143
- });
144
- }
145
- }
146
-
147
- function parsearSecciones(contenido, fases) {
148
- // ## Fase N: Nombre o ## Fase N — Nombre
149
- const reFase = /^##\s+Fase\s+(\d+)\s*[:\-—–]\s*(.+?)$/gm;
150
- const matches = [...contenido.matchAll(reFase)];
151
-
152
- for (let i = 0; i < matches.length; i++) {
153
- const m = matches[i];
154
- const numero = parseInt(m[1], 10);
155
- const nombre = m[2].trim();
156
- const inicio = m.index + m[0].length;
157
- const fin = i + 1 < matches.length ? matches[i + 1].index : contenido.length;
158
- const seccion = contenido.slice(inicio, fin);
159
-
160
- const existente = fases.get(numero) || {
161
- numero,
162
- nombre,
163
- objetivo: null,
164
- estado: 'pendiente',
165
- estado_raw: 'Pendiente',
166
- fecha_inicio: null,
167
- fecha_fin_estimada: null,
168
- fecha_fin_real: null,
169
- entregables: [],
170
- criterios_verificacion: [],
171
- fuente: 'seccion-detallada',
172
- };
173
-
174
- existente.nombre = nombre || existente.nombre;
175
- existente.fuente = 'seccion-detallada';
176
-
177
- const objetivo = extraerCampo(seccion, 'Objetivo');
178
- if (objetivo) existente.objetivo = objetivo;
179
-
180
- const estadoRaw = extraerCampo(seccion, 'Estado');
181
- if (estadoRaw) {
182
- existente.estado = normalizarEstado(estadoRaw);
183
- existente.estado_raw = estadoRaw;
184
- }
185
-
186
- const fIni = extraerCampo(seccion, 'Fecha inicio');
187
- if (fIni && esFecha(fIni)) existente.fecha_inicio = fIni;
188
-
189
- const fFinEst = extraerCampo(seccion, 'Fecha fin estimada');
190
- if (fFinEst && esFecha(fFinEst)) existente.fecha_fin_estimada = fFinEst;
191
-
192
- const fFinReal = extraerCampo(seccion, 'Fecha fin real');
193
- if (fFinReal && esFecha(fFinReal)) existente.fecha_fin_real = fFinReal;
194
-
195
- // Entregables (subtabla)
196
- existente.entregables = parsearTablaEntregables(seccion);
197
-
198
- // Criterios de verificación (checklist)
199
- existente.criterios_verificacion = parsearChecklistCriterios(seccion);
200
-
201
- fases.set(numero, existente);
202
- }
203
- }
204
-
205
- function extraerCampo(seccion, etiqueta) {
206
- const re = new RegExp(`^\\s*[\\*\\-]?\\s*\\*\\*${etiqueta}\\*\\*\\s*:\\s*(.+?)$`, 'mi');
207
- const m = seccion.match(re);
208
- if (!m) return null;
209
- const valor = m[1].trim();
210
- if (valor === '—' || valor === '-' || valor === '' || valor.toLowerCase().startsWith('[')) {
211
- return null;
212
- }
213
- return valor;
214
- }
215
-
216
- function parsearTablaEntregables(seccion) {
217
- const lineas = seccion.split('\n');
218
- const entregables = [];
219
- let enTabla = false;
220
- let idxNum = -1, idxNombre = -1, idxDesc = -1, idxReqs = -1, idxEstado = -1;
221
-
222
- for (const linea of lineas) {
223
- const trim = linea.trim();
224
- if (!trim.startsWith('|')) {
225
- if (enTabla && trim === '') {
226
- enTabla = false;
227
- idxNum = -1;
228
- }
229
- continue;
230
- }
231
- const celdas = trim.split('|').map(c => c.trim()).filter(c => c !== '');
232
-
233
- if (idxNum === -1) {
234
- const low = celdas.map(c => c.toLowerCase());
235
- const n = low.findIndex(c => c === '#' || c === 'id');
236
- const no = low.findIndex(c => c === 'entregable' || c === 'nombre');
237
- const de = low.findIndex(c => c === 'descripción' || c === 'descripcion');
238
- const re = low.findIndex(c => c.includes('requisitos') || c.includes('cubiertos'));
239
- const es = low.findIndex(c => c === 'estado');
240
- if (n !== -1 && no !== -1 && es !== -1) {
241
- idxNum = n; idxNombre = no; idxDesc = de; idxReqs = re; idxEstado = es;
242
- enTabla = true;
243
- }
244
- continue;
245
- }
246
-
247
- if (celdas.every(c => /^-+$/.test(c.replace(/:/g, '')))) continue;
248
- if (!enTabla) continue;
249
-
250
- const numEntregable = celdas[idxNum];
251
- if (!numEntregable) continue;
252
-
253
- entregables.push({
254
- id: numEntregable,
255
- nombre: celdas[idxNombre] || null,
256
- descripcion: idxDesc !== -1 ? (celdas[idxDesc] || null) : null,
257
- requisitos: idxReqs !== -1
258
- ? (celdas[idxReqs] || '').split(',').map(s => s.trim()).filter(Boolean)
259
- : [],
260
- estado: normalizarEstado(celdas[idxEstado]),
261
- });
262
- }
263
-
264
- return entregables;
265
- }
266
-
267
- function parsearChecklistCriterios(seccion) {
268
- // Captura solo el bloque "Criterios de verificación" si existe
269
- const idx = seccion.toLowerCase().indexOf('criterios de verificación');
270
- if (idx === -1) return [];
271
- const subseccion = seccion.slice(idx);
272
- // Cortar en la siguiente sub-sección o el final
273
- const finIdx = subseccion.search(/\n###?\s+/m);
274
- const recorte = finIdx === -1 ? subseccion : subseccion.slice(0, finIdx);
275
-
276
- const criterios = [];
277
- const re = /^\s*-\s*\[(x| )\]\s+(.+?)$/gim;
278
- let m;
279
- while ((m = re.exec(recorte)) !== null) {
280
- criterios.push({
281
- cumplido: m[1].toLowerCase() === 'x',
282
- descripcion: m[2].trim(),
283
- });
284
- }
285
- return criterios;
286
- }
287
-
288
- function normalizarEstado(estadoRaw) {
289
- if (!estadoRaw) return 'pendiente';
290
- const lower = estadoRaw.toLowerCase().trim();
291
-
292
- if (lower.includes('complet')) return 'completado';
293
- if (lower.includes('done') || lower.includes('hecho')) return 'completado';
294
- if (lower.includes('progreso') || lower.includes('progress') || lower.includes('curso')) {
295
- return 'en_progreso';
296
- }
297
- if (lower.includes('spec') && lower.includes('listo')) return 'spec_listo';
298
- if (lower.includes('bloque') || lower.includes('block')) return 'bloqueado';
299
- if (lower.includes('pendiente') || lower.includes('pending')) return 'pendiente';
300
-
301
- return 'pendiente'; // default conservador
302
- }
303
-
304
- function esFecha(s) {
305
- return /^\d{4}-\d{2}-\d{2}/.test(s);
306
- }
307
-
308
- // ─── Enriquecimiento desde .planning/fases/ ──────────────────────────────────
309
-
310
- /**
311
- * Para cada fase, intenta enriquecer con info de:
312
- * .planning/fases/0N-PLAN.md → existe + estado
313
- * .planning/fases/0N-RESUMEN.md → completada
314
- * .planning/fases/0N-CONTEXTO.md → discutida
315
- */
316
- function enriquecerDesdeFases(fases, opciones = {}) {
317
- // CWD dinámico — recalcula al llamar, no al require, para que los tests con
318
- // process.chdir() funcionen sin requerir reload del módulo.
319
- const cwd = opciones.cwd || process.cwd();
320
- const fasesDir = path.join(cwd, '.planning', 'fases');
321
- if (!fs.existsSync(fasesDir)) return fases;
322
-
323
- const archivos = fs.readdirSync(fasesDir);
324
-
325
- for (const fase of fases) {
326
- const num = fase.numero.toString().padStart(2, '0');
327
-
328
- fase.artefactos = {
329
- contexto_md: archivos.includes(`${num}-CONTEXTO.md`)
330
- ? `.planning/fases/${num}-CONTEXTO.md` : null,
331
- plan_md: archivos.includes(`${num}-PLAN.md`)
332
- ? `.planning/fases/${num}-PLAN.md` : null,
333
- resumen_md: archivos.includes(`${num}-RESUMEN.md`)
334
- ? `.planning/fases/${num}-RESUMEN.md` : null,
335
- };
336
-
337
- // Si tiene PLAN.md, intentar leer su frontmatter `estado:` para refinar
338
- if (fase.artefactos.plan_md) {
339
- const planPath = path.join(cwd, fase.artefactos.plan_md);
340
- try {
341
- const planContent = fs.readFileSync(planPath, 'utf-8');
342
- const frontMatch = planContent.match(/^---\s*\n([\s\S]*?)\n---/);
343
- if (frontMatch) {
344
- const fm = frontMatch[1];
345
- const estadoPlan = (fm.match(/^estado:\s*(.+)$/m) || [])[1];
346
- if (estadoPlan) {
347
- const estadoPlanLow = estadoPlan.trim().toLowerCase();
348
- fase.plan_estado = estadoPlanLow;
349
- // Si el PLAN está aprobado pero el roadmap dice "pendiente",
350
- // refinar a "en_progreso" porque ya pasó la planeación.
351
- if (estadoPlanLow === 'aprobado' && fase.estado === 'pendiente') {
352
- fase.estado = 'en_progreso';
353
- }
354
- }
355
- }
356
- } catch { /* no bloquear si el PLAN tiene formato no esperado */ }
357
- }
358
-
359
- // Si tiene RESUMEN.md, asumir completada (a menos que HOJA-RUTA diga otra cosa explícita)
360
- if (fase.artefactos.resumen_md && fase.estado !== 'bloqueado') {
361
- fase.estado = 'completado';
362
- }
363
- }
364
-
365
- return fases;
366
- }
367
-
368
- // ─── Generación del JSON canónico ────────────────────────────────────────────
369
-
370
- function generarFeatureList(fases) {
371
- const ahora = new Date().toISOString();
372
- const totales = {
373
- fases: fases.length,
374
- completadas: fases.filter(f => f.estado === 'completado').length,
375
- en_progreso: fases.filter(f => f.estado === 'en_progreso').length,
376
- pendientes: fases.filter(f => f.estado === 'pendiente').length,
377
- bloqueadas: fases.filter(f => f.estado === 'bloqueado').length,
378
- spec_listas: fases.filter(f => f.estado === 'spec_listo').length,
379
- };
380
-
381
- return {
382
- schema: 'swl-feature-list/v1',
383
- generado_en: ahora,
384
- fuente: '.planning/HOJA-RUTA.md',
385
- nota: 'Derivado automáticamente. HOJA-RUTA.md es la fuente de verdad. Regenerar con `node scripts/derivar-feature-list.js`.',
386
- totales,
387
- fases,
388
- };
389
- }
390
-
391
- // ─── Comparación para --check ────────────────────────────────────────────────
392
-
393
- function compararConDisco(generado, rutaOut) {
394
- if (!fs.existsSync(rutaOut)) {
395
- return { iguales: false, razon: `${rutaOut} no existe` };
396
- }
397
- let actual;
398
- try {
399
- actual = JSON.parse(fs.readFileSync(rutaOut, 'utf-8'));
400
- } catch (err) {
401
- return { iguales: false, razon: `JSON existente está corrupto: ${err.message}` };
402
- }
403
-
404
- // Comparar ignorando `generado_en` (timestamp cambia siempre)
405
- const a = { ...generado, generado_en: '_' };
406
- const b = { ...actual, generado_en: '_' };
407
-
408
- const aStr = JSON.stringify(a);
409
- const bStr = JSON.stringify(b);
410
-
411
- if (aStr === bStr) return { iguales: true };
412
- return {
413
- iguales: false,
414
- razon: 'Contenido difiere. Regenerar con `node scripts/derivar-feature-list.js`.',
415
- };
416
- }
417
-
418
- // ─── Main ────────────────────────────────────────────────────────────────────
419
-
420
- function parsearArgs(argv) {
421
- const args = { out: DEFAULT_OUT, check: false, verbose: false };
422
- for (let i = 2; i < argv.length; i++) {
423
- const a = argv[i];
424
- if (a === '--check' || a === '-c') args.check = true;
425
- else if (a === '--verbose' || a === '-v') args.verbose = true;
426
- else if (a === '--out' || a === '-o') args.out = argv[++i];
427
- else if (a === '--help' || a === '-h') {
428
- console.log('Uso: derivar-feature-list.js [--out <path>] [--check] [--verbose]');
429
- process.exit(0);
430
- }
431
- }
432
- return args;
433
- }
434
-
435
- function main() {
436
- const args = parsearArgs(process.argv);
437
-
438
- if (!fs.existsSync(HOJA_RUTA)) {
439
- console.error('[derivar-feature-list] .planning/HOJA-RUTA.md no encontrado.');
440
- console.error(' Inicializa con `/swl:nuevo-proyecto` o `/swl:adoptar-proyecto` primero.');
441
- process.exit(1);
442
- }
443
-
444
- const contenido = fs.readFileSync(HOJA_RUTA, 'utf-8');
445
- let fases;
446
- try {
447
- fases = parsearHojaRuta(contenido);
448
- fases = enriquecerDesdeFases(fases);
449
- } catch (err) {
450
- console.error(`[derivar-feature-list] Error de parsing: ${err.message}`);
451
- process.exit(3);
452
- }
453
-
454
- if (fases.length === 0 && args.verbose) {
455
- console.log('[derivar-feature-list] No se detectaron fases en HOJA-RUTA.md.');
456
- console.log(' El JSON se genera vacío (válido para proyectos recién inicializados).');
457
- }
458
-
459
- const generado = generarFeatureList(fases);
460
-
461
- if (args.check) {
462
- const cmp = compararConDisco(generado, args.out);
463
- if (cmp.iguales) {
464
- if (args.verbose) console.log(`[derivar-feature-list] OK — ${args.out} está sincronizado.`);
465
- process.exit(0);
466
- }
467
- console.error(`[derivar-feature-list] DRIFT: ${cmp.razon}`);
468
- process.exit(2);
469
- }
470
-
471
- fs.writeFileSync(args.out, JSON.stringify(generado, null, 2), 'utf-8');
472
- if (args.verbose) {
473
- console.log(`[derivar-feature-list] Generado ${args.out}`);
474
- console.log(` ${generado.totales.fases} fases | ${generado.totales.completadas} completadas | ${generado.totales.en_progreso} en progreso | ${generado.totales.pendientes} pendientes`);
475
- } else {
476
- console.log(`[derivar-feature-list] ${args.out} actualizado (${generado.totales.fases} fases)`);
477
- }
478
- }
479
-
480
- if (require.main === module) {
481
- main();
482
- }
483
-
484
- module.exports = {
485
- parsearHojaRuta,
486
- enriquecerDesdeFases,
487
- generarFeatureList,
488
- normalizarEstado,
489
- };
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * derivar-feature-list.js — Sub-fase 5 (Opción B desde análisis cc-sdd)
6
+ *
7
+ * Genera `.planning/feature-list.json` como **state machine machine-readable**
8
+ * derivado del Markdown libre de `.planning/HOJA-RUTA.md` y los PLAN.md/
9
+ * RESUMEN.md de cada fase en `.planning/fases/`.
10
+ *
11
+ * El HOJA-RUTA.md es la fuente de verdad human-friendly; este script produce
12
+ * una proyección JSON consumible por:
13
+ * - `/swl:dashboard` (vista de progreso de fases)
14
+ * - `/swl:metricas` (cálculo de velocity, lead time, completitud)
15
+ * - Hooks de observabilidad
16
+ * - Auditoría externa programática
17
+ *
18
+ * NO reemplaza HOJA-RUTA.md. Si entran en conflicto, HOJA-RUTA.md gana —
19
+ * este JSON es una derivación cache, regenerable.
20
+ *
21
+ * Inspirado en `feature_list.json` de harness-sdd-main, adaptado al modelo
22
+ * por-fase de swl-ses (no por-feature).
23
+ *
24
+ * Uso:
25
+ * node scripts/derivar-feature-list.js
26
+ * node scripts/derivar-feature-list.js --out custom/path.json
27
+ * node scripts/derivar-feature-list.js --check # exit 1 si JSON está stale
28
+ * node scripts/derivar-feature-list.js --verbose
29
+ *
30
+ * Exit codes:
31
+ * 0 OK / generado / sincronizado
32
+ * 1 HOJA-RUTA.md ausente o .planning/ no inicializado
33
+ * 2 --check detectó drift entre HOJA-RUTA.md y feature-list.json
34
+ * 3 Error de parsing
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+
40
+ const CWD = process.cwd();
41
+ const PLANNING_DIR = path.join(CWD, '.planning');
42
+ const HOJA_RUTA = path.join(PLANNING_DIR, 'HOJA-RUTA.md');
43
+ const FASES_DIR = path.join(PLANNING_DIR, 'fases');
44
+ const DEFAULT_OUT = path.join(PLANNING_DIR, 'feature-list.json');
45
+
46
+ const ESTADOS_VALIDOS = new Set([
47
+ 'pendiente',
48
+ 'en_progreso',
49
+ 'spec_listo',
50
+ 'completado',
51
+ 'bloqueado',
52
+ ]);
53
+
54
+ // ─── Parsing del HOJA-RUTA.md ────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Extrae fases del HOJA-RUTA.md.
58
+ *
59
+ * Reconoce dos formatos:
60
+ *
61
+ * 1. Tabla resumen al inicio:
62
+ * | Fase | Nombre | Objetivo | Duración estimada | Estado |
63
+ * | 1 | Bootstrap | ... | 2 semanas | Completado |
64
+ *
65
+ * 2. Secciones detalladas:
66
+ * ## Fase N: Nombre
67
+ * **Objetivo**: ...
68
+ * **Estado**: Pendiente | En progreso | Completado
69
+ * **Fecha inicio**: YYYY-MM-DD
70
+ * **Fecha fin real**: YYYY-MM-DD
71
+ *
72
+ * Si solo existe la tabla, se usa esa. Si existen ambos, las secciones
73
+ * detalladas ganan (más metadata).
74
+ */
75
+ function parsearHojaRuta(contenido) {
76
+ const fases = new Map(); // numero → fase
77
+
78
+ // Parsear tabla resumen
79
+ parsearTablaResumen(contenido, fases);
80
+
81
+ // Parsear secciones detalladas (sobreescriben con más detalle)
82
+ parsearSecciones(contenido, fases);
83
+
84
+ return Array.from(fases.values()).sort((a, b) => a.numero - b.numero);
85
+ }
86
+
87
+ function parsearTablaResumen(contenido, fases) {
88
+ // Detecta tabla cuyo encabezado contiene "Fase" o "Phase" + "Nombre" + "Estado"
89
+ const lineas = contenido.split('\n');
90
+ let enTabla = false;
91
+ let idxFase = -1;
92
+ let idxNombre = -1;
93
+ let idxObjetivo = -1;
94
+ let idxEstado = -1;
95
+
96
+ for (const linea of lineas) {
97
+ const trim = linea.trim();
98
+ if (!trim.startsWith('|')) {
99
+ if (enTabla && trim === '') enTabla = false;
100
+ continue;
101
+ }
102
+
103
+ const celdas = trim.split('|').map(c => c.trim()).filter(c => c !== '');
104
+
105
+ // Detectar header
106
+ if (idxFase === -1) {
107
+ const lowered = celdas.map(c => c.toLowerCase());
108
+ const fa = lowered.findIndex(c => c === 'fase' || c === 'phase' || c === '#');
109
+ const no = lowered.findIndex(c => c === 'nombre' || c === 'name');
110
+ const ob = lowered.findIndex(c => c === 'objetivo' || c === 'objective');
111
+ const es = lowered.findIndex(c => c === 'estado' || c === 'status');
112
+ if (fa !== -1 && no !== -1 && es !== -1) {
113
+ idxFase = fa;
114
+ idxNombre = no;
115
+ idxObjetivo = ob;
116
+ idxEstado = es;
117
+ enTabla = true;
118
+ }
119
+ continue;
120
+ }
121
+
122
+ // Línea separadora (|---|---|)
123
+ if (celdas.every(c => /^-+$/.test(c.replace(/:/g, '')))) continue;
124
+
125
+ if (!enTabla) continue;
126
+
127
+ const numStr = celdas[idxFase];
128
+ const numero = parseInt(numStr, 10);
129
+ if (!Number.isFinite(numero)) continue;
130
+
131
+ fases.set(numero, {
132
+ numero,
133
+ nombre: celdas[idxNombre] || `Fase ${numero}`,
134
+ objetivo: idxObjetivo !== -1 ? (celdas[idxObjetivo] || null) : null,
135
+ estado: normalizarEstado(celdas[idxEstado]),
136
+ estado_raw: celdas[idxEstado],
137
+ fecha_inicio: null,
138
+ fecha_fin_estimada: null,
139
+ fecha_fin_real: null,
140
+ entregables: [],
141
+ criterios_verificacion: [],
142
+ fuente: 'tabla-resumen',
143
+ });
144
+ }
145
+ }
146
+
147
+ function parsearSecciones(contenido, fases) {
148
+ // ## Fase N: Nombre o ## Fase N — Nombre
149
+ const reFase = /^##\s+Fase\s+(\d+)\s*[:\-—–]\s*(.+?)$/gm;
150
+ const matches = [...contenido.matchAll(reFase)];
151
+
152
+ for (let i = 0; i < matches.length; i++) {
153
+ const m = matches[i];
154
+ const numero = parseInt(m[1], 10);
155
+ const nombre = m[2].trim();
156
+ const inicio = m.index + m[0].length;
157
+ const fin = i + 1 < matches.length ? matches[i + 1].index : contenido.length;
158
+ const seccion = contenido.slice(inicio, fin);
159
+
160
+ const existente = fases.get(numero) || {
161
+ numero,
162
+ nombre,
163
+ objetivo: null,
164
+ estado: 'pendiente',
165
+ estado_raw: 'Pendiente',
166
+ fecha_inicio: null,
167
+ fecha_fin_estimada: null,
168
+ fecha_fin_real: null,
169
+ entregables: [],
170
+ criterios_verificacion: [],
171
+ fuente: 'seccion-detallada',
172
+ };
173
+
174
+ existente.nombre = nombre || existente.nombre;
175
+ existente.fuente = 'seccion-detallada';
176
+
177
+ const objetivo = extraerCampo(seccion, 'Objetivo');
178
+ if (objetivo) existente.objetivo = objetivo;
179
+
180
+ const estadoRaw = extraerCampo(seccion, 'Estado');
181
+ if (estadoRaw) {
182
+ existente.estado = normalizarEstado(estadoRaw);
183
+ existente.estado_raw = estadoRaw;
184
+ }
185
+
186
+ const fIni = extraerCampo(seccion, 'Fecha inicio');
187
+ if (fIni && esFecha(fIni)) existente.fecha_inicio = fIni;
188
+
189
+ const fFinEst = extraerCampo(seccion, 'Fecha fin estimada');
190
+ if (fFinEst && esFecha(fFinEst)) existente.fecha_fin_estimada = fFinEst;
191
+
192
+ const fFinReal = extraerCampo(seccion, 'Fecha fin real');
193
+ if (fFinReal && esFecha(fFinReal)) existente.fecha_fin_real = fFinReal;
194
+
195
+ // Entregables (subtabla)
196
+ existente.entregables = parsearTablaEntregables(seccion);
197
+
198
+ // Criterios de verificación (checklist)
199
+ existente.criterios_verificacion = parsearChecklistCriterios(seccion);
200
+
201
+ fases.set(numero, existente);
202
+ }
203
+ }
204
+
205
+ function extraerCampo(seccion, etiqueta) {
206
+ const re = new RegExp(`^\\s*[\\*\\-]?\\s*\\*\\*${etiqueta}\\*\\*\\s*:\\s*(.+?)$`, 'mi');
207
+ const m = seccion.match(re);
208
+ if (!m) return null;
209
+ const valor = m[1].trim();
210
+ if (valor === '—' || valor === '-' || valor === '' || valor.toLowerCase().startsWith('[')) {
211
+ return null;
212
+ }
213
+ return valor;
214
+ }
215
+
216
+ function parsearTablaEntregables(seccion) {
217
+ const lineas = seccion.split('\n');
218
+ const entregables = [];
219
+ let enTabla = false;
220
+ let idxNum = -1, idxNombre = -1, idxDesc = -1, idxReqs = -1, idxEstado = -1;
221
+
222
+ for (const linea of lineas) {
223
+ const trim = linea.trim();
224
+ if (!trim.startsWith('|')) {
225
+ if (enTabla && trim === '') {
226
+ enTabla = false;
227
+ idxNum = -1;
228
+ }
229
+ continue;
230
+ }
231
+ const celdas = trim.split('|').map(c => c.trim()).filter(c => c !== '');
232
+
233
+ if (idxNum === -1) {
234
+ const low = celdas.map(c => c.toLowerCase());
235
+ const n = low.findIndex(c => c === '#' || c === 'id');
236
+ const no = low.findIndex(c => c === 'entregable' || c === 'nombre');
237
+ const de = low.findIndex(c => c === 'descripción' || c === 'descripcion');
238
+ const re = low.findIndex(c => c.includes('requisitos') || c.includes('cubiertos'));
239
+ const es = low.findIndex(c => c === 'estado');
240
+ if (n !== -1 && no !== -1 && es !== -1) {
241
+ idxNum = n; idxNombre = no; idxDesc = de; idxReqs = re; idxEstado = es;
242
+ enTabla = true;
243
+ }
244
+ continue;
245
+ }
246
+
247
+ if (celdas.every(c => /^-+$/.test(c.replace(/:/g, '')))) continue;
248
+ if (!enTabla) continue;
249
+
250
+ const numEntregable = celdas[idxNum];
251
+ if (!numEntregable) continue;
252
+
253
+ entregables.push({
254
+ id: numEntregable,
255
+ nombre: celdas[idxNombre] || null,
256
+ descripcion: idxDesc !== -1 ? (celdas[idxDesc] || null) : null,
257
+ requisitos: idxReqs !== -1
258
+ ? (celdas[idxReqs] || '').split(',').map(s => s.trim()).filter(Boolean)
259
+ : [],
260
+ estado: normalizarEstado(celdas[idxEstado]),
261
+ });
262
+ }
263
+
264
+ return entregables;
265
+ }
266
+
267
+ function parsearChecklistCriterios(seccion) {
268
+ // Captura solo el bloque "Criterios de verificación" si existe
269
+ const idx = seccion.toLowerCase().indexOf('criterios de verificación');
270
+ if (idx === -1) return [];
271
+ const subseccion = seccion.slice(idx);
272
+ // Cortar en la siguiente sub-sección o el final
273
+ const finIdx = subseccion.search(/\n###?\s+/m);
274
+ const recorte = finIdx === -1 ? subseccion : subseccion.slice(0, finIdx);
275
+
276
+ const criterios = [];
277
+ const re = /^\s*-\s*\[(x| )\]\s+(.+?)$/gim;
278
+ let m;
279
+ while ((m = re.exec(recorte)) !== null) {
280
+ criterios.push({
281
+ cumplido: m[1].toLowerCase() === 'x',
282
+ descripcion: m[2].trim(),
283
+ });
284
+ }
285
+ return criterios;
286
+ }
287
+
288
+ function normalizarEstado(estadoRaw) {
289
+ if (!estadoRaw) return 'pendiente';
290
+ const lower = estadoRaw.toLowerCase().trim();
291
+
292
+ if (lower.includes('complet')) return 'completado';
293
+ if (lower.includes('done') || lower.includes('hecho')) return 'completado';
294
+ if (lower.includes('progreso') || lower.includes('progress') || lower.includes('curso')) {
295
+ return 'en_progreso';
296
+ }
297
+ if (lower.includes('spec') && lower.includes('listo')) return 'spec_listo';
298
+ if (lower.includes('bloque') || lower.includes('block')) return 'bloqueado';
299
+ if (lower.includes('pendiente') || lower.includes('pending')) return 'pendiente';
300
+
301
+ return 'pendiente'; // default conservador
302
+ }
303
+
304
+ function esFecha(s) {
305
+ return /^\d{4}-\d{2}-\d{2}/.test(s);
306
+ }
307
+
308
+ // ─── Enriquecimiento desde .planning/fases/ ──────────────────────────────────
309
+
310
+ /**
311
+ * Para cada fase, intenta enriquecer con info de:
312
+ * .planning/fases/0N-PLAN.md → existe + estado
313
+ * .planning/fases/0N-RESUMEN.md → completada
314
+ * .planning/fases/0N-CONTEXTO.md → discutida
315
+ */
316
+ function enriquecerDesdeFases(fases, opciones = {}) {
317
+ // CWD dinámico — recalcula al llamar, no al require, para que los tests con
318
+ // process.chdir() funcionen sin requerir reload del módulo.
319
+ const cwd = opciones.cwd || process.cwd();
320
+ const fasesDir = path.join(cwd, '.planning', 'fases');
321
+ if (!fs.existsSync(fasesDir)) return fases;
322
+
323
+ const archivos = fs.readdirSync(fasesDir);
324
+
325
+ for (const fase of fases) {
326
+ const num = fase.numero.toString().padStart(2, '0');
327
+
328
+ fase.artefactos = {
329
+ contexto_md: archivos.includes(`${num}-CONTEXTO.md`)
330
+ ? `.planning/fases/${num}-CONTEXTO.md` : null,
331
+ plan_md: archivos.includes(`${num}-PLAN.md`)
332
+ ? `.planning/fases/${num}-PLAN.md` : null,
333
+ resumen_md: archivos.includes(`${num}-RESUMEN.md`)
334
+ ? `.planning/fases/${num}-RESUMEN.md` : null,
335
+ };
336
+
337
+ // Si tiene PLAN.md, intentar leer su frontmatter `estado:` para refinar
338
+ if (fase.artefactos.plan_md) {
339
+ const planPath = path.join(cwd, fase.artefactos.plan_md);
340
+ try {
341
+ const planContent = fs.readFileSync(planPath, 'utf-8');
342
+ const frontMatch = planContent.match(/^---\s*\n([\s\S]*?)\n---/);
343
+ if (frontMatch) {
344
+ const fm = frontMatch[1];
345
+ const estadoPlan = (fm.match(/^estado:\s*(.+)$/m) || [])[1];
346
+ if (estadoPlan) {
347
+ const estadoPlanLow = estadoPlan.trim().toLowerCase();
348
+ fase.plan_estado = estadoPlanLow;
349
+ // Si el PLAN está aprobado pero el roadmap dice "pendiente",
350
+ // refinar a "en_progreso" porque ya pasó la planeación.
351
+ if (estadoPlanLow === 'aprobado' && fase.estado === 'pendiente') {
352
+ fase.estado = 'en_progreso';
353
+ }
354
+ }
355
+ }
356
+ } catch { /* no bloquear si el PLAN tiene formato no esperado */ }
357
+ }
358
+
359
+ // Si tiene RESUMEN.md, asumir completada (a menos que HOJA-RUTA diga otra cosa explícita)
360
+ if (fase.artefactos.resumen_md && fase.estado !== 'bloqueado') {
361
+ fase.estado = 'completado';
362
+ }
363
+ }
364
+
365
+ return fases;
366
+ }
367
+
368
+ // ─── Generación del JSON canónico ────────────────────────────────────────────
369
+
370
+ function generarFeatureList(fases) {
371
+ const ahora = new Date().toISOString();
372
+ const totales = {
373
+ fases: fases.length,
374
+ completadas: fases.filter(f => f.estado === 'completado').length,
375
+ en_progreso: fases.filter(f => f.estado === 'en_progreso').length,
376
+ pendientes: fases.filter(f => f.estado === 'pendiente').length,
377
+ bloqueadas: fases.filter(f => f.estado === 'bloqueado').length,
378
+ spec_listas: fases.filter(f => f.estado === 'spec_listo').length,
379
+ };
380
+
381
+ return {
382
+ schema: 'swl-feature-list/v1',
383
+ generado_en: ahora,
384
+ fuente: '.planning/HOJA-RUTA.md',
385
+ nota: 'Derivado automáticamente. HOJA-RUTA.md es la fuente de verdad. Regenerar con `node scripts/derivar-feature-list.js`.',
386
+ totales,
387
+ fases,
388
+ };
389
+ }
390
+
391
+ // ─── Comparación para --check ────────────────────────────────────────────────
392
+
393
+ function compararConDisco(generado, rutaOut) {
394
+ if (!fs.existsSync(rutaOut)) {
395
+ return { iguales: false, razon: `${rutaOut} no existe` };
396
+ }
397
+ let actual;
398
+ try {
399
+ actual = JSON.parse(fs.readFileSync(rutaOut, 'utf-8'));
400
+ } catch (err) {
401
+ return { iguales: false, razon: `JSON existente está corrupto: ${err.message}` };
402
+ }
403
+
404
+ // Comparar ignorando `generado_en` (timestamp cambia siempre)
405
+ const a = { ...generado, generado_en: '_' };
406
+ const b = { ...actual, generado_en: '_' };
407
+
408
+ const aStr = JSON.stringify(a);
409
+ const bStr = JSON.stringify(b);
410
+
411
+ if (aStr === bStr) return { iguales: true };
412
+ return {
413
+ iguales: false,
414
+ razon: 'Contenido difiere. Regenerar con `node scripts/derivar-feature-list.js`.',
415
+ };
416
+ }
417
+
418
+ // ─── Main ────────────────────────────────────────────────────────────────────
419
+
420
+ function parsearArgs(argv) {
421
+ const args = { out: DEFAULT_OUT, check: false, verbose: false };
422
+ for (let i = 2; i < argv.length; i++) {
423
+ const a = argv[i];
424
+ if (a === '--check' || a === '-c') args.check = true;
425
+ else if (a === '--verbose' || a === '-v') args.verbose = true;
426
+ else if (a === '--out' || a === '-o') args.out = argv[++i];
427
+ else if (a === '--help' || a === '-h') {
428
+ console.log('Uso: derivar-feature-list.js [--out <path>] [--check] [--verbose]');
429
+ process.exit(0);
430
+ }
431
+ }
432
+ return args;
433
+ }
434
+
435
+ function main() {
436
+ const args = parsearArgs(process.argv);
437
+
438
+ if (!fs.existsSync(HOJA_RUTA)) {
439
+ console.error('[derivar-feature-list] .planning/HOJA-RUTA.md no encontrado.');
440
+ console.error(' Inicializa con `/swl:nuevo-proyecto` o `/swl:adoptar-proyecto` primero.');
441
+ process.exit(1);
442
+ }
443
+
444
+ const contenido = fs.readFileSync(HOJA_RUTA, 'utf-8');
445
+ let fases;
446
+ try {
447
+ fases = parsearHojaRuta(contenido);
448
+ fases = enriquecerDesdeFases(fases);
449
+ } catch (err) {
450
+ console.error(`[derivar-feature-list] Error de parsing: ${err.message}`);
451
+ process.exit(3);
452
+ }
453
+
454
+ if (fases.length === 0 && args.verbose) {
455
+ console.log('[derivar-feature-list] No se detectaron fases en HOJA-RUTA.md.');
456
+ console.log(' El JSON se genera vacío (válido para proyectos recién inicializados).');
457
+ }
458
+
459
+ const generado = generarFeatureList(fases);
460
+
461
+ if (args.check) {
462
+ const cmp = compararConDisco(generado, args.out);
463
+ if (cmp.iguales) {
464
+ if (args.verbose) console.log(`[derivar-feature-list] OK — ${args.out} está sincronizado.`);
465
+ process.exit(0);
466
+ }
467
+ console.error(`[derivar-feature-list] DRIFT: ${cmp.razon}`);
468
+ process.exit(2);
469
+ }
470
+
471
+ fs.writeFileSync(args.out, JSON.stringify(generado, null, 2), 'utf-8');
472
+ if (args.verbose) {
473
+ console.log(`[derivar-feature-list] Generado ${args.out}`);
474
+ console.log(` ${generado.totales.fases} fases | ${generado.totales.completadas} completadas | ${generado.totales.en_progreso} en progreso | ${generado.totales.pendientes} pendientes`);
475
+ } else {
476
+ console.log(`[derivar-feature-list] ${args.out} actualizado (${generado.totales.fases} fases)`);
477
+ }
478
+ }
479
+
480
+ if (require.main === module) {
481
+ main();
482
+ }
483
+
484
+ module.exports = {
485
+ parsearHojaRuta,
486
+ enriquecerDesdeFases,
487
+ generarFeatureList,
488
+ normalizarEstado,
489
+ };