@saulwade/swl-ses 1.6.3 → 1.6.6

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 (46) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/agentes/gh-fix-ci-swl.md +275 -0
  4. package/agentes/nemesis-auditor-swl.md +90 -1
  5. package/comandos/swl/exportar-vault.md +106 -14
  6. package/comandos/swl/nemesis.md +70 -3
  7. package/comandos/swl/release.md +62 -2
  8. package/comandos/swl/salud.md +32 -0
  9. package/comandos/swl/verificar.md +116 -2
  10. package/habilidades/agent-browser/SKILL.md +111 -4
  11. package/habilidades/agent-deep-links/SKILL.md +148 -0
  12. package/habilidades/backend-async-postgres-testing/SKILL.md +215 -0
  13. package/habilidades/backend-error-design/SKILL.md +221 -0
  14. package/habilidades/browser-interaction-patterns/SKILL.md +514 -0
  15. package/habilidades/browser-research-domains/SKILL.md +635 -0
  16. package/habilidades/changelog-generator/SKILL.md +172 -0
  17. package/habilidades/changelog-generator/scripts/parse-commits.js +354 -0
  18. package/habilidades/devsecops-pipeline-security/SKILL.md +3 -0
  19. package/habilidades/fastapi-experto/SKILL.md +49 -4
  20. package/habilidades/harness-claude-code/SKILL.md +4 -1
  21. package/habilidades/postgresql-experto/SKILL.md +80 -4
  22. package/habilidades/proceso-discovery-machote/SKILL.md +157 -0
  23. package/habilidades/proceso-modular-split/SKILL.md +256 -0
  24. package/habilidades/tdd-workflow/SKILL.md +12 -5
  25. package/hooks/extraccion-aprendizajes.js +8 -0
  26. package/hooks/lib/deep-links.js +185 -0
  27. package/hooks/lib/evolution-tracker.js +148 -20
  28. package/hooks/lib/gateway-notify.js +70 -7
  29. package/manifiestos/modulos.json +13 -3
  30. package/manifiestos/skills-lock.json +1247 -1191
  31. package/package.json +92 -92
  32. package/plugin.json +371 -362
  33. package/reglas/arquitectura.md +38 -0
  34. package/reglas/arreglar-al-detectar.md +93 -0
  35. package/reglas/auditorias-documentales-estructurales.md +38 -0
  36. package/reglas/registro-componentes-nuevos.md +14 -0
  37. package/reglas/tests-cleanup.md +220 -0
  38. package/scripts/instalador.js +72 -4
  39. package/scripts/lib/mcp_config.py +29 -14
  40. package/scripts/lib/notificaciones-telegram.js +14 -0
  41. package/scripts/lib/transformadores/codex.js +4 -0
  42. package/scripts/lib/transformadores/cursor.js +5 -0
  43. package/scripts/mcp-orchestrator.py +153 -131
  44. package/scripts/mcp-pool-manager.py +132 -107
  45. package/scripts/mcp-telemetry.py +139 -120
  46. package/scripts/verificar-release.js +199 -1
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deep Links — Helper zero-deps para construir URLs que abren archivos en
5
+ * IDEs y editores directamente desde notificaciones.
6
+ *
7
+ * Cubre VS Code (vscode://), VS Code Insiders, Cursor (cursor://), JetBrains
8
+ * (jetbrains://), Codex Desktop (codex://) y fallbacks para apps sin esquema
9
+ * oficial (Visual Studio en Windows usa CLI fallback).
10
+ *
11
+ * Documentación funcional y matriz de soporte en:
12
+ * habilidades/agent-deep-links/SKILL.md
13
+ *
14
+ * Documentado en ADR-0029 (integración parcial awesome-codex-skills).
15
+ *
16
+ * @module hooks/lib/deep-links
17
+ */
18
+
19
+ const path = require('node:path');
20
+
21
+ /**
22
+ * IDEs soportados con esquema oficial confiable.
23
+ * Cualquier valor fuera de esta lista retorna null en construirDeepLink.
24
+ */
25
+ const IDES_SOPORTADOS = Object.freeze([
26
+ 'vscode',
27
+ 'vscode-insiders',
28
+ 'cursor',
29
+ 'codex',
30
+ 'jetbrains-idea',
31
+ 'jetbrains-pycharm',
32
+ 'jetbrains-webstorm',
33
+ 'jetbrains-goland',
34
+ 'jetbrains-rubymine',
35
+ 'jetbrains-clion',
36
+ 'jetbrains-rider',
37
+ 'jetbrains-phpstorm',
38
+ ]);
39
+
40
+ /**
41
+ * Map de IDE → esquema base. JetBrains usa formato distinto (manejado en
42
+ * función separada).
43
+ */
44
+ const ESQUEMAS = Object.freeze({
45
+ 'vscode': 'vscode://file',
46
+ 'vscode-insiders': 'vscode-insiders://file',
47
+ 'cursor': 'cursor://file',
48
+ });
49
+
50
+ /**
51
+ * Indica si un IDE soporta deep links a archivo:línea.
52
+ *
53
+ * @param {string} ide - Identificador del IDE.
54
+ * @returns {boolean}
55
+ */
56
+ function soportaDeepLinks(ide) {
57
+ if (typeof ide !== 'string') return false;
58
+ return IDES_SOPORTADOS.includes(ide);
59
+ }
60
+
61
+ /**
62
+ * Normaliza un path absoluto para uso en deep links.
63
+ * - Resuelve a absoluto (rechaza relativos).
64
+ * - Normaliza separadores (Windows backslash → forward slash; VS Code/Cursor
65
+ * aceptan forward slashes incluso en Windows).
66
+ * - Encoding URI para espacios y caracteres especiales.
67
+ *
68
+ * @param {string} rutaAbsoluta
69
+ * @returns {string|null}
70
+ * @private
71
+ */
72
+ function _normalizarPath(rutaAbsoluta) {
73
+ if (typeof rutaAbsoluta !== 'string' || rutaAbsoluta.length === 0) {
74
+ return null;
75
+ }
76
+ if (!path.isAbsolute(rutaAbsoluta)) {
77
+ return null;
78
+ }
79
+ // path.resolve normaliza .., ., dobles separadores.
80
+ const resuelto = path.resolve(rutaAbsoluta);
81
+ // Forward slashes en todos los SO — los IDEs los aceptan en Windows también.
82
+ const forwardSlashes = resuelto.split(path.sep).join('/');
83
+ // encodeURI preserva : y / (los necesitamos), encodea espacios y caracteres especiales.
84
+ return encodeURI(forwardSlashes);
85
+ }
86
+
87
+ /**
88
+ * Valida que línea y columna sean enteros positivos si están definidos.
89
+ * @private
90
+ */
91
+ function _validarPosicion(linea, columna) {
92
+ if (linea !== undefined && linea !== null) {
93
+ if (!Number.isInteger(linea) || linea < 1) return false;
94
+ }
95
+ if (columna !== undefined && columna !== null) {
96
+ if (!Number.isInteger(columna) || columna < 1) return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Construye un deep link para abrir un archivo en el IDE especificado.
103
+ *
104
+ * Retorna null cuando:
105
+ * - El IDE no soporta deep links a archivos (Codex Desktop, Xcode, etc.).
106
+ * - La ruta no es absoluta o está vacía.
107
+ * - Línea/columna están definidas pero no son enteros positivos.
108
+ * - El IDE es JetBrains pero falta `proyecto` (requerido).
109
+ *
110
+ * @param {object} params
111
+ * @param {string} params.ide - 'vscode' | 'vscode-insiders' | 'cursor' | 'jetbrains-<ide-id>'
112
+ * @param {string} params.rutaAbsoluta - Path absoluto al archivo. Forward o backslashes.
113
+ * @param {number} [params.linea] - Línea 1-indexed.
114
+ * @param {number} [params.columna] - Columna 1-indexed.
115
+ * @param {string} [params.proyecto] - Solo para JetBrains: nombre del proyecto.
116
+ * @returns {string|null} URL del deep link, o null si no se puede construir.
117
+ */
118
+ function construirDeepLink(params) {
119
+ const { ide, rutaAbsoluta, linea, columna, proyecto } = params || {};
120
+
121
+ if (!soportaDeepLinks(ide)) return null;
122
+ if (!_validarPosicion(linea, columna)) return null;
123
+
124
+ const pathNormalizado = _normalizarPath(rutaAbsoluta);
125
+ if (!pathNormalizado) return null;
126
+
127
+ // JetBrains usa un formato distinto al resto.
128
+ if (ide.startsWith('jetbrains-')) {
129
+ if (!proyecto || typeof proyecto !== 'string') return null;
130
+ const ideId = ide.slice('jetbrains-'.length);
131
+ // jetbrains://idea/navigate/reference?project=<name>&path=<rel>:<line>
132
+ const query = new URLSearchParams({
133
+ project: proyecto,
134
+ path: linea ? `${pathNormalizado}:${linea}` : pathNormalizado,
135
+ }).toString();
136
+ return `jetbrains://${ideId}/navigate/reference?${query}`;
137
+ }
138
+
139
+ // VS Code / Cursor / VS Code Insiders.
140
+ const base = ESQUEMAS[ide];
141
+ let url = `${base}${pathNormalizado.startsWith('/') ? '' : '/'}${pathNormalizado}`;
142
+ if (linea) {
143
+ url += `:${linea}`;
144
+ if (columna) {
145
+ url += `:${columna}`;
146
+ }
147
+ }
148
+ return url;
149
+ }
150
+
151
+ /**
152
+ * Formato según receptor — envuelve un deep link en la sintaxis correcta del
153
+ * canal destino. Devuelve texto plano (con sufijo "(abrir manualmente)") si
154
+ * el deep link es null.
155
+ *
156
+ * @param {string|null} deepLink - URL del deep link, o null para fallback.
157
+ * @param {string} etiqueta - Texto visible para el usuario.
158
+ * @param {string} receptor - 'telegram' | 'discord' | 'slack' | 'email' | 'plain'
159
+ * @returns {string}
160
+ */
161
+ function formatearEnlace(deepLink, etiqueta, receptor) {
162
+ if (!deepLink) {
163
+ return `${etiqueta} (abrir manualmente)`;
164
+ }
165
+ switch (receptor) {
166
+ case 'slack':
167
+ return `<${deepLink}|${etiqueta}>`;
168
+ case 'telegram':
169
+ case 'discord':
170
+ // Ambos usan Markdown estándar para enlaces.
171
+ return `[${etiqueta}](${deepLink})`;
172
+ case 'email':
173
+ return `<a href="${deepLink}">${etiqueta}</a>`;
174
+ case 'plain':
175
+ default:
176
+ return `${etiqueta}: ${deepLink}`;
177
+ }
178
+ }
179
+
180
+ module.exports = {
181
+ construirDeepLink,
182
+ formatearEnlace,
183
+ soportaDeepLinks,
184
+ IDES_SOPORTADOS,
185
+ };
@@ -46,6 +46,35 @@ const EVOLUTION_FIELDS = {
46
46
  /** Nombre del archivo sidecar para componentes sin frontmatter (reglas). */
47
47
  const SIDECAR_FILENAME = '.evolved.json';
48
48
 
49
+ /**
50
+ * Patrones de nombre de archivo que NUNCA deben tratarse como componentes
51
+ * evolucionados aunque tengan extensión `.md` y frontmatter `evolved: true`.
52
+ * Cubren residuos de herramientas externas (Syncthing, merge tools, editores).
53
+ *
54
+ * Cuando un dispositivo sincroniza vía Syncthing y hay un conflicto, se crea
55
+ * `SKILL.sync-conflict-YYYYMMDD-HHMMSS-XXXX.md` que es copia del original con
56
+ * el mismo frontmatter. Sin este filtro, scanEvolved los reportaba como
57
+ * "evoluciones" independientes y el output mostraba 4 SKILL.md duplicados sin
58
+ * forma de distinguirlos (v1.6.5 release sesión 2026-05-21).
59
+ */
60
+ const EXCLUDED_FILENAME_PATTERNS = [
61
+ /\.sync-conflict-/, // Syncthing
62
+ /\.orig$/, // git merge backup
63
+ /\.bak$/, // backups varios
64
+ /\.rej$/, // patch reject
65
+ /\.merge_file_/, // merge tools (kdiff3, etc.)
66
+ /~$/, // editores tipo Emacs/Vim
67
+ ];
68
+
69
+ /**
70
+ * Decide si un nombre de archivo debe ignorarse al escanear evoluciones.
71
+ * @param {string} filename
72
+ * @returns {boolean}
73
+ */
74
+ function isExcludedFilename(filename) {
75
+ return EXCLUDED_FILENAME_PATTERNS.some((re) => re.test(filename));
76
+ }
77
+
49
78
  /** Comandos que generan evoluciones. */
50
79
  const EVOLUTION_SOURCES = [
51
80
  'evolucionar',
@@ -345,6 +374,30 @@ function _stripEvolutionFields(content) {
345
374
  .join('\n');
346
375
  }
347
376
 
377
+ /**
378
+ * Separa frontmatter YAML del body en un archivo markdown SWL.
379
+ *
380
+ * Cuando se calcula el diff de mutaciones de un archivo evolucionado, el
381
+ * frontmatter SIEMPRE diverge (el destino tiene campos `evolved-*` que el
382
+ * origen no tiene, y viceversa con campos nuevos del paquete). Contar esas
383
+ * diferencias como "mutaciones del usuario" genera ruido masivo por
384
+ * desplazamiento de líneas. Esta función permite comparar solo el body.
385
+ *
386
+ * @param {string} content - Contenido completo del archivo .md.
387
+ * @returns {{ frontmatter: string, body: string }}
388
+ * - `frontmatter`: bloque YAML entre `---` (vacío si no hay frontmatter).
389
+ * - `body`: todo lo que viene después del frontmatter cerrado.
390
+ * @private
391
+ */
392
+ function _splitFrontmatterAndBody(content) {
393
+ const m = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/);
394
+ if (!m) return { frontmatter: '', body: content };
395
+ return { frontmatter: m[1], body: m[2] };
396
+ }
397
+
398
+ /** Umbral defensivo: tras este número de diffs el archivo pasa a modo resumen. */
399
+ const DIFF_NOISY_THRESHOLD = 50;
400
+
348
401
  // ---------------------------------------------------------------------------
349
402
  // Merge de evoluciones
350
403
  // ---------------------------------------------------------------------------
@@ -356,10 +409,29 @@ function _stripEvolutionFields(content) {
356
409
  * evolución (frontmatter evolved-*). Las mutaciones de contenido se preservan
357
410
  * generando un archivo .evolved-diff.md que Claude puede re-aplicar.
358
411
  *
412
+ * Comparación: solo el body (post-frontmatter) se compara línea-a-línea.
413
+ * El frontmatter SIEMPRE diverge (el destino tiene campos `evolved-*` que el
414
+ * origen no tiene, y viceversa con campos nuevos del paquete), por lo que
415
+ * contarlo como mutación genera ruido por desplazamiento.
416
+ *
417
+ * Limpieza: cuando un merge posterior elimina la divergencia (diffs vacíos),
418
+ * borra el `.evolved-diff.md` huérfano de sesiones previas si existe.
419
+ *
420
+ * Cap defensivo: si tras alinear correctamente el body aún hay más de
421
+ * `DIFF_NOISY_THRESHOLD` líneas distintas, genera un resumen estadístico
422
+ * con muestra (primeras 20 + últimas 5) en lugar del dump completo.
423
+ *
359
424
  * @param {string} destino - Ruta del archivo evolucionado (local).
360
425
  * @param {string} origen - Ruta del archivo nuevo del paquete.
361
426
  * @param {string} versionNueva - Versión del paquete nuevo.
362
- * @returns {{ merged: boolean, diffPath?: string, error?: string }}
427
+ * @returns {{
428
+ * merged: boolean,
429
+ * diffPath?: string,
430
+ * diffsCount?: number,
431
+ * cleanedDiff?: boolean,
432
+ * truncated?: boolean,
433
+ * error?: string
434
+ * }}
363
435
  */
364
436
  function mergeEvolved(destino, origen, versionNueva) {
365
437
  try {
@@ -375,11 +447,14 @@ function mergeEvolved(destino, origen, versionNueva) {
375
447
  const destinoContent = fs.readFileSync(destino, 'utf8');
376
448
  const origenContent = fs.readFileSync(origen, 'utf8');
377
449
 
378
- // Extraer las líneas que son diferentes entre destino (sin evolved fields) y origen.
379
- // Normalizar CRLF a LF para comparar independientemente del SO de origen.
380
- const destinoSinEvo = _stripEvolutionFields(destinoContent);
381
- const origenLines = origenContent.split(/\r?\n/);
382
- const destinoLines = destinoSinEvo.split(/\r?\n/);
450
+ // Comparar SOLO el body, no el frontmatter. El frontmatter del destino
451
+ // tiene los campos `evolved-*` que el origen no tiene contarlos como
452
+ // mutaciones desplaza todas las líneas siguientes y genera ruido.
453
+ const { body: destinoBody } = _splitFrontmatterAndBody(destinoContent);
454
+ const { body: origenBody } = _splitFrontmatterAndBody(origenContent);
455
+
456
+ const origenLines = origenBody.split(/\r?\n/);
457
+ const destinoLines = destinoBody.split(/\r?\n/);
383
458
 
384
459
  const diffs = [];
385
460
  const maxLen = Math.max(origenLines.length, destinoLines.length);
@@ -395,34 +470,85 @@ function mergeEvolved(destino, origen, versionNueva) {
395
470
  }
396
471
  }
397
472
 
473
+ const diffPath = destino.replace(/\.md$/, '.evolved-diff.md');
474
+
398
475
  if (diffs.length === 0) {
399
- // Sin diferencias reales — solo re-aplicar campos evolved al nuevo
476
+ // Sin diferencias reales — limpiar diff huérfano si existe (de sesión
477
+ // previa donde sí hubo divergencia que ya quedó resuelta) y re-aplicar
478
+ // campos evolved al destino.
479
+ let cleanedDiff = false;
480
+ if (fs.existsSync(diffPath)) {
481
+ try {
482
+ fs.unlinkSync(diffPath);
483
+ cleanedDiff = true;
484
+ } catch {
485
+ // Best-effort: si el unlink falla por permisos/locks, dejarlo —
486
+ // el merge sigue siendo válido.
487
+ }
488
+ }
489
+
490
+ // force: true — `mergeEvolved` solo se invoca en contexto de update
491
+ // intencional. El skip de isPackageRoot() aplica a la primera marca
492
+ // del mantenedor, no a re-aplicar campos tras un merge resuelto.
400
493
  const marked = markAsEvolved(destino, {
401
494
  from: versionNueva,
402
495
  by: evo.metadata.evolvedBy || 'auto-evolución',
403
496
  rounds: evo.metadata.evolvedRounds ? parseInt(evo.metadata.evolvedRounds, 10) : undefined,
404
497
  score: evo.metadata.evolvedScore,
405
498
  note: `Re-aplicado desde v${evo.metadata.evolvedFrom || '?'} tras actualización a v${versionNueva}`,
499
+ force: true,
406
500
  });
407
- return { merged: marked.marked };
501
+ return { merged: marked.marked, cleanedDiff };
408
502
  }
409
503
 
410
- // Guardar diff para revisión/re-aplicación por Claude
411
- const diffPath = destino.replace(/\.md$/, '.evolved-diff.md');
412
- const diffContent = [
504
+ // Cap defensivo: tras alinear correctamente sigue habiendo más de N diffs.
505
+ // En lugar de dumpear cada línea (puede explotar a miles), generar resumen
506
+ // con muestra acotada.
507
+ const truncated = diffs.length > DIFF_NOISY_THRESHOLD;
508
+ const diffsParaMostrar = truncated
509
+ ? [...diffs.slice(0, 20), ...diffs.slice(-5)]
510
+ : diffs;
511
+
512
+ const header = [
413
513
  `# Diff de evolución — ${path.basename(destino)}`,
414
514
  ``,
415
515
  `**Archivo evolucionado por**: ${evo.metadata.evolvedBy || 'auto-evolución'}`,
416
516
  `**Versión base original**: ${evo.metadata.evolvedFrom || '?'}`,
417
517
  `**Versión nueva**: ${versionNueva}`,
418
518
  `**Fecha**: ${new Date().toISOString().split('T')[0]}`,
519
+ `**Diferencias detectadas (body)**: ${diffs.length}`,
419
520
  ``,
420
- `## Mutaciones locales a re-aplicar`,
421
- ``,
422
- `Estas son las líneas que difieren entre la versión evolucionada y la nueva.`,
423
- `Re-aplicar con \`/swl:autoresearch\` o manualmente.`,
424
- ``,
425
- ...diffs.map(d => [
521
+ ];
522
+
523
+ if (truncated) {
524
+ header.push(
525
+ `## ⚠ Resumen (truncado)`,
526
+ ``,
527
+ `El diff excede el umbral defensivo de ${DIFF_NOISY_THRESHOLD} líneas`,
528
+ `(${diffs.length} diferencias detectadas). Esto suele indicar:`,
529
+ ``,
530
+ `- El archivo fue reescrito completo entre versiones (rebrand, refactor).`,
531
+ `- El alineamiento línea-a-línea no es útil aquí — usar \`git diff\`.`,
532
+ ``,
533
+ `Se muestran las primeras 20 + últimas 5 diferencias como muestra.`,
534
+ `Para diff completo: \`diff <(sed '1,/^---$/d; 1,/^---$/d' archivo) <(...)\`.`,
535
+ ``,
536
+ `## Muestra de mutaciones (primeras 20 + últimas 5)`,
537
+ ``,
538
+ );
539
+ } else {
540
+ header.push(
541
+ `## Mutaciones locales a re-aplicar`,
542
+ ``,
543
+ `Estas son las líneas del body que difieren entre la versión`,
544
+ `evolucionada y la nueva. Re-aplicar con \`/swl:autoresearch\` o manualmente.`,
545
+ ``,
546
+ );
547
+ }
548
+
549
+ const diffContent = [
550
+ ...header,
551
+ ...diffsParaMostrar.map(d => [
426
552
  `### Línea ${d.line}`,
427
553
  `- **Nueva (base)**: \`${d.origen}\``,
428
554
  `- **Evolucionada**: \`${d.destino}\``,
@@ -432,7 +558,7 @@ function mergeEvolved(destino, origen, versionNueva) {
432
558
 
433
559
  atomicWriteSync(diffPath, diffContent, 'utf8');
434
560
 
435
- return { merged: true, diffPath, diffsCount: diffs.length };
561
+ return { merged: true, diffPath, diffsCount: diffs.length, truncated };
436
562
  } catch (err) {
437
563
  return { merged: false, error: err.message };
438
564
  }
@@ -463,7 +589,7 @@ function scanEvolved(dir, opts = {}) {
463
589
 
464
590
  if (entry.isDirectory() && recursive) {
465
591
  results.push(...scanEvolved(fullPath, opts));
466
- } else if (entry.name.endsWith('.md')) {
592
+ } else if (entry.name.endsWith('.md') && !isExcludedFilename(entry.name)) {
467
593
  const evo = readEvolutionMeta(fullPath);
468
594
  if (evo.evolved) {
469
595
  results.push({ path: fullPath, ...evo.metadata });
@@ -478,7 +604,7 @@ function scanEvolved(dir, opts = {}) {
478
604
  try {
479
605
  const data = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
480
606
  for (const [filename, meta] of Object.entries(data)) {
481
- if (meta.evolved) {
607
+ if (meta.evolved && !isExcludedFilename(filename)) {
482
608
  results.push({ path: path.join(dir, filename), ...meta });
483
609
  }
484
610
  }
@@ -498,8 +624,10 @@ module.exports = {
498
624
  decideUpdateStrategy,
499
625
  mergeEvolved,
500
626
  scanEvolved,
627
+ isExcludedFilename,
501
628
  isPackageRoot,
502
629
  EVOLUTION_FIELDS,
503
630
  EVOLUTION_SOURCES,
631
+ EXCLUDED_FILENAME_PATTERNS,
504
632
  SIDECAR_FILENAME,
505
633
  };
@@ -21,9 +21,23 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { atomicWriteJSON } = require('./atomic-write');
23
23
 
24
+ // Deep links opt-in (ADR-0029). Si el módulo no carga (instalación incompleta),
25
+ // el enriquecimiento se omite silenciosamente — comportamiento default sin
26
+ // cambios respecto a versiones previas.
27
+ let construirDeepLink, formatearEnlace;
28
+ try {
29
+ ({ construirDeepLink, formatearEnlace } = require('./deep-links'));
30
+ } catch {
31
+ construirDeepLink = () => null;
32
+ formatearEnlace = (_, etiqueta) => etiqueta;
33
+ }
34
+
24
35
  const COMMS_DIR = '.planning/comms';
25
36
  const CONFIG_PATH = 'manifiestos/gateway-config.json';
26
37
 
38
+ /** Receptores reconocidos para formato de deep link. */
39
+ const RECEPTORES = new Set(['telegram', 'discord', 'slack', 'email', 'plain']);
40
+
27
41
  /**
28
42
  * Verifica si el gateway está habilitado en configuración.
29
43
  * @returns {boolean}
@@ -66,10 +80,52 @@ function tipoHabilitado(tipo) {
66
80
  }
67
81
  }
68
82
 
83
+ /**
84
+ * Si el payload incluye `fileRef` + `idePreferido`, retorna el enlace
85
+ * formateado al receptor; en otro caso retorna null. No modifica el payload.
86
+ *
87
+ * Estructura esperada del payload (opt-in, ADR-0029):
88
+ * payload.fileRef = {
89
+ * archivo: '/abs/path/al/archivo.js',
90
+ * linea?: 42,
91
+ * columna?: 8,
92
+ * etiqueta?: 'Abrir archivo.js:42', // default: archivo:linea
93
+ * proyecto?: 'mi-proyecto', // requerido solo para JetBrains
94
+ * }
95
+ * payload.idePreferido = 'vscode' | 'cursor' | ...
96
+ *
97
+ * @param {object} payload
98
+ * @param {string} receptor - 'telegram' | 'discord' | 'slack' | 'email' | 'plain'
99
+ * @returns {string|null}
100
+ * @private
101
+ */
102
+ function _construirEnlaceFileRef(payload, receptor) {
103
+ if (!payload || !payload.fileRef || !payload.idePreferido) return null;
104
+ const { archivo, linea, columna, etiqueta, proyecto } = payload.fileRef;
105
+ if (!archivo) return null;
106
+
107
+ const url = construirDeepLink({
108
+ ide: payload.idePreferido,
109
+ rutaAbsoluta: archivo,
110
+ linea,
111
+ columna,
112
+ proyecto,
113
+ });
114
+
115
+ const label = etiqueta || (linea ? `${path.basename(archivo)}:${linea}` : path.basename(archivo));
116
+ const formato = RECEPTORES.has(receptor) ? receptor : 'plain';
117
+ return formatearEnlace(url, label, formato);
118
+ }
119
+
69
120
  /**
70
121
  * Encola una notificación para el gateway.
71
122
  * No bloquea ni lanza. Retorna true si se encoló, false si fue descartada.
72
123
  *
124
+ * Enriquecimiento opt-in (ADR-0029): si `params.payload.fileRef` +
125
+ * `params.payload.idePreferido` están presentes, el mensaje agrega el campo
126
+ * `enlace` con un deep link formateado al receptor. Sin esos campos, el
127
+ * comportamiento es idéntico al previo (compatibilidad hacia atrás).
128
+ *
73
129
  * @param {object} params
74
130
  * @param {string} params.tipo - session-stop, checkpoint, error, release, build-fail, custom
75
131
  * @param {string} [params.titulo] - Título corto.
@@ -89,17 +145,24 @@ function notificarGateway(params) {
89
145
  }
90
146
 
91
147
  const id = `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
148
+ const receptor = params.to || 'all';
149
+ const payloadBase = {
150
+ tipo: params.tipo,
151
+ titulo: params.titulo || '',
152
+ texto: params.texto || '',
153
+ ...(params.payload || {}),
154
+ };
155
+
156
+ // Enriquecimiento opt-in con deep link (ADR-0029).
157
+ const enlace = _construirEnlaceFileRef(payloadBase, receptor);
158
+ if (enlace) payloadBase.enlace = enlace;
159
+
92
160
  const msg = {
93
161
  id,
94
162
  type: 'gateway_notification',
95
163
  from: 'swl-system',
96
- to: params.to || 'all',
97
- payload: {
98
- tipo: params.tipo,
99
- titulo: params.titulo || '',
100
- texto: params.texto || '',
101
- ...(params.payload || {}),
102
- },
164
+ to: receptor,
165
+ payload: payloadBase,
103
166
  text: params.texto || '',
104
167
  timestamp: new Date().toISOString(),
105
168
  status: 'pending',
@@ -18,6 +18,7 @@
18
18
  "agentes/depurador-swl.md",
19
19
  "agentes/documentador-swl.md",
20
20
  "agentes/resolutor-build-swl.md",
21
+ "agentes/gh-fix-ci-swl.md",
21
22
  "agentes/perfilador-usuario-swl.md"
22
23
  ],
23
24
  "targets": [
@@ -241,7 +242,11 @@
241
242
  "habilidades/reducir-entropia",
242
243
  "habilidades/proceso-intent-engineering",
243
244
  "habilidades/proceso-ddia-fundamentos",
244
- "habilidades/proceso-ddia-streaming"
245
+ "habilidades/proceso-ddia-streaming",
246
+ "habilidades/proceso-discovery-machote",
247
+ "habilidades/proceso-modular-split",
248
+ "habilidades/agent-deep-links",
249
+ "habilidades/changelog-generator"
245
250
  ],
246
251
  "targets": [
247
252
  "claude",
@@ -264,7 +269,9 @@
264
269
  "habilidades/testing-python",
265
270
  "habilidades/manejo-errores",
266
271
  "habilidades/auth-patrones",
267
- "habilidades/build-errors-python"
272
+ "habilidades/build-errors-python",
273
+ "habilidades/backend-error-design",
274
+ "habilidades/backend-async-postgres-testing"
268
275
  ],
269
276
  "targets": [
270
277
  "claude",
@@ -709,6 +716,8 @@
709
716
  "habilidades/prompt-engineering",
710
717
  "habilidades/structured-outputs",
711
718
  "habilidades/agent-browser",
719
+ "habilidades/browser-interaction-patterns",
720
+ "habilidades/browser-research-domains",
712
721
  "habilidades/wiki-conocimiento",
713
722
  "habilidades/orquestacion-async",
714
723
  "habilidades/diagrama-arquitectura",
@@ -895,7 +904,8 @@
895
904
  "reglas/analisis-previo-tareas-grandes.md",
896
905
  "reglas/registro-componentes-nuevos.md",
897
906
  "reglas/auditorias-documentales-estructurales.md",
898
- "reglas/intent-engineering.md"
907
+ "reglas/intent-engineering.md",
908
+ "reglas/tests-cleanup.md"
899
909
  ],
900
910
  "targets": [
901
911
  "claude",