@saulwade/swl-ses 2.0.0 → 2.2.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 (97) 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 +64 -1
  8. package/comandos/swl/adoptar-proyecto.md +258 -255
  9. package/comandos/swl/aprender.md +828 -840
  10. package/comandos/swl/aprobar-plan.md +26 -37
  11. package/comandos/swl/autoresearch.md +12 -14
  12. package/comandos/swl/briefing.md +119 -0
  13. package/comandos/swl/checkpoint.md +10 -15
  14. package/comandos/swl/claudemd.md +239 -234
  15. package/comandos/swl/compactar.md +29 -2
  16. package/comandos/swl/configurar-ci.md +20 -19
  17. package/comandos/swl/cron.md +10 -12
  18. package/comandos/swl/discutir-fase.md +8 -5
  19. package/comandos/swl/ejecutar-fase.md +15 -2
  20. package/comandos/swl/evolucionar.md +6 -11
  21. package/comandos/swl/inbox.md +10 -10
  22. package/comandos/swl/modelo.md +7 -9
  23. package/comandos/swl/notificaciones.md +19 -116
  24. package/comandos/swl/nuevo-proyecto.md +205 -205
  25. package/comandos/swl/planear-fase.md +5 -3
  26. package/comandos/swl/release.md +46 -0
  27. package/comandos/swl/status.md +333 -279
  28. package/comandos/swl/verificar.md +817 -812
  29. package/habilidades/changelog-generator/scripts/parse-commits.js +6 -4
  30. package/habilidades/ejecutar-fase/SKILL.md +541 -518
  31. package/habilidades/planear-fase/SKILL.md +3 -2
  32. package/habilidades/swl-claudemd/SKILL.md +10 -6
  33. package/habilidades/tdd-workflow/SKILL.md +715 -713
  34. package/habilidades/validacion-ci-sistema/SKILL.md +17 -1
  35. package/hooks/calidad-pre-commit.js +5 -1
  36. package/hooks/check-update.js +39 -1
  37. package/hooks/lib/autonomia.js +208 -0
  38. package/hooks/lib/briefing.js +474 -0
  39. package/hooks/lib/propose-step.js +358 -0
  40. package/hooks/session-briefing.js +98 -0
  41. package/hooks/telemetria-skill-routing.js +100 -0
  42. package/instintos/autonomia.yaml +27 -0
  43. package/llms.txt +4 -4
  44. package/manifiestos/hooks-config.json +18 -0
  45. package/manifiestos/modulos.json +25 -3
  46. package/manifiestos/skills-lock.json +17 -17
  47. package/package.json +93 -93
  48. package/plugin.json +371 -371
  49. package/reglas/analizar-directorios-antes-de-escribir.md +228 -0
  50. package/reglas/consultar-vault-primero.md +195 -0
  51. package/reglas/debatir-antes-de-aceptar.md +158 -0
  52. package/reglas/git-coauthor.md +100 -0
  53. package/reglas/monitor-ci.md +309 -0
  54. package/reglas/registro-componentes-nuevos.md +38 -10
  55. package/reglas/sesiones-paralelas.md +180 -0
  56. package/reglas/usar-code-review-graph.md +155 -0
  57. package/reglas/verificar-citas-normativas.md +548 -0
  58. package/scripts/auditar-claudemd.js +38 -0
  59. package/scripts/cli/aprobar-plan.js +73 -0
  60. package/scripts/cli/briefing.js +23 -0
  61. package/scripts/cli/ciclo-evolucion.js +26 -0
  62. package/scripts/cli/configurar-ci.js +40 -0
  63. package/scripts/cli/derivar-feature-list.js +25 -0
  64. package/scripts/cli/detectar-host.js +27 -0
  65. package/scripts/cli/diary-entry.js +69 -0
  66. package/scripts/cli/execution-state.js +18 -0
  67. package/scripts/cli/gateway-notify.js +41 -0
  68. package/scripts/cli/liberar-fase.js +42 -0
  69. package/scripts/cli/loop-telemetry.js +125 -0
  70. package/scripts/cli/mark-evolved.js +56 -0
  71. package/scripts/cli/metricas-dora.js +26 -0
  72. package/scripts/cli/near-duplicate.js +55 -0
  73. package/scripts/cli/notificaciones.js +123 -0
  74. package/scripts/cli/propose-step.js +29 -0
  75. package/scripts/cli/schedule-parse.js +19 -0
  76. package/scripts/cli/sugerir-modelo.js +20 -0
  77. package/scripts/cli/verificar-plan.js +36 -0
  78. package/scripts/cli/verificar-trazabilidad.js +35 -0
  79. package/scripts/derivar-feature-list.js +1 -0
  80. package/scripts/instalador.js +52 -6
  81. package/scripts/lib/auditar-invocaciones-comandos.js +104 -0
  82. package/scripts/lib/ci-reader.js +193 -0
  83. package/scripts/lib/detectar-host-swl.js +175 -0
  84. package/scripts/lib/evidencia-release.js +322 -0
  85. package/scripts/lib/gate-hooks-requires.js +249 -0
  86. package/scripts/lib/gate-licencias.js +212 -0
  87. package/scripts/lib/git-metricas.js +257 -0
  88. package/scripts/lib/metricas-dora.js +204 -0
  89. package/scripts/lib/resolver-plan-fase.js +37 -0
  90. package/scripts/tui/ejecutores.js +1 -1
  91. package/scripts/validar-manifest.js +92 -1
  92. package/scripts/validar.js +13 -0
  93. package/scripts/verificar-evolucion.js +54 -4
  94. package/scripts/verificar-release.js +102 -0
  95. package/scripts/verificar-trazabilidad.js +12 -6
  96. package/reglas/arquitectura.evolved.json +0 -7
  97. package/reglas/seguridad.evolved.json +0 -7
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wrapper CLI `swl-ses notificaciones <op>` — gestiona las notificaciones
5
+ * Telegram y su bot daemon. Reemplaza los inline
6
+ * `require('./scripts/lib/notificaciones-telegram')` relativos al proyecto
7
+ * (rotos downstream; ver docs/invocacion-cli-cross-scope.md). Encapsula también
8
+ * el formato de salida que antes vivía en el comando.
9
+ *
10
+ * Ops:
11
+ * init (requiere TTY) bot-start
12
+ * status bot-stop
13
+ * disable [--purge] bot-status
14
+ * repair bot-restart
15
+ * bot-enable-autostart
16
+ * bot-disable-autostart
17
+ */
18
+
19
+ const nt = require('../lib/notificaciones-telegram');
20
+
21
+ function imprimirStatus() {
22
+ const s = nt.status();
23
+ const botS = nt.botStatus();
24
+ const autostartLabel =
25
+ process.platform === 'win32'
26
+ ? 'Scheduled Task'
27
+ : process.platform === 'linux'
28
+ ? 'systemd --user'
29
+ : 'LaunchAgent';
30
+ const mudos = s.proyectosSilenciados.length
31
+ ? s.proyectosSilenciados.join(', ')
32
+ : 'ninguno';
33
+ console.log('');
34
+ console.log('Notificaciones Telegram — estado actual');
35
+ console.log(' .env: ', s.envExiste ? 'existe' : 'no existe');
36
+ console.log(' token: ', s.tokenConfigurado ? 'configurado' : 'no configurado');
37
+ console.log(
38
+ ' chat_id: ',
39
+ s.chatIdConfigurado ? 'configurado (' + s.chatIdParcial + ')' : 'no configurado'
40
+ );
41
+ console.log(' proyectos mudos: ', mudos);
42
+ console.log(' bot daemon: ', botS.activo ? 'activo (PID ' + botS.pid + ')' : 'detenido');
43
+ console.log(' autostart: usar /swl:notificaciones bot enable-autostart (' + autostartLabel + ')');
44
+ console.log('');
45
+ }
46
+
47
+ function imprimirBotStatus() {
48
+ const r = nt.botStatus();
49
+ console.log('');
50
+ console.log('Bot daemon — estado actual');
51
+ console.log(' activo:', r.activo ? 'sí' : 'no');
52
+ console.log(' pid: ', r.pid !== null ? r.pid : 'n/a');
53
+ console.log('');
54
+ }
55
+
56
+ function reportarOk(prefijo, r, mensajeDefault) {
57
+ if (r.ok) {
58
+ console.log('[' + prefijo + ']', r.mensaje || mensajeDefault);
59
+ } else {
60
+ console.error('[' + prefijo + '] Error:', r.error);
61
+ process.exitCode = 1;
62
+ }
63
+ }
64
+
65
+ // Tabla de dispatch de los ops del bot daemon: [prefijo, fn, mensajeDefault].
66
+ const BOT_OPS = {
67
+ 'bot-start': ['bot', () => nt.botStart(), 'Bot iniciado.'],
68
+ 'bot-stop': ['bot', () => nt.botStop(), 'Bot detenido.'],
69
+ 'bot-restart': ['bot', () => nt.botRestart(), 'Bot reiniciado.'],
70
+ 'bot-enable-autostart': ['autostart', () => nt.botEnableAutostart(), 'Autostart habilitado.'],
71
+ 'bot-disable-autostart': ['autostart', () => nt.botDisableAutostart(), 'Autostart deshabilitado.'],
72
+ };
73
+
74
+ async function notificaciones(opciones = {}) {
75
+ const op = (opciones._args && opciones._args[0]) || opciones.op || 'status';
76
+
77
+ if (BOT_OPS[op]) {
78
+ const [prefijo, fn, msg] = BOT_OPS[op];
79
+ reportarOk(prefijo, fn(), msg);
80
+ return;
81
+ }
82
+
83
+ switch (op) {
84
+ case 'init': {
85
+ if (!process.stdin.isTTY) {
86
+ console.log('Este subcomando requiere entrada interactiva (TTY).');
87
+ console.log('Ejecuta desde tu terminal:');
88
+ console.log(' swl-ses notificaciones init');
89
+ console.log('O usa el instalador:');
90
+ console.log(' npx -y @saulwade/swl-ses@latest install');
91
+ return;
92
+ }
93
+ const r = await nt.init({ esTty: true });
94
+ console.log('[resultado]', r.resultado, r.detalle || '');
95
+ if (r.resultado === 'error') process.exitCode = 1;
96
+ return;
97
+ }
98
+ case 'status':
99
+ imprimirStatus();
100
+ return;
101
+ case 'disable': {
102
+ const r = nt.disable({ confirmar: true, conservarEnv: !opciones.purge });
103
+ console.log('[resultado]', r.resultado, r.detalle || '');
104
+ if (r.resultado === 'error') process.exitCode = 1;
105
+ return;
106
+ }
107
+ case 'repair': {
108
+ const r = await nt.repair();
109
+ console.log('[repair]', r.resultado, r.detalle || '');
110
+ if (r.resultado === 'error') process.exitCode = 1;
111
+ return;
112
+ }
113
+ case 'bot-status':
114
+ imprimirBotStatus();
115
+ return;
116
+ default:
117
+ console.error('Op desconocida: ' + op);
118
+ console.error('Ops: init|status|disable|repair|bot-start|bot-stop|bot-status|bot-restart|bot-enable-autostart|bot-disable-autostart');
119
+ process.exitCode = 1;
120
+ }
121
+ }
122
+
123
+ module.exports = notificaciones;
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wrapper CLI para `swl-ses propose-step` — anexo propositivo del RESUMEN de
5
+ * fase (ADR-0037). Envuelve hooks/lib/propose-step.js (que lee process.argv).
6
+ *
7
+ * Reemplaza el `node hooks/lib/propose-step.js --rango=...` relativo al proyecto
8
+ * que /swl:ejecutar-fase invocaba — roto downstream (ver
9
+ * docs/invocacion-cli-cross-scope.md).
10
+ *
11
+ * Uso: swl-ses propose-step --rango=<base>..HEAD (opt-out SWL_PROPOSE=0)
12
+ */
13
+
14
+ const { main } = require('../../hooks/lib/propose-step.js');
15
+
16
+ function proposeStep(opciones = {}) {
17
+ opciones = opciones || {};
18
+ const args = ['node', 'propose-step'];
19
+ if (opciones.rango) args.push(`--rango=${opciones.rango}`);
20
+ const argvOriginal = process.argv;
21
+ process.argv = args;
22
+ try {
23
+ return main(process.argv);
24
+ } finally {
25
+ process.argv = argvOriginal;
26
+ }
27
+ }
28
+
29
+ module.exports = proposeStep;
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wrapper CLI `swl-ses schedule-parse` — parsea una frase en inglés a expresión
5
+ * cron. Reemplaza el inline `require('./scripts/lib/schedule-parser')` relativo
6
+ * al proyecto (roto downstream; ver docs/invocacion-cli-cross-scope.md).
7
+ *
8
+ * Uso: swl-ses schedule-parse "every morning at 9am"
9
+ */
10
+
11
+ const { parseNaturalSchedule } = require('../lib/schedule-parser');
12
+
13
+ function scheduleParse(opciones = {}) {
14
+ const frase =
15
+ (opciones._args && opciones._args.join(' ')) || opciones.texto || '';
16
+ console.log(JSON.stringify(parseNaturalSchedule(frase), null, 2));
17
+ }
18
+
19
+ module.exports = scheduleParse;
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wrapper CLI `swl-ses sugerir-modelo` — evalúa la complejidad de una tarea y
5
+ * sugiere el modelo (opus/sonnet/haiku). Reemplaza el inline
6
+ * `require('./hooks/lib/model-router.js')` relativo al proyecto (roto downstream;
7
+ * ver docs/invocacion-cli-cross-scope.md).
8
+ *
9
+ * Uso: swl-ses sugerir-modelo "<descripción de la tarea>"
10
+ */
11
+
12
+ const { sugerirModelo } = require('../../hooks/lib/model-router.js');
13
+
14
+ function sugerirModeloCli(opciones = {}) {
15
+ const desc =
16
+ (opciones._args && opciones._args.join(' ')) || opciones.desc || '';
17
+ console.log(JSON.stringify(sugerirModelo(desc), null, 2));
18
+ }
19
+
20
+ module.exports = sugerirModeloCli;
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Subcomando `swl-ses verificar-plan --fase=N` — verificación read-only del lock
5
+ * de un PLAN (gate G1). Lo usa /swl:ejecutar-fase antes de implementar y
6
+ * /swl:aprobar-plan Paso 2 para detectar planes ya firmados/mutados/legacy.
7
+ *
8
+ * Reemplaza el `node -e "require('./scripts/lib/plan-lock')"` inline que se
9
+ * rompía downstream (ver docs/invocacion-cli-cross-scope.md).
10
+ *
11
+ * Exit 0 si ok (firmado o legacy); exit 1 si mutado/sin-firmar/corrupto.
12
+ *
13
+ * Uso: swl-ses verificar-plan --fase=N [--plan=<ruta>]
14
+ */
15
+
16
+ const { verificarPlan } = require('../lib/plan-lock');
17
+ const { resolverPlanPath } = require('../lib/resolver-plan-fase');
18
+
19
+ function verificarPlanCli(opciones = {}) {
20
+ opciones = opciones || {};
21
+ const planPath = resolverPlanPath(opciones);
22
+ if (!planPath) {
23
+ console.error('Uso: swl-ses verificar-plan --fase=N [--plan=<ruta>]');
24
+ process.exit(1);
25
+ }
26
+ const r = verificarPlan(planPath);
27
+ console.log(JSON.stringify(r, null, 2));
28
+ process.exit(r.ok ? 0 : 1);
29
+ }
30
+
31
+ module.exports = verificarPlanCli;
32
+
33
+ if (require.main === module) {
34
+ const { parsearOpciones } = require('../lib/parsear-opciones');
35
+ verificarPlanCli(parsearOpciones(process.argv.slice(2)));
36
+ }
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wrapper CLI para el subcomando `swl-ses verificar-trazabilidad`.
5
+ *
6
+ * Traduce las opciones parseadas del bin a las flags que espera el script
7
+ * standalone `scripts/verificar-trazabilidad.js` (que lee process.argv y hace
8
+ * su propio process.exit). Mismo patrón que scripts/cli/audit-claudemd.js.
9
+ *
10
+ * Reemplaza el `node scripts/verificar-trazabilidad.js` relativo al proyecto que
11
+ * /swl:aprobar-plan invocaba — roto downstream (ver
12
+ * docs/invocacion-cli-cross-scope.md).
13
+ *
14
+ * Uso: swl-ses verificar-trazabilidad --fase=N [--solo-plan] [--json]
15
+ */
16
+
17
+ const { main } = require('../verificar-trazabilidad.js');
18
+
19
+ function verificarTrazabilidad(opciones = {}) {
20
+ opciones = opciones || {};
21
+ const args = [];
22
+ if (opciones.fase != null && opciones.fase !== true) args.push(`--fase=${opciones.fase}`);
23
+ if (opciones['solo-plan'] || opciones.solo_plan) args.push('--solo-plan');
24
+ if (opciones.json) args.push('--json');
25
+
26
+ const argvOriginal = process.argv;
27
+ process.argv = ['node', 'verificar-trazabilidad.js', ...args];
28
+ try {
29
+ main();
30
+ } finally {
31
+ process.argv = argvOriginal;
32
+ }
33
+ }
34
+
35
+ module.exports = verificarTrazabilidad;
@@ -486,4 +486,5 @@ module.exports = {
486
486
  enriquecerDesdeFases,
487
487
  generarFeatureList,
488
488
  normalizarEstado,
489
+ main,
489
490
  };
