@saulwade/swl-ses 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CLAUDE.md +196 -196
  2. package/README.md +579 -579
  3. package/agentes/_propose-step.md +90 -0
  4. package/agentes/implementador-swl.md +2 -0
  5. package/agentes/orquestador-swl.md +2 -0
  6. package/agentes/perfilador-usuario-swl.md +14 -1
  7. package/bin/swl-ses.js +1 -1
  8. package/comandos/swl/aprobar-plan.md +3 -2
  9. package/comandos/swl/briefing.md +122 -0
  10. package/comandos/swl/compactar.md +29 -2
  11. package/comandos/swl/discutir-fase.md +8 -5
  12. package/comandos/swl/ejecutar-fase.md +6 -0
  13. package/comandos/swl/planear-fase.md +5 -3
  14. package/comandos/swl/release.md +46 -0
  15. package/comandos/swl/status.md +69 -0
  16. package/comandos/swl/verificar.md +3 -2
  17. package/habilidades/changelog-generator/scripts/parse-commits.js +6 -4
  18. package/habilidades/ejecutar-fase/SKILL.md +541 -518
  19. package/habilidades/planear-fase/SKILL.md +3 -2
  20. package/habilidades/tdd-workflow/SKILL.md +715 -713
  21. package/habilidades/validacion-ci-sistema/SKILL.md +17 -1
  22. package/hooks/calidad-pre-commit.js +5 -1
  23. package/hooks/check-update.js +39 -1
  24. package/hooks/lib/autonomia.js +208 -0
  25. package/hooks/lib/briefing.js +474 -0
  26. package/hooks/lib/propose-step.js +357 -0
  27. package/hooks/session-briefing.js +98 -0
  28. package/hooks/telemetria-skill-routing.js +100 -0
  29. package/instintos/autonomia.yaml +27 -0
  30. package/llms.txt +4 -4
  31. package/manifiestos/hooks-config.json +18 -0
  32. package/manifiestos/modulos.json +25 -3
  33. package/manifiestos/skills-lock.json +14 -14
  34. package/package.json +93 -93
  35. package/plugin.json +371 -371
  36. package/reglas/analizar-directorios-antes-de-escribir.md +228 -0
  37. package/reglas/consultar-vault-primero.md +195 -0
  38. package/reglas/debatir-antes-de-aceptar.md +158 -0
  39. package/reglas/git-coauthor.md +100 -0
  40. package/reglas/monitor-ci.md +309 -0
  41. package/reglas/registro-componentes-nuevos.md +38 -10
  42. package/reglas/sesiones-paralelas.md +180 -0
  43. package/reglas/usar-code-review-graph.md +155 -0
  44. package/reglas/verificar-citas-normativas.md +548 -0
  45. package/scripts/instalador.js +52 -6
  46. package/scripts/lib/ci-reader.js +193 -0
  47. package/scripts/lib/detectar-host-swl.js +175 -0
  48. package/scripts/lib/evidencia-release.js +322 -0
  49. package/scripts/lib/gate-hooks-requires.js +249 -0
  50. package/scripts/lib/gate-licencias.js +212 -0
  51. package/scripts/lib/git-metricas.js +257 -0
  52. package/scripts/lib/metricas-dora.js +204 -0
  53. package/scripts/tui/ejecutores.js +1 -1
  54. package/scripts/validar-manifest.js +92 -1
  55. package/scripts/verificar-evolucion.js +54 -4
  56. package/scripts/verificar-release.js +102 -0
  57. package/scripts/verificar-trazabilidad.js +11 -5
  58. package/reglas/arquitectura.evolved.json +0 -7
  59. package/reglas/seguridad.evolved.json +0 -7
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * git-metricas.js
5
+ *
6
+ * Métricas DORA derivables SOLO de git (sin CI), para el proyecto destino donde
7
+ * SWL está instalado:
8
+ * - deployment frequency: tags de release en una ventana -> deploys/semana.
9
+ * - lead time for changes: commit más antiguo desde el release previo -> tag
10
+ * actual (p50/p95).
11
+ * - detección de degradación de entrega: compara ventana actual vs previa
12
+ * (consumido por el recolector del briefing — git-only, <200ms, NUNCA gh).
13
+ *
14
+ * "deployment" = tag de release que matchea `patronTag` (default /^v?\d+\.\d+\.\d+/).
15
+ *
16
+ * Funciones puras zero-deps. git vía execFileSync sin shell, con try/catch que
17
+ * degrada a `{disponible:false}` (no es repo) o a 0/sinDatos (repo sin tags).
18
+ * Entrypoint CLI `--json`. Parte de la Fase 15 (ADR-0039).
19
+ */
20
+
21
+ const { execFileSync } = require('node:child_process');
22
+
23
+ const PATRON_TAG_DEFAULT = /^v?\d+\.\d+\.\d+/;
24
+ // Rango git válido: prohíbe `-` inicial en cada extremo (defensa en profundidad
25
+ // contra un refname que git interpretaría como flag de `git log`). Los nombres
26
+ // vienen de `git for-each-ref` —git ya rechaza refs con `-` inicial—, pero la
27
+ // validación local cierra el hueco si el origen del rango cambiara (H-02).
28
+ const RE_RANGO_VALIDO =
29
+ /^(?!-)[A-Za-z0-9_./@~^][A-Za-z0-9_./@~^-]*(\.\.\.?(?!-)[A-Za-z0-9_./@~^][A-Za-z0-9_./@~^-]*)?$/;
30
+ const MS_DIA = 24 * 60 * 60 * 1000;
31
+ const MS_HORA = 60 * 60 * 1000;
32
+ const GIT_MAX_BUFFER = 8 * 1024 * 1024; // 8 MiB
33
+ const GIT_TIMEOUT_MS = 5000; // evita hangs (H-04); el recolector del briefing degrada si git cuelga
34
+
35
+ function _git(baseDir, args) {
36
+ return execFileSync('git', args, {
37
+ cwd: baseDir,
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ maxBuffer: GIT_MAX_BUFFER,
41
+ timeout: GIT_TIMEOUT_MS,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Lista tags que matchean patronTag con su fecha de creación.
47
+ * @returns {Array<{name:string, date:Date}>|null} null si no es repo git.
48
+ */
49
+ function _gitTagsConFecha(baseDir, patronTag = PATRON_TAG_DEFAULT) {
50
+ let salida;
51
+ try {
52
+ salida = _git(baseDir, [
53
+ 'for-each-ref',
54
+ '--sort=creatordate',
55
+ '--format=%(refname:short)|%(creatordate:iso-strict)',
56
+ 'refs/tags',
57
+ ]);
58
+ } catch {
59
+ return null; // no es repo git / git no disponible
60
+ }
61
+ const tags = [];
62
+ for (const linea of salida.split('\n')) {
63
+ const l = linea.trim();
64
+ if (!l) continue;
65
+ const sep = l.lastIndexOf('|');
66
+ if (sep === -1) continue;
67
+ const name = l.slice(0, sep);
68
+ const iso = l.slice(sep + 1);
69
+ if (!patronTag.test(name)) continue;
70
+ const t = Date.parse(iso);
71
+ if (!Number.isFinite(t)) continue;
72
+ tags.push({ name, date: new Date(t) });
73
+ }
74
+ tags.sort((a, b) => a.date - b.date);
75
+ return tags;
76
+ }
77
+
78
+ /** ISO del commit más antiguo (primero) de un rango git, o null. */
79
+ function _primerCommitISO(baseDir, rango) {
80
+ if (!RE_RANGO_VALIDO.test(rango)) return null;
81
+ let salida;
82
+ try {
83
+ salida = _git(baseDir, ['log', rango, '--reverse', '--format=%cI', '--']);
84
+ } catch {
85
+ return null;
86
+ }
87
+ const primera = salida.split('\n').map((s) => s.trim()).filter(Boolean)[0];
88
+ return primera || null;
89
+ }
90
+
91
+ // Nearest-rank (ceil), no interpolación. Con 2 muestras [a,b] y p=50 → a.
92
+ function _percentil(valoresOrdenados, p) {
93
+ if (valoresOrdenados.length === 0) return null;
94
+ const idx = Math.ceil((p / 100) * valoresOrdenados.length) - 1;
95
+ return valoresOrdenados[Math.max(0, Math.min(idx, valoresOrdenados.length - 1))];
96
+ }
97
+
98
+ function _enVentana(date, desde, hasta) {
99
+ return date >= desde && date <= hasta;
100
+ }
101
+
102
+ /**
103
+ * Deployment frequency en la ventana.
104
+ * @returns {{disponible:boolean, totalDeploys?:number, deploysPorSemana?:number,
105
+ * sinDatos?:boolean, razon?:string}}
106
+ */
107
+ function deploymentFrequency(baseDir, opts = {}) {
108
+ const { ventanaDias = 30, hoy = new Date(), patronTag = PATRON_TAG_DEFAULT } = opts;
109
+ const tags = _gitTagsConFecha(baseDir, patronTag);
110
+ if (tags === null) return { disponible: false, razon: 'no es un repositorio git' };
111
+
112
+ const desde = new Date(hoy.getTime() - ventanaDias * MS_DIA);
113
+ const enVentana = tags.filter((t) => _enVentana(t.date, desde, hoy));
114
+ const semanas = ventanaDias / 7;
115
+ return {
116
+ disponible: true,
117
+ totalDeploys: enVentana.length,
118
+ deploysPorSemana: semanas > 0 ? enVentana.length / semanas : 0,
119
+ sinDatos: tags.length === 0,
120
+ };
121
+ }
122
+
123
+ /** Lead times (horas) commit->tag para tags en [desde,hasta]. */
124
+ function _leadTimesEnVentana(baseDir, tags, desde, hasta) {
125
+ const horas = [];
126
+ for (let i = 0; i < tags.length; i++) {
127
+ const tag = tags[i];
128
+ if (!_enVentana(tag.date, desde, hasta)) continue;
129
+ const prev = tags[i - 1];
130
+ const rango = prev ? `${prev.name}..${tag.name}` : tag.name;
131
+ const primeroISO = _primerCommitISO(baseDir, rango);
132
+ if (!primeroISO) continue;
133
+ const t = Date.parse(primeroISO);
134
+ if (!Number.isFinite(t)) continue;
135
+ const h = (tag.date.getTime() - t) / MS_HORA;
136
+ if (h >= 0) horas.push(h);
137
+ }
138
+ return horas.sort((a, b) => a - b);
139
+ }
140
+
141
+ /**
142
+ * Lead time for changes (commit->tag) en la ventana.
143
+ * @returns {{disponible:boolean, p50Horas?:number, p95Horas?:number,
144
+ * muestras?:number, sinDatos?:boolean, razon?:string}}
145
+ */
146
+ function leadTimeForChanges(baseDir, opts = {}) {
147
+ const { ventanaDias = 30, hoy = new Date(), patronTag = PATRON_TAG_DEFAULT } = opts;
148
+ const tags = _gitTagsConFecha(baseDir, patronTag);
149
+ if (tags === null) return { disponible: false, razon: 'no es un repositorio git' };
150
+
151
+ const desde = new Date(hoy.getTime() - ventanaDias * MS_DIA);
152
+ const horas = _leadTimesEnVentana(baseDir, tags, desde, hoy);
153
+ if (horas.length === 0) {
154
+ return { disponible: true, sinDatos: true, muestras: 0, p50Horas: null, p95Horas: null };
155
+ }
156
+ return {
157
+ disponible: true,
158
+ sinDatos: false,
159
+ muestras: horas.length,
160
+ p50Horas: _percentil(horas, 50),
161
+ p95Horas: _percentil(horas, 95),
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Detecta degradación de entrega comparando la ventana actual vs la previa de
167
+ * igual duración. Git-only y rápido — apto para el recolector del briefing.
168
+ * Degradación = deploy freq cae >50% O lead time mediano sube >50%.
169
+ * @returns {{disponible:boolean, degradado:boolean, motivo:(string|null),
170
+ * deployFreqActual:number, deployFreqPrevia:number,
171
+ * leadTimeP50Actual:(number|null), leadTimeP50Previo:(number|null)}}
172
+ */
173
+ function detectarDegradacionEntrega(baseDir, opts = {}) {
174
+ const { ventanaDias = 30, hoy = new Date(), patronTag = PATRON_TAG_DEFAULT } = opts;
175
+ const tags = _gitTagsConFecha(baseDir, patronTag);
176
+ if (tags === null) {
177
+ return {
178
+ disponible: false,
179
+ degradado: false,
180
+ motivo: null,
181
+ deployFreqActual: 0,
182
+ deployFreqPrevia: 0,
183
+ leadTimeP50Actual: null,
184
+ leadTimeP50Previo: null,
185
+ };
186
+ }
187
+
188
+ const finActual = hoy;
189
+ const inicioActual = new Date(hoy.getTime() - ventanaDias * MS_DIA);
190
+ const inicioPrevio = new Date(hoy.getTime() - 2 * ventanaDias * MS_DIA);
191
+ const semanas = ventanaDias / 7;
192
+
193
+ const deploysActual = tags.filter((t) => _enVentana(t.date, inicioActual, finActual)).length;
194
+ const deploysPrevio = tags.filter((t) => _enVentana(t.date, inicioPrevio, inicioActual)).length;
195
+ const deployFreqActual = semanas > 0 ? deploysActual / semanas : 0;
196
+ const deployFreqPrevia = semanas > 0 ? deploysPrevio / semanas : 0;
197
+
198
+ const ltActual = _percentil(_leadTimesEnVentana(baseDir, tags, inicioActual, finActual), 50);
199
+ const ltPrevio = _percentil(_leadTimesEnVentana(baseDir, tags, inicioPrevio, inicioActual), 50);
200
+
201
+ const motivos = [];
202
+ if (deployFreqPrevia > 0 && deployFreqActual < deployFreqPrevia * 0.5) {
203
+ motivos.push(
204
+ `deployment frequency cayó >50% (${deployFreqPrevia.toFixed(2)} → ${deployFreqActual.toFixed(2)} deploys/semana)`
205
+ );
206
+ }
207
+ if (ltPrevio !== null && ltActual !== null && ltActual > ltPrevio * 1.5) {
208
+ motivos.push(
209
+ `lead time mediano subió >50% (${ltPrevio.toFixed(1)}h → ${ltActual.toFixed(1)}h)`
210
+ );
211
+ }
212
+
213
+ return {
214
+ disponible: true,
215
+ degradado: motivos.length > 0,
216
+ motivo: motivos.length > 0 ? motivos.join('; ') : null,
217
+ deployFreqActual,
218
+ deployFreqPrevia,
219
+ leadTimeP50Actual: ltActual,
220
+ leadTimeP50Previo: ltPrevio,
221
+ };
222
+ }
223
+
224
+ module.exports = {
225
+ deploymentFrequency,
226
+ leadTimeForChanges,
227
+ detectarDegradacionEntrega,
228
+ PATRON_TAG_DEFAULT,
229
+ _gitTagsConFecha,
230
+ };
231
+
232
+ // Entrypoint CLI: node scripts/lib/git-metricas.js [--json] [--dias=N] [baseDir]
233
+ if (!require.main || require.main === module) {
234
+ const args = process.argv.slice(2);
235
+ const json = args.includes('--json');
236
+ const diasArg = args.find((a) => a.startsWith('--dias='));
237
+ const ventanaDias = Math.max(1, Math.min(365, (diasArg ? parseInt(diasArg.slice('--dias='.length), 10) : 30) || 30));
238
+ const baseDir = args.find((a) => !a.startsWith('--')) || process.cwd();
239
+
240
+ const out = {
241
+ ventana_dias: ventanaDias,
242
+ deploymentFrequency: deploymentFrequency(baseDir, { ventanaDias }),
243
+ leadTime: leadTimeForChanges(baseDir, { ventanaDias }),
244
+ degradacion: detectarDegradacionEntrega(baseDir, { ventanaDias }),
245
+ };
246
+
247
+ if (json) {
248
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
249
+ } else {
250
+ const df = out.deploymentFrequency;
251
+ const lt = out.leadTime;
252
+ process.stdout.write(`Deployment frequency: ${df.disponible ? (df.sinDatos ? 'sin datos' : df.deploysPorSemana.toFixed(2) + ' deploys/semana') : 'no disponible'}\n`);
253
+ process.stdout.write(`Lead time (p50/p95): ${lt.disponible && !lt.sinDatos ? `${lt.p50Horas.toFixed(1)}h / ${lt.p95Horas.toFixed(1)}h` : 'sin datos'}\n`);
254
+ process.stdout.write(`Degradación: ${out.degradacion.degradado ? out.degradacion.motivo : 'no'}\n`);
255
+ }
256
+ process.exit(0);
257
+ }
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * metricas-dora.js
5
+ *
6
+ * Compone las 4 métricas DORA del proyecto destino y las clasifica en
7
+ * elite/high/medium/low según los umbrales del DORA Report 2023:
8
+ * - deployment frequency + lead time desde git (siempre, git-metricas.js).
9
+ * - change failure rate + MTTR desde gh (degrada si ausente, ci-reader.js).
10
+ *
11
+ * Persiste el informe en `.planning/evolution/metricas-dora.json` (runtime).
12
+ * Entrypoint CLI `--json`. Parte de la Fase 15 (ADR-0039).
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const gitMetricas = require('./git-metricas');
19
+ const ciReader = require('./ci-reader');
20
+
21
+ let atomicWriteJSON;
22
+ try {
23
+ ({ atomicWriteJSON } = require('../../hooks/lib/atomic-write'));
24
+ } catch (err) {
25
+ if (err.code !== 'MODULE_NOT_FOUND') throw err;
26
+ atomicWriteJSON = (p, obj) => {
27
+ const dir = path.dirname(p);
28
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
29
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8');
30
+ };
31
+ }
32
+
33
+ const STORAGE_REL = path.join('.planning', 'evolution', 'metricas-dora.json');
34
+
35
+ /**
36
+ * Umbrales DORA Report 2023 (Accelerate State of DevOps). Boundaries expresados
37
+ * en las unidades internas de cada métrica. Ajustar aquí si el reporte cambia.
38
+ *
39
+ * - deploymentFrequency (deploys/semana, mayor es mejor):
40
+ * elite ≥1/día (7/sem) · high ≥1/mes (0.23/sem) · medium ≥1/semestre (0.038/sem) · low <semestral.
41
+ * - leadTimeHoras (commit→tag, menor es mejor): elite <1día · high <1sem · medium <1mes · low ≥1mes.
42
+ * - changeFailureRate (0..1, menor es mejor): elite ≤0.15 · high ≤0.30 · medium ≤0.45 · low >0.45.
43
+ * - mttrHoras (menor es mejor): elite <1h · high <1día · medium <1sem · low ≥1sem.
44
+ */
45
+ const UMBRALES_DORA_2023 = {
46
+ deploymentFrequency: { elite: 7, high: 0.23, medium: 0.038 }, // deploys/semana, mayor mejor
47
+ leadTimeHoras: { elite: 24, high: 168, medium: 730 }, // menor mejor
48
+ changeFailureRate: { elite: 0.15, high: 0.3, medium: 0.45 }, // menor mejor
49
+ mttrHoras: { elite: 1, high: 24, medium: 168 }, // menor mejor
50
+ };
51
+
52
+ const NIVELES = ['elite', 'high', 'medium', 'low'];
53
+
54
+ /**
55
+ * Clasifica un valor de una métrica en elite/high/medium/low.
56
+ * @param {string} metrica - clave de UMBRALES_DORA_2023.
57
+ * @param {number|null|undefined} valor
58
+ * @returns {'elite'|'high'|'medium'|'low'|'sin-datos'}
59
+ */
60
+ function clasificar(metrica, valor) {
61
+ const u = UMBRALES_DORA_2023[metrica];
62
+ if (!u || valor === null || valor === undefined || !Number.isFinite(valor)) return 'sin-datos';
63
+
64
+ if (metrica === 'deploymentFrequency') {
65
+ // mayor es mejor
66
+ if (valor >= u.elite) return 'elite';
67
+ if (valor >= u.high) return 'high';
68
+ if (valor >= u.medium) return 'medium';
69
+ return 'low';
70
+ }
71
+ // menor es mejor (leadTime, cfr, mttr)
72
+ if (valor < u.elite) return 'elite';
73
+ if (valor < u.high) return 'high';
74
+ if (valor < u.medium) return 'medium';
75
+ return 'low';
76
+ }
77
+
78
+ /**
79
+ * Calcula las 4 métricas DORA del proyecto en baseDir.
80
+ * @returns objeto serializable con generado, ventana_dias y las 4 métricas.
81
+ */
82
+ function calcularDora(baseDir = process.cwd(), opts = {}) {
83
+ const { ventanaDias = 30, hoy = new Date(), ejecutorGh } = opts;
84
+
85
+ // Git (siempre)
86
+ const df = gitMetricas.deploymentFrequency(baseDir, { ventanaDias, hoy });
87
+ const lt = gitMetricas.leadTimeForChanges(baseDir, { ventanaDias, hoy });
88
+
89
+ // CI (degrada): una sola llamada a gh. Si no está disponible, NO se re-intenta
90
+ // — se propaga la razón directamente (evita 3 invocaciones del mismo fallo).
91
+ const runsBase = ciReader.obtenerRuns({ ejecutorGh, rama: 'main' });
92
+ const cfr = runsBase.disponible
93
+ ? ciReader.changeFailureRate({ ventanaDias, hoy, runs: runsBase.runs })
94
+ : { disponible: false, razon: runsBase.razon };
95
+ const mttr = runsBase.disponible
96
+ ? ciReader.meanTimeToRestore({ ventanaDias, hoy, runs: runsBase.runs })
97
+ : { disponible: false, razon: runsBase.razon };
98
+
99
+ const deploymentFrequency = df.disponible
100
+ ? {
101
+ disponible: true,
102
+ valor: df.sinDatos ? null : df.deploysPorSemana,
103
+ unidad: 'deploys/semana',
104
+ clasificacion: df.sinDatos ? 'sin-datos' : clasificar('deploymentFrequency', df.deploysPorSemana),
105
+ sinDatos: !!df.sinDatos,
106
+ }
107
+ : { disponible: false, razon: df.razon };
108
+
109
+ const leadTime = lt.disponible
110
+ ? {
111
+ disponible: true,
112
+ p50Horas: lt.sinDatos ? null : lt.p50Horas,
113
+ p95Horas: lt.sinDatos ? null : lt.p95Horas,
114
+ clasificacion: lt.sinDatos ? 'sin-datos' : clasificar('leadTimeHoras', lt.p50Horas),
115
+ sinDatos: !!lt.sinDatos,
116
+ }
117
+ : { disponible: false, razon: lt.razon };
118
+
119
+ const changeFailureRate = cfr.disponible
120
+ ? {
121
+ disponible: true,
122
+ valor: cfr.sinDatos ? null : cfr.cfr,
123
+ clasificacion: cfr.sinDatos ? 'sin-datos' : clasificar('changeFailureRate', cfr.cfr),
124
+ sinDatos: !!cfr.sinDatos,
125
+ totalRuns: cfr.totalRuns,
126
+ fallidos: cfr.fallidos,
127
+ }
128
+ : { disponible: false, razon: cfr.razon };
129
+
130
+ const mttrOut = mttr.disponible
131
+ ? {
132
+ disponible: true,
133
+ mttrHoras: mttr.sinDatos ? null : mttr.mttrHoras,
134
+ clasificacion: mttr.sinDatos ? 'sin-datos' : clasificar('mttrHoras', mttr.mttrHoras),
135
+ sinDatos: !!mttr.sinDatos,
136
+ recuperaciones: mttr.recuperaciones,
137
+ }
138
+ : { disponible: false, razon: mttr.razon };
139
+
140
+ return {
141
+ generado: hoy.toISOString(),
142
+ ventana_dias: ventanaDias,
143
+ deploymentFrequency,
144
+ leadTime,
145
+ changeFailureRate,
146
+ mttr: mttrOut,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Persiste el informe DORA en `<baseDir>/.planning/evolution/metricas-dora.json`.
152
+ * `baseDir` se trata como ruta CONFIABLE del invocador (CLI local / cwd); NUNCA
153
+ * debe propagarse desde input web/HTTP/PR. Aun así se valida contención bajo
154
+ * baseDir para evitar write-anywhere si el path subiera de nivel (H-01).
155
+ */
156
+ function persistir(baseDir, dora) {
157
+ const base = path.resolve(baseDir);
158
+ const destino = path.resolve(base, STORAGE_REL);
159
+ if (destino !== base && !destino.startsWith(base + path.sep)) {
160
+ throw new Error('destino de métricas DORA fuera de baseDir');
161
+ }
162
+ atomicWriteJSON(destino, dora);
163
+ return destino;
164
+ }
165
+
166
+ module.exports = {
167
+ clasificar,
168
+ calcularDora,
169
+ persistir,
170
+ UMBRALES_DORA_2023,
171
+ NIVELES,
172
+ STORAGE_REL,
173
+ };
174
+
175
+ // Entrypoint CLI: node scripts/lib/metricas-dora.js [--json] [--dias=N] [baseDir]
176
+ if (!require.main || require.main === module) {
177
+ const args = process.argv.slice(2);
178
+ const json = args.includes('--json');
179
+ const diasArg = args.find((a) => a.startsWith('--dias='));
180
+ const ventanaDias = Math.max(1, Math.min(365, (diasArg ? parseInt(diasArg.slice('--dias='.length), 10) : 30) || 30));
181
+ const baseDir = args.find((a) => !a.startsWith('--')) || process.cwd();
182
+
183
+ const dora = calcularDora(baseDir, { ventanaDias });
184
+ try {
185
+ persistir(baseDir, dora);
186
+ } catch {
187
+ /* persistencia best-effort: no romper el informe si el disco falla */
188
+ }
189
+
190
+ if (json) {
191
+ process.stdout.write(JSON.stringify(dora, null, 2) + '\n');
192
+ } else {
193
+ const fmt = (txt) => process.stdout.write(`${txt}\n`);
194
+ const df = dora.deploymentFrequency;
195
+ const lt = dora.leadTime;
196
+ const cfr = dora.changeFailureRate;
197
+ const mt = dora.mttr;
198
+ fmt(`Deployment frequency: ${df.disponible ? (df.sinDatos ? 'sin datos' : `${df.valor.toFixed(2)}/sem [${df.clasificacion}]`) : 'no disponible'}`);
199
+ fmt(`Lead time (p50): ${lt.disponible ? (lt.sinDatos ? 'sin datos' : `${lt.p50Horas.toFixed(1)}h [${lt.clasificacion}]`) : 'no disponible'}`);
200
+ fmt(`Change failure rate: ${cfr.disponible ? (cfr.sinDatos ? 'sin datos' : `${(cfr.valor * 100).toFixed(1)}% [${cfr.clasificacion}]`) : 'no disponible (gh ausente)'}`);
201
+ fmt(`MTTR: ${mt.disponible ? (mt.sinDatos ? 'sin datos' : `${mt.mttrHoras.toFixed(1)}h [${mt.clasificacion}]`) : 'no disponible (gh ausente)'}`);
202
+ }
203
+ process.exit(0);
204
+ }
@@ -47,7 +47,7 @@ function _calcularTotalesDelPlan(opciones) {
47
47
  // mediante resolverPerfil, que devuelve { perfil, modulos, archivos, warnings }.
48
48
  try {
49
49
  const { resolverPerfil } = require('../lib/manifiestos');
50
- const resolucion = resolverPerfil(opciones.profile || 'core', {});
50
+ const resolucion = resolverPerfil(opciones.profile || 'completo', {});
51
51
  if (!resolucion || !Array.isArray(resolucion.archivos)) return {};
52
52
  const totales = {};
53
53
  for (const archivo of resolucion.archivos) {
@@ -149,6 +149,88 @@ function verificarModulosReferenciadosPorPerfil(modulos, perfiles) {
149
149
  return false;
150
150
  }
151
151
 
152
+ function fragmentosDeclaradosEnAgente(contenido) {
153
+ // Extrae los nombres de fragmento (_x) del frontmatter `fragmentos:` de un
154
+ // agente. Soporta lista multilínea (`fragmentos:\n - _x`) e inline
155
+ // (`fragmentos: [_x, _y]`).
156
+ const fm = contenido.match(/^---\r?\n([\s\S]*?)\r?\n---/);
157
+ if (!fm) return [];
158
+ const lineas = fm[1].split(/\r?\n/);
159
+ const out = [];
160
+ let dentro = false;
161
+ for (const l of lineas) {
162
+ const inline = l.match(/^fragmentos:\s*\[(.*)\]\s*$/);
163
+ if (inline) {
164
+ inline[1].split(',').forEach(s => { const t = s.trim(); if (t) out.push(t); });
165
+ dentro = false;
166
+ continue;
167
+ }
168
+ if (/^fragmentos:\s*$/.test(l)) { dentro = true; continue; }
169
+ if (dentro) {
170
+ const m = l.match(/^\s*-\s*(_[a-z][a-z0-9-]*)\s*$/);
171
+ if (m) { out.push(m[1]); continue; }
172
+ if (/^\S/.test(l)) dentro = false; // siguiente clave de primer nivel
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function verificarFragmentos(declarados) {
179
+ // DT-FRAGMENTOS-VALIDACION (Fase 13): dos invariantes que la regla
180
+ // fragmentos-compartidos.md declara y que antes no se enforzaban:
181
+ // 1. Todo fragmento agentes/_*.md debe estar referenciado por ≥2 agentes.
182
+ // 2. Ningún fragmento agentes/_*.md debe aparecer en modulos.json (no es agente).
183
+ const base = path.join(RAIZ, 'agentes');
184
+ if (!fs.existsSync(base)) {
185
+ log(' [OK] fragmentos: sin directorio agentes/ (nada que validar)');
186
+ return true;
187
+ }
188
+ const fragmentos = fs.readdirSync(base)
189
+ .filter(f => f.startsWith('_') && f.endsWith('.md'))
190
+ .map(f => f.replace(/\.md$/, '')); // '_propose-step'
191
+
192
+ // Contar referencias por fragmento en los agentes reales (no fragmentos).
193
+ const referencias = new Map(fragmentos.map(f => [f, 0]));
194
+ const agentes = fs.readdirSync(base)
195
+ .filter(f => f.endsWith('.md') && !f.startsWith('_'));
196
+ for (const ag of agentes) {
197
+ const refs = fragmentosDeclaradosEnAgente(fs.readFileSync(path.join(base, ag), 'utf-8'));
198
+ for (const r of refs) {
199
+ if (referencias.has(r)) referencias.set(r, referencias.get(r) + 1);
200
+ }
201
+ }
202
+
203
+ let ok = true;
204
+
205
+ // Check 1: ≥2 referencias.
206
+ const subreferenciados = fragmentos.filter(f => referencias.get(f) < 2);
207
+ if (subreferenciados.length === 0) {
208
+ log(` [OK] fragmentos: ${fragmentos.length}/${fragmentos.length} referenciados por ≥2 agentes`);
209
+ } else {
210
+ ok = false;
211
+ log(` [FALLA] fragmentos: ${subreferenciados.length} referenciado(s) por <2 agentes`);
212
+ for (const f of subreferenciados) {
213
+ log(` → agentes/${f}.md (referencias: ${referencias.get(f)})`);
214
+ errores.push({ categoria: 'fragmentos', archivo: `agentes/${f}.md`, tipo: 'fragmento_subreferenciado' });
215
+ }
216
+ }
217
+
218
+ // Check 2: no en modulos.json.
219
+ const enModulos = fragmentos.filter(f => declarados.has(`agentes/${f}.md`));
220
+ if (enModulos.length === 0) {
221
+ log(' [OK] fragmentos: ninguno declarado como agente en modulos.json');
222
+ } else {
223
+ ok = false;
224
+ log(` [FALLA] fragmentos: ${enModulos.length} declarado(s) en modulos.json (no son agentes)`);
225
+ for (const f of enModulos) {
226
+ log(` → agentes/${f}.md`);
227
+ errores.push({ categoria: 'fragmentos', archivo: `agentes/${f}.md`, tipo: 'fragmento_en_modulos' });
228
+ }
229
+ }
230
+
231
+ return ok;
232
+ }
233
+
152
234
  function verificarDeclarados1a1ExistenEnDisco(declarados) {
153
235
  // Caso inverso: declarados en manifest pero no presentes en disco
154
236
  const rotos = [];
@@ -205,6 +287,9 @@ function main() {
205
287
  const perfilOk = verificarModulosReferenciadosPorPerfil(modulos, perfiles);
206
288
  if (!perfilOk) todoOk = false;
207
289
 
290
+ const fragmentosOk = verificarFragmentos(declarados);
291
+ if (!fragmentosOk) todoOk = false;
292
+
208
293
  const inversoOk = verificarDeclarados1a1ExistenEnDisco(declarados);
209
294
  if (!inversoOk) todoOk = false;
210
295
 
@@ -219,6 +304,8 @@ function main() {
219
304
  log(' - "no_declarado_hooks_config": registrar el hook en manifiestos/hooks-config.json');
220
305
  log(' - "modulo_huerfano_sin_perfil": agregar el módulo al array `modulos` de al menos un perfil en manifiestos/perfiles.json');
221
306
  log(' - "declarado_no_existe": eliminar la ruta de modulos.json (archivo borrado) o crear el archivo');
307
+ log(' - "fragmento_subreferenciado": el fragmento agentes/_*.md debe ser usado por ≥2 agentes (declararlo en su frontmatter `fragmentos:`) o re-incrustarlo en el único agente que lo usa');
308
+ log(' - "fragmento_en_modulos": quitar el fragmento agentes/_*.md de modulos.json (no es un agente routable)');
222
309
  }
223
310
 
224
311
  if (salidaJson) {
@@ -228,4 +315,8 @@ function main() {
228
315
  process.exit(todoOk ? 0 : 1);
229
316
  }
230
317
 
231
- main();
318
+ if (require.main === module) {
319
+ main();
320
+ }
321
+
322
+ module.exports = { fragmentosDeclaradosEnAgente };
@@ -33,6 +33,26 @@ const fs = require('fs');
33
33
  const path = require('path');
34
34
  const { execSync } = require('child_process');
35
35
 
36
+ /**
37
+ * Detecta si `dir` es la raíz del paquete swl-ses (repo madre).
38
+ *
39
+ * Espeja la excepción de `markAsEvolved` (hooks/lib/evolution-tracker.js):
40
+ * en el repo madre los cambios del mantenedor se rastrean por git + bump de
41
+ * `version`, NO por metadatos `evolved-*` — el tracker se NIEGA a marcarlos.
42
+ * Antes de este fix, los CHECKs 2/3 exigían aquí lo que el tracker rechaza
43
+ * escribir → el gate del Paso 6 de /swl:aprender era insatisfacible en el
44
+ * repo madre (detectado 2026-06-12, aprendizaje M4). El set de nombres debe
45
+ * mantenerse alineado con `isPackageRoot` del tracker.
46
+ */
47
+ function esRaizDelPaquete(dir) {
48
+ try {
49
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir || process.cwd(), 'package.json'), 'utf8'));
50
+ return pkg.name === '@saulwade/swl-ses' || pkg.name === 'swl-ses';
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
36
56
  /** Extrae el bloque de frontmatter YAML del inicio del archivo. */
37
57
  function extraerFrontmatter(contenido) {
38
58
  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(contenido);
@@ -132,8 +152,16 @@ function archivosModificados(since) {
132
152
  }
133
153
  }
134
154
 
135
- /** Verifica un archivo concreto. Devuelve `{ archivo, problemas[], info }`. */
136
- function verificarArchivo(filePath) {
155
+ /**
156
+ * Verifica un archivo concreto. Devuelve `{ archivo, problemas[], info }`.
157
+ * @param {object} [opts] - `opts.raizPaquete` fuerza/inyecta la detección de
158
+ * repo madre (default: `esRaizDelPaquete(process.cwd())`). En repo madre los
159
+ * CHECKs 2/3 (evolved) se omiten — solo aplican version presente + bump.
160
+ */
161
+ function verificarArchivo(filePath, opts = {}) {
162
+ const raizPaquete = opts.raizPaquete !== undefined
163
+ ? opts.raizPaquete
164
+ : esRaizDelPaquete(process.cwd());
137
165
  const resultado = { archivo: filePath, problemas: [], info: {} };
138
166
 
139
167
  if (!fs.existsSync(filePath)) {
@@ -186,7 +214,15 @@ function verificarArchivo(filePath) {
186
214
  // CHECK 2: evolved = true (en frontmatter o en .evolved.json)
187
215
  // v1.6.1 (Cabo A4): exento si deprecated:true — no tiene sentido exigir
188
216
  // metadatos de evolución para componentes que se van a eliminar.
189
- if (ev.evolved !== 'true' && !esDeprecated) {
217
+ // Exento también en el repo madre: markAsEvolved se NIEGA a marcar ahí
218
+ // (los cambios del mantenedor se rastrean por git + bump de version), así
219
+ // que exigirlo haría el gate insatisfacible. Ver esRaizDelPaquete().
220
+ if (raizPaquete) {
221
+ resultado.info.notas = resultado.info.notas || [];
222
+ resultado.info.notas.push(
223
+ 'raíz del paquete: checks evolved omitidos (cambios del mantenedor = git + bump)'
224
+ );
225
+ } else if (ev.evolved !== 'true' && !esDeprecated) {
190
226
  resultado.problemas.push(
191
227
  '`evolved` no encontrado (ni en frontmatter ni en .evolved.json del directorio)'
192
228
  );
@@ -284,6 +320,13 @@ function main() {
284
320
  archivos = args.filter(a => !a.startsWith('--'));
285
321
  }
286
322
 
323
+ if (esRaizDelPaquete(process.cwd())) {
324
+ process.stdout.write(
325
+ '[nota] Raíz del paquete swl-ses: los checks de `evolved` se omiten — ' +
326
+ 'los cambios del mantenedor se rastrean por git + bump de `version`.\n'
327
+ );
328
+ }
329
+
287
330
  let fallos = 0;
288
331
  for (const archivo of archivos) {
289
332
  const r = verificarArchivo(archivo);
@@ -302,4 +345,11 @@ function main() {
302
345
 
303
346
  if (require.main === module) main();
304
347
 
305
- module.exports = { verificarArchivo, extraerFrontmatter, leerCampo, obtenerVersionEnHEAD, main };
348
+ module.exports = {
349
+ verificarArchivo,
350
+ extraerFrontmatter,
351
+ leerCampo,
352
+ obtenerVersionEnHEAD,
353
+ esRaizDelPaquete,
354
+ main,
355
+ };