@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.
- package/CLAUDE.md +8 -8
- package/README.md +13 -13
- package/agentes/accesibilidad-wcag-swl.md +3 -3
- package/agentes/auto-evolucion-swl.md +908 -908
- package/agentes/disenador-ui-swl.md +6 -5
- package/agentes/frontend-angular-swl.md +2 -2
- package/agentes/frontend-css-swl.md +2 -2
- package/agentes/frontend-react-swl.md +4 -4
- package/agentes/frontend-swl.md +6 -6
- package/agentes/investigador-ux-swl.md +5 -5
- package/agentes/orquestador-swl.md +96 -8
- package/agentes/perfilador-usuario-swl.md +308 -308
- package/agentes/producto-prd-swl.md +1 -1
- package/agentes/red-team-swl.md +218 -218
- package/agentes/revisor-codigo-swl.md +34 -10
- package/agentes/revisor-seguridad-swl.md +7 -0
- package/agentes/tdd-qa-swl.md +39 -2
- package/comandos/swl/actualizar.md +1 -1
- package/comandos/swl/aprender.md +2 -2
- package/comandos/swl/aprobar-plan.md +152 -0
- package/comandos/swl/autoresearch.md +102 -6
- package/comandos/swl/ayuda.md +3 -3
- package/comandos/swl/discutir-fase.md +20 -2
- package/comandos/swl/ejecutar-fase.md +53 -6
- package/comandos/swl/evolucionar.md +1 -1
- package/comandos/swl/inbox.md +1 -1
- package/comandos/swl/instalar.md +1 -1
- package/comandos/swl/nemesis.md +42 -1
- package/comandos/swl/planear-fase.md +25 -1
- package/comandos/swl/plugins.md +1 -1
- package/comandos/swl/predecir.md +139 -0
- package/comandos/swl/release.md +1 -1
- package/comandos/swl/status.md +279 -0
- package/comandos/swl/verificar.md +75 -7
- package/habilidades/ai-runtime-security/SKILL.md +1 -1
- package/habilidades/angular-moderno/SKILL.md +44 -1
- package/habilidades/auto-evolucion-protocolo/SKILL.md +276 -276
- package/habilidades/autoresearch/SKILL.md +15 -1
- package/habilidades/benchmark-memoria/SKILL.md +1 -1
- package/habilidades/calidad-contract-testing/SKILL.md +165 -0
- package/habilidades/calidad-mutation-testing/SKILL.md +170 -0
- package/habilidades/changelog-generator/SKILL.md +9 -2
- package/habilidades/changelog-generator/scripts/parse-commits.js +12 -1
- package/habilidades/checklist-seguridad/SKILL.md +29 -1
- package/habilidades/checklist-seguridad/recursos/stride-cobertura.md +60 -0
- package/habilidades/css-moderno/SKILL.md +3 -1
- package/habilidades/diagrama-arquitectura/SKILL.md +1 -1
- package/habilidades/drift-detection/SKILL.md +179 -179
- package/habilidades/ejecutar-fase/SKILL.md +64 -14
- package/habilidades/estructura-proyecto-claude/SKILL.md +17 -14
- package/habilidades/estructura-proyecto-claude/recursos/configuracion-y-extensiones.md +34 -23
- package/habilidades/estructura-proyecto-claude/recursos/frontmatter-y-hooks-referencia.md +70 -53
- package/habilidades/estructura-proyecto-claude/recursos/mcp-json-template.json +57 -77
- package/habilidades/extractor-de-aprendizajes/SKILL.md +9 -5
- package/habilidades/fastapi-experto/SKILL.md +56 -5
- package/habilidades/harness-claude-code/SKILL.md +10 -7
- package/{reglas/harness-claude-code.md → habilidades/harness-claude-code/recursos/disciplina-harness-regla.md} +2 -2
- package/habilidades/instalar-sistema/SKILL.md +3 -3
- package/habilidades/meta-skills-estandar/recursos/frameworks-seguridad.md +1 -1
- package/habilidades/patrones-python/SKILL.md +8 -5
- package/habilidades/perfil-usuario/SKILL.md +200 -200
- package/habilidades/planear-fase/SKILL.md +25 -4
- package/habilidades/proceso-ddia-fundamentos/SKILL.md +1 -1
- package/habilidades/proceso-ddia-streaming/SKILL.md +4 -4
- package/habilidades/proceso-debate-adversarial/SKILL.md +164 -0
- package/habilidades/proceso-debate-adversarial/recursos/personas.md +105 -0
- package/habilidades/proceso-dynamic-workflows/SKILL.md +138 -0
- package/habilidades/proceso-dynamic-workflows/recursos/template-adversarial-verify.js +65 -0
- package/habilidades/proceso-dynamic-workflows/recursos/template-triage.js +65 -0
- package/habilidades/protocolo-revision-swl/SKILL.md +1 -1
- package/habilidades/seguridad-skills-ia/SKILL.md +1 -1
- package/habilidades/swl-claudemd/SKILL.md +50 -210
- package/habilidades/swl-claudemd/recursos/contrato-aprender.md +83 -0
- package/habilidades/swl-claudemd/recursos/duplicacion-reglas-globales.md +85 -0
- package/habilidades/swl-claudemd/recursos/plantillas-init.md +94 -0
- package/habilidades/swl-dashboard/SKILL.md +9 -9
- package/habilidades/swl-revisar-impacto/SKILL.md +1 -1
- package/habilidades/tdd-workflow/SKILL.md +58 -5
- package/habilidades/tdd-workflow/recursos/gherkin-bdd.md +111 -0
- package/habilidades/validacion-ci-sistema/SKILL.md +3 -3
- package/hooks/calidad-pre-commit.js +340 -3
- package/hooks/ciclo-evolucion-subagente.js +26 -0
- package/hooks/ciclo-evolucion.js +26 -0
- package/hooks/contexto-iteracion.js +144 -0
- package/hooks/extraccion-aprendizajes.js +13 -0
- package/hooks/lib/ciclo-evolucion.js +47 -0
- package/hooks/{auto-evolucion.js → lib/etapa-auto-evolucion.js} +701 -700
- package/hooks/{metricas-evolucion.js → lib/etapa-metricas.js} +388 -376
- package/hooks/{actualizar-perfil-usuario.js → lib/etapa-perfil-usuario.js} +376 -364
- package/hooks/lib/evolution-tracker.js +24 -3
- package/hooks/lib/loop-telemetry.js +321 -0
- package/hooks/notificacion-telegram.js +11 -3
- package/hooks/spec-gate.js +211 -0
- package/hooks/tdd-gate.js +241 -0
- package/hooks/validar-intent-spec.js +30 -10
- package/llms.txt +29 -0
- package/manifiestos/hooks-config.json +36 -18
- package/manifiestos/modulos.json +23 -14
- package/manifiestos/skills-lock.json +100 -72
- package/package.json +4 -3
- package/plugin.json +9 -10
- package/reglas/accesibilidad.md +10 -0
- package/reglas/api-diseno.md +9 -0
- package/reglas/arquitectura.evolved.json +7 -0
- package/reglas/arquitectura.md +65 -0
- package/reglas/auditorias-documentales-estructurales.md +7 -0
- package/reglas/cloud-infra.md +8 -0
- package/reglas/fragmentos-compartidos.md +5 -0
- package/reglas/gobernanza.md +4 -4
- package/reglas/hooks.md +6 -0
- package/reglas/intent-engineering.md +4 -0
- package/reglas/markitdown.md +8 -0
- package/reglas/memoria-consolidada.md +1 -1
- package/reglas/patrones.md +6 -0
- package/reglas/registro-componentes-nuevos.md +10 -1
- package/reglas/seguridad-agentes.md +1 -1
- package/reglas/seguridad.evolved.json +7 -0
- package/reglas/seguridad.md +144 -0
- package/reglas/skills-estandar.md +6 -0
- package/reglas/testing.md +7 -0
- package/reglas/tests-cleanup.md +4 -0
- package/reglas/usar-sistema-swl.md +1 -1
- package/scripts/generar-inventario.js +64 -1
- package/scripts/instalador.js +32 -2
- package/scripts/lib/gitignore-manifest.js +29 -1
- package/scripts/lib/plan-lock.js +275 -0
- package/scripts/migrar-fase-dominio.js +0 -1
- package/scripts/smoke-test.js +24 -2
- package/scripts/verificar-trazabilidad.js +292 -0
- package/agentes/ux-disenador-swl.md +0 -503
- package/comandos/swl/dashboard.md +0 -146
- package/comandos/swl/evolucion-estado.md +0 -191
- package/comandos/swl/metricas.md +0 -342
- package/comandos/swl/salud.md +0 -481
- 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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 };
|