@saulwade/swl-ses 1.8.0 → 2.0.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 (135) hide show
  1. package/CLAUDE.md +8 -8
  2. package/README.md +13 -13
  3. package/agentes/accesibilidad-wcag-swl.md +3 -3
  4. package/agentes/auto-evolucion-swl.md +908 -908
  5. package/agentes/disenador-ui-swl.md +6 -5
  6. package/agentes/frontend-angular-swl.md +2 -2
  7. package/agentes/frontend-css-swl.md +2 -2
  8. package/agentes/frontend-react-swl.md +4 -4
  9. package/agentes/frontend-swl.md +6 -6
  10. package/agentes/investigador-ux-swl.md +5 -5
  11. package/agentes/orquestador-swl.md +96 -8
  12. package/agentes/perfilador-usuario-swl.md +308 -308
  13. package/agentes/producto-prd-swl.md +1 -1
  14. package/agentes/red-team-swl.md +218 -218
  15. package/agentes/revisor-codigo-swl.md +34 -10
  16. package/agentes/revisor-seguridad-swl.md +7 -0
  17. package/agentes/tdd-qa-swl.md +39 -2
  18. package/comandos/swl/actualizar.md +1 -1
  19. package/comandos/swl/aprender.md +2 -2
  20. package/comandos/swl/aprobar-plan.md +152 -0
  21. package/comandos/swl/autoresearch.md +102 -6
  22. package/comandos/swl/ayuda.md +3 -3
  23. package/comandos/swl/discutir-fase.md +20 -2
  24. package/comandos/swl/ejecutar-fase.md +53 -6
  25. package/comandos/swl/evolucionar.md +1 -1
  26. package/comandos/swl/inbox.md +1 -1
  27. package/comandos/swl/instalar.md +1 -1
  28. package/comandos/swl/nemesis.md +42 -1
  29. package/comandos/swl/planear-fase.md +25 -1
  30. package/comandos/swl/plugins.md +1 -1
  31. package/comandos/swl/predecir.md +139 -0
  32. package/comandos/swl/release.md +1 -1
  33. package/comandos/swl/status.md +279 -0
  34. package/comandos/swl/verificar.md +75 -7
  35. package/habilidades/ai-runtime-security/SKILL.md +1 -1
  36. package/habilidades/angular-moderno/SKILL.md +44 -1
  37. package/habilidades/auto-evolucion-protocolo/SKILL.md +276 -276
  38. package/habilidades/autoresearch/SKILL.md +15 -1
  39. package/habilidades/benchmark-memoria/SKILL.md +1 -1
  40. package/habilidades/calidad-contract-testing/SKILL.md +165 -0
  41. package/habilidades/calidad-mutation-testing/SKILL.md +170 -0
  42. package/habilidades/changelog-generator/SKILL.md +9 -2
  43. package/habilidades/changelog-generator/scripts/parse-commits.js +12 -1
  44. package/habilidades/checklist-seguridad/SKILL.md +29 -1
  45. package/habilidades/checklist-seguridad/recursos/stride-cobertura.md +60 -0
  46. package/habilidades/css-moderno/SKILL.md +3 -1
  47. package/habilidades/diagrama-arquitectura/SKILL.md +1 -1
  48. package/habilidades/drift-detection/SKILL.md +179 -179
  49. package/habilidades/ejecutar-fase/SKILL.md +64 -14
  50. package/habilidades/estructura-proyecto-claude/SKILL.md +17 -14
  51. package/habilidades/estructura-proyecto-claude/recursos/configuracion-y-extensiones.md +34 -23
  52. package/habilidades/estructura-proyecto-claude/recursos/frontmatter-y-hooks-referencia.md +70 -53
  53. package/habilidades/estructura-proyecto-claude/recursos/mcp-json-template.json +57 -77
  54. package/habilidades/extractor-de-aprendizajes/SKILL.md +9 -5
  55. package/habilidades/fastapi-experto/SKILL.md +56 -5
  56. package/habilidades/harness-claude-code/SKILL.md +10 -7
  57. package/{reglas/harness-claude-code.md → habilidades/harness-claude-code/recursos/disciplina-harness-regla.md} +2 -2
  58. package/habilidades/instalar-sistema/SKILL.md +3 -3
  59. package/habilidades/meta-skills-estandar/recursos/frameworks-seguridad.md +1 -1
  60. package/habilidades/patrones-python/SKILL.md +8 -5
  61. package/habilidades/perfil-usuario/SKILL.md +200 -200
  62. package/habilidades/planear-fase/SKILL.md +25 -4
  63. package/habilidades/proceso-ddia-fundamentos/SKILL.md +1 -1
  64. package/habilidades/proceso-ddia-streaming/SKILL.md +4 -4
  65. package/habilidades/proceso-debate-adversarial/SKILL.md +164 -0
  66. package/habilidades/proceso-debate-adversarial/recursos/personas.md +105 -0
  67. package/habilidades/proceso-dynamic-workflows/SKILL.md +138 -0
  68. package/habilidades/proceso-dynamic-workflows/recursos/template-adversarial-verify.js +65 -0
  69. package/habilidades/proceso-dynamic-workflows/recursos/template-triage.js +65 -0
  70. package/habilidades/protocolo-revision-swl/SKILL.md +1 -1
  71. package/habilidades/seguridad-skills-ia/SKILL.md +1 -1
  72. package/habilidades/swl-claudemd/SKILL.md +50 -210
  73. package/habilidades/swl-claudemd/recursos/contrato-aprender.md +83 -0
  74. package/habilidades/swl-claudemd/recursos/duplicacion-reglas-globales.md +85 -0
  75. package/habilidades/swl-claudemd/recursos/plantillas-init.md +94 -0
  76. package/habilidades/swl-dashboard/SKILL.md +9 -9
  77. package/habilidades/swl-revisar-impacto/SKILL.md +1 -1
  78. package/habilidades/tdd-workflow/SKILL.md +58 -5
  79. package/habilidades/tdd-workflow/recursos/gherkin-bdd.md +111 -0
  80. package/habilidades/validacion-ci-sistema/SKILL.md +3 -3
  81. package/hooks/calidad-pre-commit.js +340 -3
  82. package/hooks/ciclo-evolucion-subagente.js +26 -0
  83. package/hooks/ciclo-evolucion.js +26 -0
  84. package/hooks/contexto-iteracion.js +144 -0
  85. package/hooks/extraccion-aprendizajes.js +13 -0
  86. package/hooks/lib/ciclo-evolucion.js +47 -0
  87. package/hooks/{auto-evolucion.js → lib/etapa-auto-evolucion.js} +701 -700
  88. package/hooks/{metricas-evolucion.js → lib/etapa-metricas.js} +388 -376
  89. package/hooks/{actualizar-perfil-usuario.js → lib/etapa-perfil-usuario.js} +376 -364
  90. package/hooks/lib/evolution-tracker.js +24 -3
  91. package/hooks/lib/loop-telemetry.js +321 -0
  92. package/hooks/notificacion-telegram.js +11 -3
  93. package/hooks/spec-gate.js +211 -0
  94. package/hooks/tdd-gate.js +241 -0
  95. package/hooks/validar-intent-spec.js +30 -10
  96. package/llms.txt +29 -0
  97. package/manifiestos/hooks-config.json +36 -18
  98. package/manifiestos/modulos.json +23 -14
  99. package/manifiestos/skills-lock.json +100 -72
  100. package/package.json +4 -3
  101. package/plugin.json +9 -10
  102. package/reglas/accesibilidad.md +10 -0
  103. package/reglas/api-diseno.md +9 -0
  104. package/reglas/arquitectura.evolved.json +7 -0
  105. package/reglas/arquitectura.md +65 -0
  106. package/reglas/auditorias-documentales-estructurales.md +7 -0
  107. package/reglas/cloud-infra.md +8 -0
  108. package/reglas/fragmentos-compartidos.md +5 -0
  109. package/reglas/gobernanza.md +4 -4
  110. package/reglas/hooks.md +6 -0
  111. package/reglas/intent-engineering.md +4 -0
  112. package/reglas/markitdown.md +8 -0
  113. package/reglas/memoria-consolidada.md +1 -1
  114. package/reglas/patrones.md +6 -0
  115. package/reglas/registro-componentes-nuevos.md +10 -1
  116. package/reglas/seguridad-agentes.md +1 -1
  117. package/reglas/seguridad.evolved.json +7 -0
  118. package/reglas/seguridad.md +144 -0
  119. package/reglas/skills-estandar.md +6 -0
  120. package/reglas/testing.md +7 -0
  121. package/reglas/tests-cleanup.md +4 -0
  122. package/reglas/usar-sistema-swl.md +1 -1
  123. package/scripts/generar-inventario.js +64 -1
  124. package/scripts/instalador.js +32 -2
  125. package/scripts/lib/gitignore-manifest.js +29 -1
  126. package/scripts/lib/plan-lock.js +275 -0
  127. package/scripts/migrar-fase-dominio.js +0 -1
  128. package/scripts/smoke-test.js +24 -2
  129. package/scripts/verificar-trazabilidad.js +292 -0
  130. package/agentes/ux-disenador-swl.md +0 -503
  131. package/comandos/swl/dashboard.md +0 -146
  132. package/comandos/swl/evolucion-estado.md +0 -191
  133. package/comandos/swl/metricas.md +0 -342
  134. package/comandos/swl/salud.md +0 -481
  135. package/reglas/verificar-citas-temporales.md +0 -139