@@ -101,17 +101,24 @@ async function install(opciones) {
101
101
  // 1. opciones.profile — flag explícito del usuario (también acepta --perfil)
102
102
  // 2. manifest.profile — declarado en .swl-ses del proyecto
103
103
  // 3. install-state existente en el destino — preserva el perfil ya instalado
104
- // 4. 'core' — default final
104
+ // 4. 'completo' — default final
105
105
  //
106
106
  // Antes del fix v1.3.5, `install --force` sin --profile defaulteaba directamente
107
- // a 'core' aunque ya existiera una instalación con perfil 'completo'. El usuario
107
+ // al default aunque ya existiera una instalación con perfil distinto. El usuario
108
108
  // perdía silenciosamente componentes y el update subsiguiente reproducía el
109
109
  // estado contaminado (los archivos del completo quedaban con contenido viejo).
110
110
  //
111
+ // El default final es 'completo' (cambiado desde 'core'): una instalación sin
112
+ // --profile entrega el sistema entero (todos los agentes, skills y comandos).
113
+ // Razón: un `install --target=... --with-mcp --global --force` sin --perfil
114
+ // parecía una instalación completa pero entregaba el perfil mínimo, dejando al
115
+ // usuario sin la mayoría de comandos /swl:* (caso reportado 2026-06-12). Para
116
+ // instalar solo el núcleo, pasar --perfil core explícito.
117
+ //
111
118
  // La detección del install-state aquí es deliberadamente best-effort: si no
112
- // existe o no se puede leer, caemos al default 'core'. Solo aplica para
119
+ // existe o no se puede leer, caemos al default 'completo'. Solo aplica para
113
120
  // `install --force` (reinstalación); en una primera instalación no hay state
114
- // todavía y core como default sigue siendo razonable.
121
+ // todavía y el sistema completo es el default razonable.
115
122
  let perfilDeState = null;
116
123
  if (!opciones.profile && !(manifest && manifest.profile)) {
117
124
  try {
@@ -131,7 +138,7 @@ async function install(opciones) {
131
138
  const perfil = opciones.profile
132
139
  || (manifest && manifest.profile)
133
140
  || perfilDeState
134
- || 'core';
141
+ || 'completo';
135
142
 
136
143
  console.log(`swl-ses install v${VERSION}`);
137
144
  console.log(`${'='.repeat(40)}`);
@@ -247,7 +254,7 @@ async function install(opciones) {
247
254
  if (allLangs && !tieneReglasLenguaje && !esGlobal) {
248
255
  console.log(
249
256
  '\n[stack] Aviso: --all-langs ignorado — el perfil actual (' +
250
- (resolucion.perfil || 'core') +
257
+ (resolucion.perfil || 'completo') +
251
258
  ') no incluye reglas de lenguajes. ' +
252
259
  'Usa --perfil completo o --perfil polyglot para activar las reglas por lenguaje.'
253
260
  );
@@ -644,6 +651,45 @@ async function install(opciones) {
644
651
  }
645
652
  }
646
653
 
654
+ // Copiar fragmentos compartidos (agentes/_*.md) — NO van en modulos.json
655
+ // (no son agentes routables) pero los agentes que los importan vía
656
+ // `fragmentos:` los leen al cargarse. El loader los espera en el dir de
657
+ // agentes del destino. Se copian raw (sin transformar: no tienen frontmatter).
658
+ // Cierra DT-FRAGMENTOS-DISTRIBUCION (Fase 13).
659
+ if (
660
+ runtime.tiposSoportados &&
661
+ runtime.tiposSoportados.includes('agentes') &&
662
+ rutas.agentes &&
663
+ resolucion.archivos.some(a => a.tipo === 'agentes')
664
+ ) {
665
+ const fragmentosOrigen = path.join(RAIZ_PKG, 'agentes');
666
+ try {
667
+ const fragmentos = fs.existsSync(fragmentosOrigen)
668
+ ? fs.readdirSync(fragmentosOrigen).filter(f => f.startsWith('_') && f.endsWith('.md'))
669
+ : [];
670
+ if (fragmentos.length > 0 && !fs.existsSync(rutas.agentes)) {
671
+ fs.mkdirSync(rutas.agentes, { recursive: true });
672
+ }
673
+ let fragmentosCopiados = 0;
674
+ for (const frag of fragmentos) {
675
+ const destino = path.join(rutas.agentes, frag);
676
+ fs.copyFileSync(path.join(fragmentosOrigen, frag), destino);
677
+ registrarArchivo(estado, {
678
+ origen: `agentes/${frag}`,
679
+ destino,
680
+ tipo: 'fragmentos', // distinto de 'agentes' → no infla el conteo del doctor
681
+ });
682
+ fragmentosCopiados++;
683
+ }
684
+ if (fragmentosCopiados > 0) {
685
+ console.log(` + Fragmentos compartidos: ${fragmentosCopiados} (agentes/_*.md)`);
686
+ }
687
+ } catch (err) {
688
+ console.error(` ! Error copiando fragmentos: ${err.message}`);
689
+ errores++;
690
+ }
691
+ }
692
+
647
693
  // Instalar userland (prioridad sobre core)
648
694
  for (const archivo of archivosUserland) {
649
695
  try {
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * auditar-invocaciones-comandos.js
5
+ *
6
+ * Gate anti-regresión de la clase de bug "invocación relativa al proyecto en
7
+ * comandos user-facing" (ver docs/invocacion-cli-cross-scope.md).
8
+ *
9
+ * Un comando que corre en el proyecto del usuario NUNCA debe invocar scripts SWL
10
+ * con ruta relativa al proyecto (`node scripts/lib/...`, `node hooks/...`,
11
+ * `require('./scripts/...')`): esas rutas no existen downstream. Debe usar el
12
+ * subcomando del CLI (`swl-ses <sub>` / `npx ... <sub>` / `node scripts/cli/<sub>.js`).
13
+ *
14
+ * Solo escanea bloques de código (``` ... ```) para no marcar menciones
15
+ * prohibitivas en prosa. Excluye `scripts/cli/` (forma repo-madre sancionada).
16
+ *
17
+ * Función pura + entrypoint CLI con exit 1 si hay violaciones.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ // Comandos que corren SOLO en el repo madre de swl-ses (el CWD ES el repo, así
24
+ // que `node scripts/...` relativo es correcto). NO son user-facing.
25
+ const SELF_DEV = new Set([
26
+ 'release',
27
+ 'contribuir',
28
+ 'evaluar-skill',
29
+ 'reflect-skills',
30
+ ]);
31
+
32
+ // Patrones de invocación relativa al proyecto que se rompen downstream.
33
+ // Se evalúan SOLO dentro de bloques de código.
34
+ const PATRONES = [
35
+ { re: /\bnode\s+(?:\.\/)?scripts\/(?!cli\/)/, desc: 'node scripts/ (usa swl-ses <sub>)' },
36
+ { re: /\bnode\s+(?:\.\/)?hooks\//, desc: 'node hooks/ (usa swl-ses <sub>)' },
37
+ { re: /require\(\s*['"]\.\.?\/scripts\//, desc: "require('./scripts/ inline" },
38
+ { re: /require\(\s*['"]\.\.?\/hooks\//, desc: "require('./hooks/ inline" },
39
+ ];
40
+
41
+ /**
42
+ * @param {string} [comandosDir] default: comandos/swl relativo al repo de este script.
43
+ * @returns {{ok:boolean, violaciones:Array<{comando:string,linea:number,texto:string,patron:string}>, escaneados:number}}
44
+ */
45
+ function auditarInvocacionesComandos(comandosDir) {
46
+ const dir = comandosDir || path.join(__dirname, '..', '..', 'comandos', 'swl');
47
+ const violaciones = [];
48
+ let escaneados = 0;
49
+
50
+ let archivos;
51
+ try {
52
+ archivos = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
53
+ } catch {
54
+ return { ok: true, violaciones, escaneados: 0 };
55
+ }
56
+
57
+ for (const archivo of archivos) {
58
+ const comando = archivo.replace(/\.md$/, '');
59
+ if (SELF_DEV.has(comando)) continue;
60
+ escaneados++;
61
+
62
+ const contenido = fs.readFileSync(path.join(dir, archivo), 'utf8');
63
+ const lineas = contenido.split('\n');
64
+ let dentroCodigo = false;
65
+
66
+ for (let i = 0; i < lineas.length; i++) {
67
+ const linea = lineas[i];
68
+ if (/^\s*```/.test(linea)) {
69
+ dentroCodigo = !dentroCodigo;
70
+ continue;
71
+ }
72
+ if (!dentroCodigo) continue;
73
+ for (const { re, desc } of PATRONES) {
74
+ if (re.test(linea)) {
75
+ violaciones.push({
76
+ comando,
77
+ linea: i + 1,
78
+ texto: linea.trim(),
79
+ patron: desc,
80
+ });
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ return { ok: violaciones.length === 0, violaciones, escaneados };
88
+ }
89
+
90
+ module.exports = { auditarInvocacionesComandos, SELF_DEV };
91
+
92
+ if (require.main === module) {
93
+ const r = auditarInvocacionesComandos();
94
+ if (r.ok) {
95
+ console.log(`[OK] invocaciones-comandos — ${r.escaneados} comandos user-facing sin rutas relativas al proyecto`);
96
+ process.exit(0);
97
+ }
98
+ console.error(`[FALLA] invocaciones-comandos — ${r.violaciones.length} invocación(es) relativa(s) al proyecto en comandos user-facing:`);
99
+ for (const v of r.violaciones) {
100
+ console.error(` ${v.comando}.md:${v.linea} [${v.patron}] ${v.texto}`);
101
+ }
102
+ console.error('\nUsa el patrón cross-scope (docs/invocacion-cli-cross-scope.md): swl-ses <sub> / npx ... <sub> / node scripts/cli/<sub>.js');
103
+ process.exit(1);
104
+ }
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ci-reader.js
5
+ *
6
+ * Métricas DORA dependientes de CI (GitHub Actions vía `gh run list`):
7
+ * - change failure rate: runs fallidos / runs completados en la ventana.
8
+ * - MTTR (mean/median time to restore): mediana del tiempo de un run fallido
9
+ * hasta el siguiente run exitoso.
10
+ *
11
+ * `gh` es OPCIONAL (D-15-01): si no está instalado, no autenticado, o el repo no
12
+ * tiene remoto GitHub, las funciones devuelven `{disponible:false, razon}` SIN
13
+ * lanzar. El executor `gh` es inyectable (`opts.ejecutorGh`) para tests sin gh
14
+ * real. Funciones puras sobre el array de runs. Entrypoint CLI `--json`.
15
+ * Parte de la Fase 15 (ADR-0039).
16
+ */
17
+
18
+ const { execFileSync } = require('node:child_process');
19
+
20
+ const MS_DIA = 24 * 60 * 60 * 1000;
21
+ const MS_HORA = 60 * 60 * 1000;
22
+ const LIMITE_DEFAULT = 200;
23
+
24
+ const CONCLUSIONES_FALLO = new Set(['failure', 'timed_out', 'startup_failure']);
25
+ const CONCLUSIONES_EXITO = new Set(['success']);
26
+
27
+ const GH_MAX_BUFFER = 8 * 1024 * 1024; // 8 MiB
28
+ const GH_TIMEOUT_MS = 8000; // gh puede tardar por red; degrada si excede (H-04)
29
+
30
+ /** Executor real de gh. Lanza si gh no está disponible/autenticado. */
31
+ function ejecutorGhReal(args) {
32
+ return execFileSync('gh', args, {
33
+ encoding: 'utf8',
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ maxBuffer: GH_MAX_BUFFER,
36
+ timeout: GH_TIMEOUT_MS,
37
+ });
38
+ }
39
+
40
+ /** Categoriza el fallo de gh sin filtrar e.message crudo a logs/persistencia (H-03). */
41
+ function _razonGh(e) {
42
+ if (e && e.code === 'ENOENT') return 'gh no instalado';
43
+ if (e && e.killed && e.signal) return 'gh excedió el timeout';
44
+ return 'gh no disponible o sin acceso al repo';
45
+ }
46
+
47
+ /**
48
+ * Obtiene los runs de CI. Degrada sin lanzar.
49
+ * @returns {{disponible:boolean, runs?:Array, razon?:string}}
50
+ */
51
+ function obtenerRuns(opts = {}) {
52
+ const { ejecutorGh = ejecutorGhReal, limite = LIMITE_DEFAULT, rama } = opts;
53
+ let salida;
54
+ try {
55
+ salida = ejecutorGh([
56
+ 'run', 'list',
57
+ '--json', 'databaseId,conclusion,createdAt,updatedAt,headBranch,workflowName',
58
+ '--limit', String(limite),
59
+ ]);
60
+ } catch (e) {
61
+ return { disponible: false, razon: _razonGh(e) };
62
+ }
63
+ let runs;
64
+ try {
65
+ runs = JSON.parse(salida);
66
+ } catch {
67
+ return { disponible: false, razon: 'salida de gh no es JSON válido' };
68
+ }
69
+ if (!Array.isArray(runs)) return { disponible: false, razon: 'salida de gh inesperada' };
70
+ if (rama) runs = runs.filter((r) => r && r.headBranch === rama);
71
+ return { disponible: true, runs };
72
+ }
73
+
74
+ function _runsEnVentana(runs, ventanaDias, hoy) {
75
+ const desde = new Date(hoy.getTime() - ventanaDias * MS_DIA).getTime();
76
+ const hasta = hoy.getTime();
77
+ return runs.filter((r) => {
78
+ const t = Date.parse(r && r.createdAt);
79
+ return Number.isFinite(t) && t >= desde && t <= hasta;
80
+ });
81
+ }
82
+
83
+ function _mediana(valoresOrdenados) {
84
+ const n = valoresOrdenados.length;
85
+ if (n === 0) return null;
86
+ const mid = Math.floor(n / 2);
87
+ return n % 2 ? valoresOrdenados[mid] : (valoresOrdenados[mid - 1] + valoresOrdenados[mid]) / 2;
88
+ }
89
+
90
+ /**
91
+ * Change failure rate en la ventana.
92
+ * @returns {{disponible:boolean, cfr?:number, totalRuns?:number, fallidos?:number,
93
+ * sinDatos?:boolean, razon?:string}}
94
+ */
95
+ function changeFailureRate(opts = {}) {
96
+ const { ventanaDias = 30, hoy = new Date() } = opts;
97
+ const base = opts.runs ? { disponible: true, runs: opts.runs } : obtenerRuns(opts);
98
+ if (!base.disponible) return { disponible: false, razon: base.razon };
99
+
100
+ const enVentana = _runsEnVentana(base.runs, ventanaDias, hoy);
101
+ const completados = enVentana.filter(
102
+ (r) => CONCLUSIONES_EXITO.has(r.conclusion) || CONCLUSIONES_FALLO.has(r.conclusion)
103
+ );
104
+ if (completados.length === 0) {
105
+ return { disponible: true, sinDatos: true, totalRuns: 0, fallidos: 0, cfr: 0 };
106
+ }
107
+ const fallidos = completados.filter((r) => CONCLUSIONES_FALLO.has(r.conclusion)).length;
108
+ return {
109
+ disponible: true,
110
+ sinDatos: false,
111
+ totalRuns: completados.length,
112
+ fallidos,
113
+ cfr: fallidos / completados.length,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * MTTR: mediana del tiempo de un run fallido hasta el siguiente run exitoso.
119
+ * @returns {{disponible:boolean, mttrHoras?:number, recuperaciones?:number,
120
+ * sinDatos?:boolean, razon?:string}}
121
+ */
122
+ function meanTimeToRestore(opts = {}) {
123
+ const { ventanaDias = 30, hoy = new Date() } = opts;
124
+ const base = opts.runs ? { disponible: true, runs: opts.runs } : obtenerRuns(opts);
125
+ if (!base.disponible) return { disponible: false, razon: base.razon };
126
+
127
+ const enVentana = _runsEnVentana(base.runs, ventanaDias, hoy)
128
+ .filter((r) => CONCLUSIONES_EXITO.has(r.conclusion) || CONCLUSIONES_FALLO.has(r.conclusion))
129
+ .map((r) => ({
130
+ fallo: CONCLUSIONES_FALLO.has(r.conclusion),
131
+ detectado: Date.parse(r.updatedAt || r.createdAt),
132
+ }))
133
+ .filter((r) => Number.isFinite(r.detectado))
134
+ .sort((a, b) => a.detectado - b.detectado);
135
+
136
+ const recuperaciones = [];
137
+ for (let i = 0; i < enVentana.length; i++) {
138
+ if (!enVentana[i].fallo) continue;
139
+ const exito = enVentana.slice(i + 1).find((r) => !r.fallo);
140
+ if (exito) {
141
+ const h = (exito.detectado - enVentana[i].detectado) / MS_HORA;
142
+ if (h >= 0) recuperaciones.push(h);
143
+ }
144
+ }
145
+ recuperaciones.sort((a, b) => a - b);
146
+ if (recuperaciones.length === 0) {
147
+ return { disponible: true, sinDatos: true, recuperaciones: 0, mttrHoras: null };
148
+ }
149
+ return {
150
+ disponible: true,
151
+ sinDatos: false,
152
+ recuperaciones: recuperaciones.length,
153
+ mttrHoras: _mediana(recuperaciones),
154
+ };
155
+ }
156
+
157
+ module.exports = {
158
+ obtenerRuns,
159
+ changeFailureRate,
160
+ meanTimeToRestore,
161
+ ejecutorGhReal,
162
+ CONCLUSIONES_FALLO,
163
+ CONCLUSIONES_EXITO,
164
+ };
165
+
166
+ // Entrypoint CLI: node scripts/lib/ci-reader.js [--json] [--dias=N]
167
+ if (!require.main || require.main === module) {
168
+ const args = process.argv.slice(2);
169
+ const json = args.includes('--json');
170
+ const diasArg = args.find((a) => a.startsWith('--dias='));
171
+ const ventanaDias = Math.max(1, Math.min(365, (diasArg ? parseInt(diasArg.slice('--dias='.length), 10) : 30) || 30));
172
+
173
+ // Una sola llamada a gh; si no está disponible, no se re-intenta.
174
+ const base = obtenerRuns({ limite: LIMITE_DEFAULT });
175
+ let out;
176
+ if (!base.disponible) {
177
+ out = { disponible: false, razon: base.razon };
178
+ } else {
179
+ const cfr = changeFailureRate({ ventanaDias, runs: base.runs });
180
+ const mttr = meanTimeToRestore({ ventanaDias, runs: base.runs });
181
+ out = { ventana_dias: ventanaDias, changeFailureRate: cfr, mttr };
182
+ }
183
+
184
+ if (json) {
185
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
186
+ } else if (!base.disponible) {
187
+ process.stdout.write(`CI no disponible: ${base.razon}\n`);
188
+ } else {
189
+ process.stdout.write(`Change failure rate: ${out.changeFailureRate.sinDatos ? 'sin datos' : (out.changeFailureRate.cfr * 100).toFixed(1) + '%'}\n`);
190
+ process.stdout.write(`MTTR: ${out.mttr.sinDatos ? 'sin datos' : out.mttr.mttrHoras.toFixed(1) + 'h'}\n`);
191
+ }
192
+ process.exit(0);
193
+ }