@saulwade/swl-ses 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };
package/scripts/doctor.js CHANGED
@@ -335,10 +335,37 @@ async function doctor(opciones = {}) {
335
335
  const { detectarStack, filtrarReglasPorStack } = require('./lib/detectar-stack');
336
336
  const resolucion = resolverPerfil(estado.perfil, { target: estado.target });
337
337
 
338
- // Aplicar el mismo filtro de reglas por stack que usa el instalador
339
- const stackDetectado = detectarStack(cwd);
340
- const filtrado = filtrarReglasPorStack(resolucion.archivos, stackDetectado);
341
- resolucion.archivos = filtrado.archivos;
338
+ // Fix v1.4.3: usar el contexto de filtrado persistido al momento del
339
+ // install (allLangs, stackInstalado) en lugar de recalcular desde el cwd
340
+ // actual del doctor. Si el estado no lo persiste (instalación previa a
341
+ // v1.4.3), inferirlo desde los archivos realmente instalados.
342
+ let stackParaFiltro;
343
+ let omitirFiltro = false;
344
+ if (estado.allLangs === true) {
345
+ omitirFiltro = true;
346
+ } else if (Array.isArray(estado.stackInstalado)) {
347
+ stackParaFiltro = new Set(estado.stackInstalado);
348
+ } else {
349
+ // Backward compat: instalaciones previas a v1.4.3 no persisten contexto
350
+ // de filtrado. Inferirlo desde los archivos realmente instalados:
351
+ // cada subdir bajo reglas/lenguajes/X/ corresponde a un lenguaje
352
+ // detectado o a --all-langs activo al momento del install.
353
+ // El campo persistido en estado es `origen` (ruta relativa al paquete).
354
+ const langsInstalados = new Set();
355
+ for (const archivo of estado.archivosInstalados) {
356
+ const ruta = (archivo.origen || '').replace(/\\/g, '/');
357
+ if (!ruta.startsWith('reglas/lenguajes/')) continue;
358
+ const resto = ruta.slice('reglas/lenguajes/'.length);
359
+ const lang = resto.split('/')[0];
360
+ if (lang) langsInstalados.add(lang);
361
+ }
362
+ stackParaFiltro = langsInstalados;
363
+ }
364
+
365
+ if (!omitirFiltro) {
366
+ const filtrado = filtrarReglasPorStack(resolucion.archivos, stackParaFiltro);
367
+ resolucion.archivos = filtrado.archivos;
368
+ }
342
369
 
343
370
  // Contar esperados por tipo (solo tipos principales)
344
371
  const tiposPrincipales = ['agentes', 'habilidades', 'comandos', 'reglas'];
@@ -411,6 +411,11 @@ async function install(opciones) {
411
411
  perfil,
412
412
  rutaBase: rutas.base,
413
413
  global: esGlobal,
414
+ // Persistir contexto de filtrado de reglas-lenguajes para que doctor
415
+ // verifique conteos contra el filtro real al instalar (no recalcule
416
+ // desde el cwd actual del doctor). Fix v1.4.3.
417
+ allLangs: allLangs === true,
418
+ stackInstalado: stackDetectado instanceof Set ? [...stackDetectado] : null,
414
419
  });
415
420
 
416
421
  let instalados = 0;
@@ -447,6 +452,11 @@ async function install(opciones) {
447
452
  }
448
453
  }
449
454
 
455
+ // Cargar transformador del target ANTES del loop de instalación —
456
+ // Sub-fase 11.5: permite que instalarArchivo invoque transformarAgente
457
+ // por archivo para targets que cambian formato (ej. codex .md → .toml).
458
+ const transformadorTarget = obtenerTransformador(target, runtime);
459
+
450
460
  // Instalar archivos core