@@ -64,6 +64,7 @@ const EXCLUDED_FILENAME_PATTERNS = [
64
64
  /\.rej$/, // patch reject
65
65
  /\.merge_file_/, // merge tools (kdiff3, etc.)
66
66
  /~$/, // editores tipo Emacs/Vim
67
+ /\.evolved-diff\.(md|txt)$/, // diffs de merge (no son componentes; .md legacy)
67
68
  ];
68
69
 
69
70
  /**
@@ -407,7 +408,13 @@ const DIFF_NOISY_THRESHOLD = 50;
407
408
  *
408
409
  * Estrategia: toma el archivo nuevo como base y re-aplica los campos de
409
410
  * evolución (frontmatter evolved-*). Las mutaciones de contenido se preservan
410
- * generando un archivo .evolved-diff.md que Claude puede re-aplicar.
411
+ * generando un archivo `.evolved-diff.txt` que Claude puede re-aplicar.
412
+ *
413
+ * Extensión `.txt` (no `.md`) deliberada: el diff vive junto al componente
414
+ * evolucionado (incluyendo `commands/`), pero el harness de Claude Code indexa
415
+ * todo `.md` dentro de `commands/` como slash-command — un `aprender.evolved-diff.md`
416
+ * aparecería como `/swl:aprender.evolved-diff`. Con `.txt` el harness no lo indexa
417
+ * y `scanEvolved` (que solo recorre `.md`) tampoco lo confunde con un componente.
411
418
  *
412
419
  * Comparación: solo el body (post-frontmatter) se compara línea-a-línea.
413
420
  * El frontmatter SIEMPRE diverge (el destino tiene campos `evolved-*` que el
@@ -415,7 +422,8 @@ const DIFF_NOISY_THRESHOLD = 50;
415
422
  * contarlo como mutación genera ruido por desplazamiento.
416
423
  *
417
424
  * Limpieza: cuando un merge posterior elimina la divergencia (diffs vacíos),
418
- * borra el `.evolved-diff.md` huérfano de sesiones previas si existe.
425
+ * borra el `.evolved-diff.txt` huérfano de sesiones previas si existe (y el
426
+ * `.evolved-diff.md` legacy de versiones anteriores a esta corrección).
419
427
  *
420
428
  * Cap defensivo: si tras alinear correctamente el body aún hay más de
421
429
  * `DIFF_NOISY_THRESHOLD` líneas distintas, genera un resumen estadístico
@@ -470,7 +478,17 @@ function mergeEvolved(destino, origen, versionNueva) {
470
478
  }
471
479
  }
472
480
 
473
- const diffPath = destino.replace(/\.md$/, '.evolved-diff.md');
481
+ const diffPath = destino.replace(/\.md$/, '.evolved-diff.txt');
482
+ // Legacy: versiones previas escribían el diff como `.evolved-diff.md`, que
483
+ // el harness indexaba como slash-command. Se limpia siempre que se toca el
484
+ // componente, exista o no divergencia nueva.
485
+ const diffPathLegacy = destino.replace(/\.md$/, '.evolved-diff.md');
486
+ const limpiarLegacy = () => {
487
+ if (fs.existsSync(diffPathLegacy)) {
488
+ try { fs.unlinkSync(diffPathLegacy); return true; } catch { /* best-effort */ }
489
+ }
490
+ return false;
491
+ };
474
492
 
475
493
  if (diffs.length === 0) {
476
494
  // Sin diferencias reales — limpiar diff huérfano si existe (de sesión
@@ -486,6 +504,7 @@ function mergeEvolved(destino, origen, versionNueva) {
486
504
  // el merge sigue siendo válido.
487
505
  }
488
506
  }
507
+ if (limpiarLegacy()) cleanedDiff = true;
489
508
 
490
509
  // force: true — `mergeEvolved` solo se invoca en contexto de update
491
510
  // intencional. El skip de isPackageRoot() aplica a la primera marca
@@ -557,6 +576,8 @@ function mergeEvolved(destino, origen, versionNueva) {
557
576
  ].join('\n');
558
577
 
559
578
  atomicWriteSync(diffPath, diffContent, 'utf8');
579
+ // Si existía el `.evolved-diff.md` legacy, eliminarlo: el `.txt` lo reemplaza.
580
+ limpiarLegacy();
560
581
 
561
582
  return { merged: true, diffPath, diffsCount: diffs.length, truncated };
562
583
  } catch (err) {
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Loop Telemetry — Registro tabular de iteraciones para loops autónomos.
5
+ *
6
+ * Da trazabilidad a cualquier loop iterativo del sistema (autoresearch,
7
+ * /swl:verificar --until-converge, /swl:nemesis --remediar): cada corrida
8
+ * deja una carpeta en .planning/loops/ con un TSV por iteración, un
9
+ * handoff.json para encadenamiento entre comandos, y utilidades de análisis
10
+ * de trayectoria (plateau, revert rate, mejora acumulada).
11
+ *
12
+ * Patrón adoptado del análisis de autoresearch v2.1 (temp/autoresearch-master,
13
+ * sesión 2026-06-10): TSV por iteración + handoff.json + detección de plateau.
14
+ * Adaptado a convenciones SWL: estado en .planning/ (no /tmp), escrituras
15
+ * atómicas para JSON, append para el TSV (alta frecuencia relativa).
16
+ *
17
+ * Formato del TSV (línea 1 = comentario de dirección, línea 2 = header):
18
+ *
19
+ * # metric_direction: higher_is_better
20
+ * iteracion timestamp metrica delta estado descripcion
21
+ * 0 2026-06-10T20:00:00Z 62 0 baseline estado inicial
22
+ * 1 2026-06-10T20:05:00Z 65 3 keep agrega test de frontera X
23
+ *
24
+ * Estados canónicos: baseline | keep | revert | crash | no-op | error-metrica
25
+ *
26
+ * Zero-deps (solo node:fs/path y hooks/lib/atomic-write).
27
+ *
28
+ * @module hooks/lib/loop-telemetry
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { atomicWriteSync, atomicWriteJSON } = require('./atomic-write');
34
+
35
+ const DIR_LOOPS = path.join('.planning', 'loops');
36
+ const COLUMNAS_DEFAULT = ['iteracion', 'timestamp', 'metrica', 'delta', 'estado', 'descripcion'];
37
+ const DIRECCIONES = ['higher_is_better', 'lower_is_better'];
38
+ const VERSION_HANDOFF = '1.0-swl';
39
+
40
+ /** Sanitiza un valor para celda TSV: sin tabs ni saltos de línea. */
41
+ function sanitizarCelda(valor) {
42
+ if (valor === null || valor === undefined) return '';
43
+ return String(valor).replace(/[\t\r\n]+/g, ' ').trim();
44
+ }
45
+
46
+ function timestampCompacto(fecha) {
47
+ const f = fecha || new Date();
48
+ const pad = (n) => String(n).padStart(2, '0');
49
+ return (
50
+ String(f.getFullYear()).slice(2) + pad(f.getMonth() + 1) + pad(f.getDate()) +
51
+ '-' + pad(f.getHours()) + pad(f.getMinutes()) + pad(f.getSeconds())
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Inicia una corrida de loop: crea .planning/loops/<tipo>-<YYMMDD-HHMMSS>/
57
+ * con iteraciones.tsv (comentario de dirección + header) y meta.json.
58
+ *
59
+ * @param {object} opciones
60
+ * @param {string} opciones.tipo - Identificador del loop (ej: 'autoresearch', 'nemesis', 'verificar').
61
+ * @param {string} [opciones.direccion='higher_is_better'] - Dirección de la métrica.
62
+ * @param {string[]} [opciones.columnas] - Columnas del TSV (default COLUMNAS_DEFAULT).
63
+ * @param {string} [opciones.raiz=process.cwd()] - Raíz del proyecto.
64
+ * @param {object} [opciones.config={}] - Config inicial persistida en meta.json.
65
+ * @returns {{dir: string, tsvPath: string}}
66
+ */
67
+ function iniciarCorrida({ tipo, direccion = 'higher_is_better', columnas, raiz = process.cwd(), config = {} }) {
68
+ if (!tipo || !/^[a-z0-9-]+$/.test(tipo)) {
69
+ throw new TypeError(`loop-telemetry: tipo inválido "${tipo}" — usar kebab-case (ej: "autoresearch")`);
70
+ }
71
+ if (!DIRECCIONES.includes(direccion)) {
72
+ throw new TypeError(`loop-telemetry: dirección inválida "${direccion}" — usar ${DIRECCIONES.join(' | ')}`);
73
+ }
74
+ const cols = Array.isArray(columnas) && columnas.length > 0 ? columnas : COLUMNAS_DEFAULT;
75
+
76
+ // Colisiones en el mismo segundo: el sufijo se agrega sobre la base
77
+ // inmutable (tipo-YYMMDD-HHMMSS-N). NUNCA recortar del nombre acumulado —
78
+ // un replace de "último bloque numérico" se come el HHMMSS del timestamp
79
+ // y rompe la extracción de tipo en corridaActiva().
80
+ const base = path.join(raiz, DIR_LOOPS, `${tipo}-${timestampCompacto()}`);
81
+ let dir = base;
82
+ let sufijo = 1;
83
+ while (fs.existsSync(dir)) {
84
+ sufijo += 1;
85
+ dir = `${base}-${sufijo}`;
86
+ }
87
+ fs.mkdirSync(dir, { recursive: true });
88
+
89
+ const tsvPath = path.join(dir, 'iteraciones.tsv');
90
+ // Header del TSV con escritura atómica: un header truncado rompe
91
+ // parsearTsv() para toda la vida de la corrida. Las filas posteriores sí
92
+ // van con appendFileSync (excepción sancionada para appends).
93
+ atomicWriteSync(tsvPath, `# metric_direction: ${direccion}\n${cols.join('\t')}\n`);
94
+
95
+ atomicWriteJSON(path.join(dir, 'meta.json'), {
96
+ tipo,
97
+ direccion,
98
+ columnas: cols,
99
+ iniciada: new Date().toISOString(),
100
+ config,
101
+ });
102
+
103
+ return { dir, tsvPath };
104
+ }
105
+
106
+ /**
107
+ * Registra una iteración: agrega una fila al TSV de la corrida.
108
+ * Las columnas se toman del header del TSV; campos ausentes quedan vacíos.
109
+ *
110
+ * @param {string} dir - Directorio de la corrida (retornado por iniciarCorrida).
111
+ * @param {object} fila - Objeto con valores por columna (ej: {iteracion: 1, metrica: 65, estado: 'keep'}).
112
+ */
113
+ function registrarIteracion(dir, fila) {
114
+ const tsvPath = path.join(dir, 'iteraciones.tsv');
115
+ const { columnas } = parsearTsv(tsvPath);
116
+ const valores = columnas.map((col) => {
117
+ if (col === 'timestamp' && !(col in fila)) return new Date().toISOString();
118
+ return sanitizarCelda(fila[col]);
119
+ });
120
+ fs.appendFileSync(tsvPath, valores.join('\t') + '\n', 'utf8');
121
+ }
122
+
123
+ /** Parsea el TSV de una corrida. @returns {{direccion: string, columnas: string[], filas: object[]}} */
124
+ function parsearTsv(tsvPath) {
125
+ const contenido = fs.readFileSync(tsvPath, 'utf8');
126
+ const lineas = contenido.split('\n').filter((l) => l.trim().length > 0);
127
+
128
+ let direccion = 'higher_is_better';
129
+ let inicio = 0;
130
+ if (lineas[0] && lineas[0].startsWith('#')) {
131
+ const m = lineas[0].match(/metric_direction:\s*(\S+)/);
132
+ if (m && DIRECCIONES.includes(m[1])) direccion = m[1];
133
+ inicio = 1;
134
+ }
135
+ const columnas = (lineas[inicio] || '').split('\t');
136
+ const filas = lineas.slice(inicio + 1).map((linea) => {
137
+ const celdas = linea.split('\t');
138
+ const obj = {};
139
+ columnas.forEach((col, i) => { obj[col] = celdas[i] !== undefined ? celdas[i] : ''; });
140
+ return obj;
141
+ });
142
+ return { direccion, columnas, filas };
143
+ }
144
+
145
+ /** Lee las iteraciones de una corrida. */
146
+ function leerIteraciones(dir) {
147
+ return parsearTsv(path.join(dir, 'iteraciones.tsv'));
148
+ }
149
+
150
+ /**
151
+ * Detecta plateau: las últimas `ventana` filas con métrica numérica no
152
+ * muestran mejora respecto al mejor valor previo a la ventana.
153
+ *
154
+ * @param {object[]} filas - Filas parseadas del TSV.
155
+ * @param {object} [opciones]
156
+ * @param {number} [opciones.ventana=3]
157
+ * @param {string} [opciones.campo='metrica']
158
+ * @param {string} [opciones.direccion='higher_is_better']
159
+ * @returns {boolean}
160
+ */
161
+ function detectarPlateau(filas, { ventana = 3, campo = 'metrica', direccion = 'higher_is_better' } = {}) {
162
+ const numericas = filas
163
+ .map((f) => parseFloat(f[campo]))
164
+ .filter((n) => Number.isFinite(n));
165
+ if (numericas.length < ventana + 1) return false;
166
+
167
+ const previas = numericas.slice(0, numericas.length - ventana);
168
+ const ultimas = numericas.slice(-ventana);
169
+ const mejor = direccion === 'higher_is_better' ? Math.max(...previas) : Math.min(...previas);
170
+
171
+ return ultimas.every((n) =>
172
+ direccion === 'higher_is_better' ? n <= mejor : n >= mejor
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Analiza la trayectoria completa de una corrida.
178
+ *
179
+ * @param {string} dir - Directorio de la corrida.
180
+ * @returns {{
181
+ * totalIteraciones: number, keeps: number, reverts: number, crashes: number,
182
+ * revertRate: number, metricaInicial: number|null, metricaFinal: number|null,
183
+ * mejora: number|null, plateau: boolean, mayorSalto: {iteracion: string, delta: number}|null,
184
+ * recomendacion: string
185
+ * }}
186
+ */
187
+ function analizarTrayectoria(dir) {
188
+ const { direccion, filas } = leerIteraciones(dir);
189
+ const datos = filas.filter((f) => f.estado !== 'baseline');
190
+ const keeps = datos.filter((f) => f.estado === 'keep').length;
191
+ const reverts = datos.filter((f) => f.estado === 'revert').length;
192
+ const crashes = datos.filter((f) => f.estado === 'crash').length;
193
+ const total = datos.length;
194
+
195
+ const metricas = filas
196
+ .map((f) => parseFloat(f.metrica))
197
+ .filter((n) => Number.isFinite(n));
198
+ const metricaInicial = metricas.length > 0 ? metricas[0] : null;
199
+ const metricaFinal = metricas.length > 0 ? metricas[metricas.length - 1] : null;
200
+ const mejora = metricaInicial !== null && metricaFinal !== null
201
+ ? metricaFinal - metricaInicial
202
+ : null;
203
+
204
+ let mayorSalto = null;
205
+ for (const f of datos) {
206
+ const delta = parseFloat(f.delta);
207
+ if (!Number.isFinite(delta)) continue;
208
+ const magnitud = direccion === 'higher_is_better' ? delta : -delta;
209
+ if (mayorSalto === null || magnitud > (direccion === 'higher_is_better' ? mayorSalto.delta : -mayorSalto.delta)) {
210
+ mayorSalto = { iteracion: f.iteracion, delta };
211
+ }
212
+ }
213
+
214
+ const plateau = detectarPlateau(filas, { direccion });
215
+ const revertRate = total > 0 ? Math.round((reverts / total) * 100) / 100 : 0;
216
+
217
+ let recomendacion;
218
+ if (plateau) {
219
+ recomendacion = 'detener: plateau detectado — las últimas iteraciones no mejoran la métrica';
220
+ } else if (total >= 3 && revertRate >= 0.67) {
221
+ recomendacion = 'cambiar estrategia: la mayoría de las mutaciones se revierten';
222
+ } else if (total === 0) {
223
+ recomendacion = 'sin datos: la corrida no registró iteraciones más allá del baseline';
224
+ } else {
225
+ recomendacion = 'continuar: la trayectoria muestra mejora activa';
226
+ }
227
+
228
+ return {
229
+ totalIteraciones: total, keeps, reverts, crashes, revertRate,
230
+ metricaInicial, metricaFinal, mejora, plateau, mayorSalto, recomendacion,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Escribe handoff.json: contrato de encadenamiento entre comandos iterativos.
236
+ *
237
+ * @param {string} dir - Directorio de la corrida.
238
+ * @param {object} datos
239
+ * @param {string} datos.source - Comando origen (ej: 'swl:nemesis').
240
+ * @param {string} datos.status - COMPLETO | ACOTADO | PLATEAU | INTERRUMPIDO | ERROR
241
+ * @param {object[]} [datos.findings=[]] - Hallazgos transferibles ({id, severidad, archivo_linea, resumen}).
242
+ * @param {object} [datos.config={}] - Config consumible por el comando siguiente.
243
+ */
244
+ function escribirHandoff(dir, { source, status, findings = [], config = {} }) {
245
+ if (!source || !status) {
246
+ throw new TypeError('loop-telemetry: handoff requiere source y status');
247
+ }
248
+ atomicWriteJSON(path.join(dir, 'handoff.json'), {
249
+ version: VERSION_HANDOFF,
250
+ source,
251
+ status,
252
+ timestamp: new Date().toISOString(),
253
+ iteraciones_tsv: 'iteraciones.tsv',
254
+ findings,
255
+ config,
256
+ });
257
+ }
258
+
259
+ /** Lee el handoff.json de una corrida. @returns {object|null} */
260
+ function leerHandoff(dir) {
261
+ try {
262
+ return JSON.parse(fs.readFileSync(path.join(dir, 'handoff.json'), 'utf8'));
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Encuentra la corrida activa más reciente: directorio en .planning/loops/
270
+ * cuyo iteraciones.tsv fue modificado hace menos de maxEdadMin minutos.
271
+ *
272
+ * @param {string} [raiz=process.cwd()]
273
+ * @param {object} [opciones]
274
+ * @param {number} [opciones.maxEdadMin=30]
275
+ * @returns {{dir: string, tipo: string, ultimasFilas: object[], totalFilas: number}|null}
276
+ */
277
+ function corridaActiva(raiz = process.cwd(), { maxEdadMin = 30 } = {}) {
278
+ const base = path.join(raiz, DIR_LOOPS);
279
+ let candidatos;
280
+ try {
281
+ candidatos = fs.readdirSync(base, { withFileTypes: true })
282
+ .filter((e) => e.isDirectory())
283
+ .map((e) => path.join(base, e.name));
284
+ } catch {
285
+ return null;
286
+ }
287
+
288
+ const limite = Date.now() - maxEdadMin * 60 * 1000;
289
+ let mejor = null;
290
+ for (const dir of candidatos) {
291
+ const tsvPath = path.join(dir, 'iteraciones.tsv');
292
+ let stat;
293
+ try { stat = fs.statSync(tsvPath); } catch { continue; }
294
+ if (stat.mtimeMs < limite) continue;
295
+ if (mejor === null || stat.mtimeMs > mejor.mtimeMs) {
296
+ mejor = { dir, mtimeMs: stat.mtimeMs };
297
+ }
298
+ }
299
+ if (!mejor) return null;
300
+
301
+ const { filas } = leerIteraciones(mejor.dir);
302
+ return {
303
+ dir: mejor.dir,
304
+ tipo: path.basename(mejor.dir).replace(/-\d{6}-\d{6}(-\d+)?$/, ''),
305
+ ultimasFilas: filas.slice(-3),
306
+ totalFilas: filas.length,
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ DIR_LOOPS,
312
+ COLUMNAS_DEFAULT,
313
+ iniciarCorrida,
314
+ registrarIteracion,
315
+ leerIteraciones,
316
+ detectarPlateau,
317
+ analizarTrayectoria,
318
+ escribirHandoff,
319
+ leerHandoff,
320
+ corridaActiva,
321
+ };
@@ -3,7 +3,13 @@
3
3
  /**
4
4
  * Hook de notificaciones Telegram para Claude Code.
5
5
  *
6
- * Se dispara en Stop, Notification y SubagentStop.
6
+ * Se dispara en Stop y Notification por default. SubagentStop es opt-in:
7
+ * cuando un turno usa subagentes, SubagentStop dispara casi simultáneo al
8
+ * Stop final y el usuario recibe el mismo contenido dos veces ("Subagente
9
+ * terminó" + "Claude terminó"). Para recibir también las terminaciones de
10
+ * subagentes (útil con agentes largos en background):
11
+ * CLAUDE_NOTIFY_EVENTS=Stop,Notification,SubagentStop
12
+ *
7
13
  * Best-effort: siempre termina con exit 0, nunca bloquea el flujo de Claude.
8
14
  * Registra actividad en ~/.claude/notifications/hook.log (append-only).
9
15
  *
@@ -214,9 +220,11 @@ async function run() {
214
220
 
215
221
  log('evento', nombreEvento, 'proyecto', nombreProyecto);
216
222
 
217
- // Filtrar eventos no deseados (configurable vía variable de entorno)
223
+ // Filtrar eventos no deseados (configurable vía variable de entorno).
224
+ // SubagentStop NO está en el default: duplica la notificación del Stop
225
+ // final cuando el turno usó subagentes (mismo cuerpo, dos mensajes).
218
226
  const eventosActivos = (
219
- process.env.CLAUDE_NOTIFY_EVENTS || 'Stop,Notification,SubagentStop'
227
+ process.env.CLAUDE_NOTIFY_EVENTS || 'Stop,Notification'
220
228
  ).split(',').map(s => s.trim());
221
229
 
222
230
  if (!eventosActivos.includes(nombreEvento)) {
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Hook: spec-gate.js (Gate G0 — SDD)
6
+ * Tipo: PreToolUse (matcher: Write|Edit|MultiEdit)
7
+ *
8
+ * Detecta escrituras de CÓDIGO FUENTE sin fase activa con PLAN aprobado.
9
+ * Implementa el gate G0 del flujo SPEC→PLAN→TEST→CODE→VERIFY (Revisión
10
+ * Evolutiva 03 §5.2): "ningún Write/Edit a código fuente sin fase activa
11
+ * con PLAN aprobado".
12
+ *
13
+ * Modo actual: WARN-ONLY (ADR-0034, decisión D-07 de 10-CONTEXTO.md).
14
+ * - Emite nudge `kind: spec-gate` a .planning/evolution/nudges.jsonl.
15
+ * - NUNCA exit 2. La promoción a blocking se decide vía ADR tras ~2
16
+ * semanas de calibración sin falsos positivos (patrón ADR-0027→0033).
17
+ *
18
+ * Concepto "fase activa": `.planning/locks/fase-activa.json`, escrito por
19
+ * /swl:aprobar-plan al firmar un plan y eliminado por /swl:ejecutar-fase al
20
+ * cerrar la fase. Formato:
21
+ * { numero, planPath, sha256, aprobadoEn, aprobadoPor }
22
+ * El hook re-computa el SHA256 del plan referenciado y lo compara con el del
23
+ * archivo de fase activa — verificación autocontenida (sin dependencia de
24
+ * scripts/lib/, que puede no estar distribuido en proyectos destino).
25
+ *
26
+ * Zero-config: en proyectos sin `.planning/` el hook sale 0 sin ruido.
27
+ * Opt-out: SWL_SPEC_GATE=0 desactiva completamente el hook.
28
+ *
29
+ * Modelo de amenaza (heredado de plan-lock.js): detecta omisión accidental
30
+ * del ciclo GSD y mutación post-firma; NO resiste a un adversario con
31
+ * escritura al repo (puede regenerar fase-activa.json).
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const crypto = require('crypto');
37
+
38
+ const RUTAS_EXCLUIDAS = [
39
+ '/temp/',
40
+ '/node_modules/',
41
+ '/respositorios-git/',
42
+ '/_userland/',
43
+ '/.planning/',
44
+ '/.claude/',
45
+ ];
46
+
47
+ /**
48
+ * Clasificador de código fuente. Reutiliza esArchivoFuente() de
49
+ * calidad-pre-commit.js (misma definición que el gate G3) con fallback
50
+ * defensivo si el módulo no está disponible en el destino.
51
+ */
52
+ function cargarClasificador() {
53
+ try {
54
+ const calidad = require(path.join(__dirname, 'calidad-pre-commit.js'));
55
+ if (typeof calidad.esArchivoFuente === 'function') return calidad.esArchivoFuente;
56
+ } catch (_) {
57
+ // Fallback mínimo: extensiones de código de los lenguajes soportados,
58
+ // excluyendo tests por sufijo/directorio.
59
+ }
60
+ return function esArchivoFuenteFallback(ruta) {
61
+ const normal = ruta.replace(/\\/g, '/');
62
+ if (/\.(test|spec)\.[jt]sx?$/.test(normal)) return false;
63
+ if (/(^|\/)(tests?|__tests__|spec)\//.test(normal)) return false;
64
+ if (/\.(md|json|ya?ml|toml|ini|cfg|conf|txt|svg|lock)$/i.test(normal)) return false;
65
+ return /\.(jsx?|tsx?|py|go|rs|java|kt|cs|rb|php|swift|c|cc|cpp|h|hpp)$/i.test(normal);
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Evalúa el estado de la fase activa del proyecto en `cwd`.
71
+ * @returns {{ ok: boolean, motivo: string|null, numero?: number }}
72
+ * ok=true → hay fase activa con plan íntegro (no advertir)
73
+ * ok=false → motivo describe por qué no hay cobertura de spec
74
+ */
75
+ function evaluarFaseActiva(cwd) {
76
+ const rutaFaseActiva = path.join(cwd, '.planning', 'locks', 'fase-activa.json');
77
+ if (!fs.existsSync(rutaFaseActiva)) {
78
+ return { ok: false, motivo: 'sin fase activa (no existe .planning/locks/fase-activa.json)' };
79
+ }
80
+
81
+ let faseActiva;
82
+ try {
83
+ faseActiva = JSON.parse(fs.readFileSync(rutaFaseActiva, 'utf-8'));
84
+ } catch (_) {
85
+ return { ok: false, motivo: 'fase-activa.json corrupto (JSON inválido)' };
86
+ }
87
+
88
+ if (!faseActiva || typeof faseActiva.planPath !== 'string' || typeof faseActiva.sha256 !== 'string') {
89
+ return { ok: false, motivo: 'fase-activa.json sin planPath/sha256' };
90
+ }
91
+
92
+ const planAbs = path.isAbsolute(faseActiva.planPath)
93
+ ? faseActiva.planPath
94
+ : path.join(cwd, faseActiva.planPath);
95
+
96
+ if (!fs.existsSync(planAbs)) {
97
+ return { ok: false, motivo: `el plan de la fase activa no existe (${faseActiva.planPath})` };
98
+ }
99
+
100
+ let hashActual;
101
+ try {
102
+ hashActual = crypto.createHash('sha256').update(fs.readFileSync(planAbs)).digest('hex');
103
+ } catch (_) {
104
+ return { ok: false, motivo: 'no se pudo leer el plan de la fase activa' };
105
+ }
106
+
107
+ if (hashActual !== faseActiva.sha256) {
108
+ return {
109
+ ok: false,
110
+ motivo: `el plan de la fase activa fue mutado tras la firma (${faseActiva.planPath})`,
111
+ numero: faseActiva.numero,
112
+ };
113
+ }
114
+
115
+ return { ok: true, motivo: null, numero: faseActiva.numero };
116
+ }
117
+
118
+ /**
119
+ * Decide si la escritura amerita advertencia G0.
120
+ * @returns {null | { motivo: string }} null = silencio
121
+ */
122
+ function evaluarEscritura({ cwd, toolName, filePath, esArchivoFuente }) {
123
+ if (!toolName || !['Write', 'Edit', 'MultiEdit'].includes(toolName)) return null;
124
+ if (!filePath) return null;
125
+
126
+ // Zero-config: sin .planning/ no hay ciclo GSD que vigilar.
127
+ if (!fs.existsSync(path.join(cwd, '.planning'))) return null;
128
+
129
+ const normal = filePath.replace(/\\/g, '/');
130
+ if (RUTAS_EXCLUIDAS.some((ex) => normal.includes(ex))) return null;
131
+ if (!esArchivoFuente(normal)) return null;
132
+
133
+ const fase = evaluarFaseActiva(cwd);
134
+ if (fase.ok) return null;
135
+
136
+ return { motivo: fase.motivo };
137
+ }
138
+
139
+ function emitirNudge({ cwd, filePath, motivo }) {
140
+ const rutaRelativa = path.relative(cwd, filePath).replace(/\\/g, '/');
141
+ let emit;
142
+ try {
143
+ ({ emit } = require(path.join(__dirname, 'lib', 'nudge-tracker.js')));
144
+ } catch (_) {
145
+ return;
146
+ }
147
+ try {
148
+ emit({
149
+ kind: 'spec-gate',
150
+ target: rutaRelativa,
151
+ source: 'hooks/spec-gate.js',
152
+ message:
153
+ `Gate G0 (warn): escritura de código fuente ${rutaRelativa} ${motivo}. ` +
154
+ `El flujo SDD espera una fase activa con PLAN aprobado (/swl:aprobar-plan N) ` +
155
+ `antes de escribir código. Si es un fix trivial o exploración, ignora este aviso ` +
156
+ `— se usa para calibrar la promoción a blocking (ADR-0034). Opt-out: SWL_SPEC_GATE=0.`,
157
+ data: { archivo: rutaRelativa, motivo, gate: 'G0' },
158
+ mutation_category: 'optimize',
159
+ risk_level: 'low',
160
+ });
161
+ } catch (_) {
162
+ // La observabilidad nunca bloquea el trabajo productivo.
163
+ }
164
+ }
165
+
166
+ function main() {
167
+ if (process.env.SWL_SPEC_GATE === '0') process.exit(0);
168
+
169
+ let hookInput = '';
170
+ try {
171
+ hookInput = fs.readFileSync(0, 'utf-8');
172
+ } catch (_) {
173
+ process.exit(0);
174
+ }
175
+
176
+ let evento;
177
+ try {
178
+ evento = JSON.parse(hookInput);
179
+ } catch (_) {
180
+ process.exit(0);
181
+ }
182
+
183
+ const cwd = process.cwd();
184
+ const toolName = evento?.tool_name;
185
+ const filePath = evento?.tool_input?.file_path;
186
+
187
+ let resultado = null;
188
+ try {
189
+ resultado = evaluarEscritura({
190
+ cwd,
191
+ toolName,
192
+ filePath,
193
+ esArchivoFuente: cargarClasificador(),
194
+ });
195
+ } catch (_) {
196
+ process.exit(0);
197
+ }
198
+
199
+ if (resultado) {
200
+ emitirNudge({ cwd, filePath, motivo: resultado.motivo });
201
+ }
202
+
203
+ // WARN-ONLY (ADR-0034): jamás exit 2 en este modo.
204
+ process.exit(0);
205
+ }
206
+
207
+ if (require.main === module) {
208
+ main();
209
+ }
210
+
211
+ module.exports = { evaluarFaseActiva, evaluarEscritura, RUTAS_EXCLUIDAS };