451
461
  for (const archivo of resolucion.archivos) {
452
462
  try {
@@ -457,6 +467,7 @@ async function install(opciones) {
457
467
  estado,
458
468
  syncMode,
459
469
  flatNaming,
470
+ transformador: transformadorTarget,
460
471
  });
461
472
  if (resultado.instalado) {
462
473
  registrarArchivo(estado, {
@@ -612,6 +623,13 @@ async function install(opciones) {
612
623
  // transformador Claude pueda detectar stack/comandos/framework.
613
624
  // Otros transformadores ignoran este campo.
614
625
  dirProyecto: process.cwd(),
626
+ // ADR-0019 Sub-fase 1: el transformador Codex necesita saber el scope para
627
+ // resolver dirBase (~/.codex/ vs cwd) y registrar el MCP server.
628
+ // El runtime trae los paths absolutos calculados por detectar-runtime.js.
629
+ esGlobal,
630
+ dirRuntimeGlobal: runtime.global,
631
+ withMcp: Boolean(opciones.with_mcp) && !opciones.no_mcp,
632
+ swlBinPath: path.join(RAIZ_PKG, 'bin', 'swl-mcp-server.js'),
615
633
  });
616
634
 
617
635
  if (instrucciones) {
@@ -628,6 +646,7 @@ async function install(opciones) {
628
646
  console.log(' = CLAUDE.md no modificado (--no-claudemd)');
629
647
  } else if (instrucciones.merge && instrucciones.merge.tipo === 'marcadores') {
630
648
  // Merge idempotente con marcadores — preserva contenido del usuario.
649
+ // Aplica a CLAUDE.md (Claude Code) y AGENTS.md (Codex CLI) — ADR-0019 Sub-fase 1.
631
650
  if (!fs.existsSync(dirInstrucciones)) {
632
651
  fs.mkdirSync(dirInstrucciones, { recursive: true });
633
652
  }
@@ -637,12 +656,13 @@ async function install(opciones) {
637
656
  endTag: instrucciones.merge.endTag,
638
657
  beginPrefix: instrucciones.merge.beginPrefix,
639
658
  });
659
+ const nombreArchivo = instrucciones.rutaRelativa;
640
660
  const etiqueta = {
641
- 'creado': '+ CLAUDE.md creado con bloque SWL',
642
- 'append': '+ Bloque SWL agregado al final de CLAUDE.md (contenido del usuario preservado)',
643
- 'reemplazado': '* Bloque SWL actualizado en CLAUDE.md (contenido del usuario preservado)',
644
- 'sin-cambios': '= CLAUDE.md ya tenía el bloque SWL actualizado',
645
- 'error': '! Error al fusionar CLAUDE.md',
661
+ 'creado': `+ ${nombreArchivo} creado con bloque SWL`,
662
+ 'append': `+ Bloque SWL agregado al final de ${nombreArchivo} (contenido del usuario preservado)`,
663
+ 'reemplazado': `* Bloque SWL actualizado en ${nombreArchivo} (contenido del usuario preservado)`,
664
+ 'sin-cambios': `= ${nombreArchivo} ya tenía el bloque SWL actualizado`,
665
+ 'error': `! Error al fusionar ${nombreArchivo}`,
646
666
  }[resMerge.accion] || `${resMerge.accion} ${resMerge.archivo}`;
647
667
  console.log(` ${etiqueta}${resMerge.detalle ? ': ' + resMerge.detalle : ''}`);
648
668
  registrarArchivo(estado, {
@@ -850,6 +870,33 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
850
870
 
851
871
  let nombreArchivo = path.basename(archivo.origen);
852
872
 
873
+ // Sub-fase 11.5 v1.5.0: para tipo 'agentes', aplicar transformarAgente del
874
+ // transformador del target ANTES de la copia. Permite que codex emita TOML
875
+ // (.toml) en lugar del .md original, o que cursor normalice el frontmatter.
876
+ // Si transformarAgente devuelve `consolidar: true`, saltar — el archivo
877
+ // se incluirá en el archivoPrincipal (AGENTS.md) generado más adelante.
878
+ let contenidoTransformado = null;
879
+ if (archivo.tipo === 'agentes' && opciones.transformador && typeof opciones.transformador.transformarAgente === 'function') {
880
+ try {
881
+ const original = fs.readFileSync(archivo.origen, 'utf-8');
882
+ const t = opciones.transformador.transformarAgente(original, { nombreArchivo });
883
+ if (t && t.consolidar === true) {
884
+ // El transformador del target prefiere consolidar este agente en el
885
+ // archivoPrincipal en lugar de tener archivo individual. No instalar aquí.
886
+ return { instalado: false, razon: 'consolidado en archivoPrincipal' };
887
+ }
888
+ if (t && typeof t.contenido === 'string') {
889
+ contenidoTransformado = t.contenido;
890
+ if (t.rutaRelativa) {
891
+ // Usar el basename del rutaRelativa devuelto (puede cambiar extensión .md → .toml)
892
+ nombreArchivo = path.basename(t.rutaRelativa);
893
+ }
894
+ }
895
+ } catch (err) {
896
+ console.log(` ! Transformación de agente falló (${err.message}), copia literal del .md`);
897
+ }
898
+ }
899
+
853
900
  // Flat naming: convierte rutas jerárquicas en nombres planos con __
854
901
  // Ejemplo: habilidades/build-errors-python → build-errors-python (sin cambio, ya es plano)
855
902
  // Ejemplo: reglas/lenguajes/java/java-estilo.md → java__java-estilo.md
@@ -991,6 +1038,10 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
991
1038
  // Copy mode (default) o fallback de symlink
992
1039
  if (stat.isDirectory()) {
993
1040
  copiarDirectorio(archivo.origen, path.join(dirDestino, nombreArchivo));
1041
+ } else if (contenidoTransformado !== null) {
1042
+ // Sub-fase 11.5: el transformador del target reescribió el contenido
1043
+ // (ej. codex .md → .toml). Escribir el contenido transformado.
1044
+ fs.writeFileSync(destino, contenidoTransformado, 'utf-8');
994
1045
  } else {
995
1046
  fs.copyFileSync(archivo.origen, destino);
996
1047
  }