@saulwade/swl-ses 1.6.1 → 1.6.5

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 (85) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +4 -4
  3. package/agentes/_intent-spec.md +73 -0
  4. package/agentes/auto-evolucion-swl.md +24 -0
  5. package/agentes/cloud-infra-swl.md +25 -0
  6. package/agentes/datos-swl.md +23 -0
  7. package/agentes/devops-ci-swl.md +24 -0
  8. package/agentes/gh-fix-ci-swl.md +275 -0
  9. package/agentes/migrador-swl.md +22 -0
  10. package/agentes/nemesis-auditor-swl.md +90 -1
  11. package/agentes/pagos-swl.md +25 -0
  12. package/agentes/release-manager-swl.md +24 -0
  13. package/agentes/sre-swl.md +24 -0
  14. package/comandos/swl/exportar-vault.md +106 -14
  15. package/comandos/swl/nemesis.md +70 -3
  16. package/comandos/swl/planear-fase.md +16 -0
  17. package/comandos/swl/release.md +62 -2
  18. package/comandos/swl/salud.md +32 -0
  19. package/comandos/swl/verificar.md +116 -2
  20. package/habilidades/agent-browser/SKILL.md +111 -4
  21. package/habilidades/agent-deep-links/SKILL.md +148 -0
  22. package/habilidades/aprender-de-git-diff/SKILL.md +288 -0
  23. package/habilidades/backend-async-postgres-testing/SKILL.md +215 -0
  24. package/habilidades/backend-error-design/SKILL.md +221 -0
  25. package/habilidades/browser-interaction-patterns/SKILL.md +514 -0
  26. package/habilidades/browser-research-domains/SKILL.md +635 -0
  27. package/habilidades/changelog-generator/SKILL.md +172 -0
  28. package/habilidades/changelog-generator/scripts/parse-commits.js +354 -0
  29. package/habilidades/devsecops-pipeline-security/SKILL.md +3 -0
  30. package/habilidades/diseno-herramientas-agente/SKILL.md +17 -1
  31. package/habilidades/fastapi-experto/SKILL.md +49 -4
  32. package/habilidades/harness-claude-code/SKILL.md +4 -1
  33. package/habilidades/meta-skills-estandar/SKILL.md +6 -0
  34. package/habilidades/meta-skills-estandar/recursos/skill-judge-rubrica.md +281 -0
  35. package/habilidades/postgresql-experto/SKILL.md +80 -4
  36. package/habilidades/proceso-autoverificacion-evidencias/SKILL.md +258 -0
  37. package/habilidades/proceso-confianza-pre-implementacion/SKILL.md +246 -0
  38. package/habilidades/proceso-ddia-fundamentos/SKILL.md +255 -0
  39. package/habilidades/proceso-ddia-streaming/SKILL.md +231 -0
  40. package/habilidades/proceso-discovery-machote/SKILL.md +157 -0
  41. package/habilidades/proceso-intent-engineering/SKILL.md +269 -0
  42. package/habilidades/proceso-modular-split/SKILL.md +256 -0
  43. package/habilidades/reducir-entropia/SKILL.md +219 -0
  44. package/habilidades/tdd-workflow/SKILL.md +12 -5
  45. package/hooks/extraccion-aprendizajes.js +8 -0
  46. package/hooks/lib/deep-links.js +185 -0
  47. package/hooks/lib/evolution-tracker.js +115 -18
  48. package/hooks/lib/gateway-notify.js +70 -7
  49. package/hooks/lib/task-budget.js +218 -0
  50. package/hooks/validar-intent-spec.js +222 -0
  51. package/manifiestos/hooks-config.json +9 -0
  52. package/manifiestos/modulos.json +22 -3
  53. package/manifiestos/skills-lock.json +1247 -1142
  54. package/package.json +3 -3
  55. package/plugin.json +18 -2
  56. package/reglas/arquitectura.md +38 -0
  57. package/reglas/arreglar-al-detectar.md +93 -0
  58. package/reglas/auditorias-documentales-estructurales.md +38 -0
  59. package/reglas/fragmentos-compartidos.md +26 -0
  60. package/reglas/intent-engineering.md +214 -0
  61. package/reglas/registro-componentes-nuevos.md +52 -0
  62. package/reglas/tests-cleanup.md +220 -0
  63. package/schemas/agent-frontmatter.schema.json +294 -167
  64. package/schemas/agent-message.schema.json +73 -53
  65. package/schemas/agent-output-implementacion.schema.json +114 -85
  66. package/schemas/agent-output-planificacion.schema.json +150 -113
  67. package/schemas/agent-output-review.schema.json +98 -78
  68. package/schemas/diary-entry.schema.json +42 -10
  69. package/schemas/hook-profiles.schema.json +54 -39
  70. package/schemas/hooks-config.schema.json +89 -74
  71. package/schemas/instinct.schema.json +152 -115
  72. package/schemas/modulos.schema.json +38 -29
  73. package/schemas/perfiles.schema.json +36 -28
  74. package/schemas/plugin.schema.json +77 -64
  75. package/schemas/skill-evals.schema.json +119 -95
  76. package/schemas/skill-frontmatter.schema.json +245 -170
  77. package/scripts/generar-inventario.js +3 -1
  78. package/scripts/lib/mcp_config.py +29 -14
  79. package/scripts/lib/schema-version.js +164 -0
  80. package/scripts/mcp-orchestrator.py +153 -131
  81. package/scripts/mcp-pool-manager.py +132 -107
  82. package/scripts/mcp-telemetry.py +139 -120
  83. package/scripts/validar-manifest.js +1 -1
  84. package/scripts/validar.js +3 -2
  85. 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
+ };
@@ -345,6 +345,30 @@ function _stripEvolutionFields(content) {
345
345
  .join('\n');
346
346
  }
347
347
 
348
+ /**
349
+ * Separa frontmatter YAML del body en un archivo markdown SWL.
350
+ *
351
+ * Cuando se calcula el diff de mutaciones de un archivo evolucionado, el
352
+ * frontmatter SIEMPRE diverge (el destino tiene campos `evolved-*` que el
353
+ * origen no tiene, y viceversa con campos nuevos del paquete). Contar esas
354
+ * diferencias como "mutaciones del usuario" genera ruido masivo por
355
+ * desplazamiento de líneas. Esta función permite comparar solo el body.
356
+ *
357
+ * @param {string} content - Contenido completo del archivo .md.
358
+ * @returns {{ frontmatter: string, body: string }}
359
+ * - `frontmatter`: bloque YAML entre `---` (vacío si no hay frontmatter).
360
+ * - `body`: todo lo que viene después del frontmatter cerrado.
361
+ * @private
362
+ */
363
+ function _splitFrontmatterAndBody(content) {
364
+ const m = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/);
365
+ if (!m) return { frontmatter: '', body: content };
366
+ return { frontmatter: m[1], body: m[2] };
367
+ }
368
+
369
+ /** Umbral defensivo: tras este número de diffs el archivo pasa a modo resumen. */
370
+ const DIFF_NOISY_THRESHOLD = 50;
371
+
348
372
  // ---------------------------------------------------------------------------
349
373
  // Merge de evoluciones
350
374
  // ---------------------------------------------------------------------------
@@ -356,10 +380,29 @@ function _stripEvolutionFields(content) {
356
380
  * evolución (frontmatter evolved-*). Las mutaciones de contenido se preservan
357
381
  * generando un archivo .evolved-diff.md que Claude puede re-aplicar.
358
382
  *
383
+ * Comparación: solo el body (post-frontmatter) se compara línea-a-línea.
384
+ * El frontmatter SIEMPRE diverge (el destino tiene campos `evolved-*` que el
385
+ * origen no tiene, y viceversa con campos nuevos del paquete), por lo que
386
+ * contarlo como mutación genera ruido por desplazamiento.
387
+ *
388
+ * Limpieza: cuando un merge posterior elimina la divergencia (diffs vacíos),
389
+ * borra el `.evolved-diff.md` huérfano de sesiones previas si existe.
390
+ *
391
+ * Cap defensivo: si tras alinear correctamente el body aún hay más de
392
+ * `DIFF_NOISY_THRESHOLD` líneas distintas, genera un resumen estadístico
393
+ * con muestra (primeras 20 + últimas 5) en lugar del dump completo.
394
+ *
359
395
  * @param {string} destino - Ruta del archivo evolucionado (local).
360
396
  * @param {string} origen - Ruta del archivo nuevo del paquete.
361
397
  * @param {string} versionNueva - Versión del paquete nuevo.
362
- * @returns {{ merged: boolean, diffPath?: string, error?: string }}
398
+ * @returns {{
399
+ * merged: boolean,
400
+ * diffPath?: string,
401
+ * diffsCount?: number,
402
+ * cleanedDiff?: boolean,
403
+ * truncated?: boolean,
404
+ * error?: string
405
+ * }}
363
406
  */
364
407
  function mergeEvolved(destino, origen, versionNueva) {
365
408
  try {
@@ -375,11 +418,14 @@ function mergeEvolved(destino, origen, versionNueva) {
375
418
  const destinoContent = fs.readFileSync(destino, 'utf8');
376
419
  const origenContent = fs.readFileSync(origen, 'utf8');
377
420
 
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/);
421
+ // Comparar SOLO el body, no el frontmatter. El frontmatter del destino
422
+ // tiene los campos `evolved-*` que el origen no tiene contarlos como
423
+ // mutaciones desplaza todas las líneas siguientes y genera ruido.
424
+ const { body: destinoBody } = _splitFrontmatterAndBody(destinoContent);
425
+ const { body: origenBody } = _splitFrontmatterAndBody(origenContent);
426
+
427
+ const origenLines = origenBody.split(/\r?\n/);
428
+ const destinoLines = destinoBody.split(/\r?\n/);
383
429
 
384
430
  const diffs = [];
385
431
  const maxLen = Math.max(origenLines.length, destinoLines.length);
@@ -395,34 +441,85 @@ function mergeEvolved(destino, origen, versionNueva) {
395
441
  }
396
442
  }
397
443
 
444
+ const diffPath = destino.replace(/\.md$/, '.evolved-diff.md');
445
+
398
446
  if (diffs.length === 0) {
399
- // Sin diferencias reales — solo re-aplicar campos evolved al nuevo
447
+ // Sin diferencias reales — limpiar diff huérfano si existe (de sesión
448
+ // previa donde sí hubo divergencia que ya quedó resuelta) y re-aplicar
449
+ // campos evolved al destino.
450
+ let cleanedDiff = false;
451
+ if (fs.existsSync(diffPath)) {
452
+ try {
453
+ fs.unlinkSync(diffPath);
454
+ cleanedDiff = true;
455
+ } catch {
456
+ // Best-effort: si el unlink falla por permisos/locks, dejarlo —
457
+ // el merge sigue siendo válido.
458
+ }
459
+ }
460
+
461
+ // force: true — `mergeEvolved` solo se invoca en contexto de update
462
+ // intencional. El skip de isPackageRoot() aplica a la primera marca
463
+ // del mantenedor, no a re-aplicar campos tras un merge resuelto.
400
464
  const marked = markAsEvolved(destino, {
401
465
  from: versionNueva,
402
466
  by: evo.metadata.evolvedBy || 'auto-evolución',
403
467
  rounds: evo.metadata.evolvedRounds ? parseInt(evo.metadata.evolvedRounds, 10) : undefined,
404
468
  score: evo.metadata.evolvedScore,
405
469
  note: `Re-aplicado desde v${evo.metadata.evolvedFrom || '?'} tras actualización a v${versionNueva}`,
470
+ force: true,
406
471
  });
407
- return { merged: marked.marked };
472
+ return { merged: marked.marked, cleanedDiff };
408
473
  }
409
474
 
410
- // Guardar diff para revisión/re-aplicación por Claude
411
- const diffPath = destino.replace(/\.md$/, '.evolved-diff.md');
412
- const diffContent = [
475
+ // Cap defensivo: tras alinear correctamente sigue habiendo más de N diffs.
476
+ // En lugar de dumpear cada línea (puede explotar a miles), generar resumen
477
+ // con muestra acotada.
478
+ const truncated = diffs.length > DIFF_NOISY_THRESHOLD;
479
+ const diffsParaMostrar = truncated
480
+ ? [...diffs.slice(0, 20), ...diffs.slice(-5)]
481
+ : diffs;
482
+
483
+ const header = [
413
484
  `# Diff de evolución — ${path.basename(destino)}`,
414
485
  ``,
415
486
  `**Archivo evolucionado por**: ${evo.metadata.evolvedBy || 'auto-evolución'}`,
416
487
  `**Versión base original**: ${evo.metadata.evolvedFrom || '?'}`,
417
488
  `**Versión nueva**: ${versionNueva}`,
418
489
  `**Fecha**: ${new Date().toISOString().split('T')[0]}`,
490
+ `**Diferencias detectadas (body)**: ${diffs.length}`,
419
491
  ``,
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 => [
492
+ ];
493
+
494
+ if (truncated) {
495
+ header.push(
496
+ `## ⚠ Resumen (truncado)`,
497
+ ``,
498
+ `El diff excede el umbral defensivo de ${DIFF_NOISY_THRESHOLD} líneas`,
499
+ `(${diffs.length} diferencias detectadas). Esto suele indicar:`,
500
+ ``,
501
+ `- El archivo fue reescrito completo entre versiones (rebrand, refactor).`,
502
+ `- El alineamiento línea-a-línea no es útil aquí — usar \`git diff\`.`,
503
+ ``,
504
+ `Se muestran las primeras 20 + últimas 5 diferencias como muestra.`,
505
+ `Para diff completo: \`diff <(sed '1,/^---$/d; 1,/^---$/d' archivo) <(...)\`.`,
506
+ ``,
507
+ `## Muestra de mutaciones (primeras 20 + últimas 5)`,
508
+ ``,
509
+ );
510
+ } else {
511
+ header.push(
512
+ `## Mutaciones locales a re-aplicar`,
513
+ ``,
514
+ `Estas son las líneas del body que difieren entre la versión`,
515
+ `evolucionada y la nueva. Re-aplicar con \`/swl:autoresearch\` o manualmente.`,
516
+ ``,
517
+ );
518
+ }
519
+
520
+ const diffContent = [
521
+ ...header,
522
+ ...diffsParaMostrar.map(d => [
426
523
  `### Línea ${d.line}`,
427
524
  `- **Nueva (base)**: \`${d.origen}\``,
428
525
  `- **Evolucionada**: \`${d.destino}\``,
@@ -432,7 +529,7 @@ function mergeEvolved(destino, origen, versionNueva) {
432
529
 
433
530
  atomicWriteSync(diffPath, diffContent, 'utf8');
434
531
 
435
- return { merged: true, diffPath, diffsCount: diffs.length };
532
+ return { merged: true, diffPath, diffsCount: diffs.length, truncated };
436
533
  } catch (err) {
437
534
  return { merged: false, error: err.message };
438
535
  }
@@ -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',
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * task-budget.js — presupuesto SEMÁNTICO de tokens por tarea.
5
+ *
6
+ * Adaptación de SuperClaude_Framework `pm_agent/token_budget.py` (MIT) a Node
7
+ * zero-deps. Provee un contrato de presupuesto por COMPLEJIDAD de tarea que
8
+ * agentes y orquestador pueden consultar para gating, NO un contador exacto
9
+ * de tokens consumidos (el modelo no expone tokens en runtime).
10
+ *
11
+ * IMPORTANTE: no confundir con `hooks/lib/token-budget.js`, que distribuye
12
+ * presupuesto entre BLOQUES DE CONTEXTO (memoria/observaciones/reglas) con
13
+ * scoring de recencia+importancia. Aquel es para inyección de contexto;
14
+ * éste es para clasificación de TAREAS.
15
+ *
16
+ * Niveles de complejidad y presupuesto recomendado:
17
+ * - simple: 200 tokens — typo fix, rename, comentario, formato.
18
+ * - medium: 1000 tokens — bugfix acotado, feature pequeña.
19
+ * - complex: 2500 tokens — feature grande, refactor cross-módulo.
20
+ *
21
+ * Uso típico:
22
+ * const { TaskBudget } = require('./task-budget');
23
+ * const budget = new TaskBudget('medium');
24
+ * if (budget.tryUse(150)) {
25
+ * // proceder con la sub-tarea
26
+ * } else {
27
+ * // presupuesto agotado — pausar, reportar, escalar
28
+ * }
29
+ *
30
+ * Semántica de "rebasado": si un agente declara tarea "simple" y consume
31
+ * 800 tokens, hay señal de que la clasificación fue incorrecta o el scope
32
+ * creció. El orquestador puede actuar (re-clasificar, pausar, alertar).
33
+ *
34
+ * @module hooks/lib/task-budget
35
+ */
36
+
37
+ /** @typedef {'simple' | 'medium' | 'complex'} ComplexityLevel */
38
+
39
+ /**
40
+ * Límites por defecto. Defaults recomendados por el material fuente
41
+ * (SuperClaude pm_agent). Modificables vía constructor para escenarios
42
+ * especiales.
43
+ * @type {Readonly<Record<ComplexityLevel, number>>}
44
+ */
45
+ const DEFAULT_LIMITS = Object.freeze({
46
+ simple: 200,
47
+ medium: 1000,
48
+ complex: 2500,
49
+ });
50
+
51
+ /**
52
+ * Gestor de presupuesto semántico por tarea.
53
+ *
54
+ * Inmutable en `limit` y `complexity` tras la construcción; mutable solo
55
+ * en `used` vía `tryUse()` / `forceUse()` / `reset()`.
56
+ */
57
+ class TaskBudget {
58
+ /**
59
+ * @param {ComplexityLevel} [complexity='medium'] - Complejidad declarada.
60
+ * @param {Partial<Record<ComplexityLevel, number>>} [overrides] - Límites custom.
61
+ */
62
+ constructor(complexity = 'medium', overrides = undefined) {
63
+ const limits = overrides
64
+ ? Object.freeze({ ...DEFAULT_LIMITS, ...overrides })
65
+ : DEFAULT_LIMITS;
66
+
67
+ // Default defensivo a 'medium' si llega un nivel desconocido — el
68
+ // material fuente hace lo mismo. Evita excepciones por typo.
69
+ const normalized = Object.prototype.hasOwnProperty.call(limits, complexity)
70
+ ? complexity
71
+ : 'medium';
72
+
73
+ /** @type {ComplexityLevel} */
74
+ this.complexity = /** @type {ComplexityLevel} */ (normalized);
75
+ /** @type {number} */
76
+ this.limit = limits[normalized];
77
+ /** @type {number} */
78
+ this.used = 0;
79
+ }
80
+
81
+ /**
82
+ * Intenta consumir `amount` tokens. Si excede el presupuesto, NO consume
83
+ * y retorna false. Útil cuando el caller debe decidir entre proceder o
84
+ * escalar.
85
+ *
86
+ * @param {number} amount - Cantidad a consumir. Debe ser número finito >= 0.
87
+ * @returns {boolean} true si se consumió, false si excedería el presupuesto.
88
+ * @throws {TypeError} si amount no es número finito >= 0.
89
+ */
90
+ tryUse(amount) {
91
+ if (!Number.isFinite(amount) || amount < 0) {
92
+ throw new TypeError(`TaskBudget.tryUse: amount debe ser número finito >= 0, recibido: ${amount}`);
93
+ }
94
+ if (this.used + amount > this.limit) {
95
+ return false;
96
+ }
97
+ this.used += amount;
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Alias semántico de `tryUse` para callers que prefieren `allocate`.
103
+ * Mantiene paridad con el API original (pm_agent.token_budget.allocate).
104
+ *
105
+ * @param {number} amount
106
+ * @returns {boolean}
107
+ */
108
+ allocate(amount) {
109
+ return this.tryUse(amount);
110
+ }
111
+
112
+ /**
113
+ * Consume `amount` tokens FORZOSAMENTE, incluso si excede el presupuesto.
114
+ * Útil cuando el consumo ya ocurrió y se quiere registrar para auditoría.
115
+ * Retorna true si quedó dentro del presupuesto, false si lo rebasó.
116
+ *
117
+ * @param {number} amount
118
+ * @returns {boolean} true si dentro del presupuesto, false si lo rebasó.
119
+ * @throws {TypeError} si amount no es número finito >= 0.
120
+ */
121
+ forceUse(amount) {
122
+ if (!Number.isFinite(amount) || amount < 0) {
123
+ throw new TypeError(`TaskBudget.forceUse: amount debe ser número finito >= 0, recibido: ${amount}`);
124
+ }
125
+ this.used += amount;
126
+ return this.used <= this.limit;
127
+ }
128
+
129
+ /**
130
+ * Tokens disponibles restantes. Puede ser negativo si se usó `forceUse`
131
+ * con consumo que rebasó el límite.
132
+ * @returns {number}
133
+ */
134
+ get remaining() {
135
+ return this.limit - this.used;
136
+ }
137
+
138
+ /**
139
+ * Fracción del presupuesto consumida en [0, ∞). 1.0 = exactamente lleno;
140
+ * >1.0 = rebasado. Útil para alertas "65% consumido".
141
+ * @returns {number}
142
+ */
143
+ get usedFraction() {
144
+ return this.limit === 0 ? 0 : this.used / this.limit;
145
+ }
146
+
147
+ /**
148
+ * true si el presupuesto fue rebasado (used > limit). Señal operacional
149
+ * de mala clasificación de complejidad o scope creep.
150
+ * @returns {boolean}
151
+ */
152
+ get isOver() {
153
+ return this.used > this.limit;
154
+ }
155
+
156
+ /**
157
+ * Resetea el contador de usados. NO cambia el límite ni la complejidad.
158
+ */
159
+ reset() {
160
+ this.used = 0;
161
+ }
162
+
163
+ /**
164
+ * Representación para logs / debugging.
165
+ * @returns {string}
166
+ */
167
+ toString() {
168
+ return `TaskBudget(complexity=${this.complexity}, limit=${this.limit}, used=${this.used})`;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Sugiere nivel de complejidad a partir de heurística simple sobre el
174
+ * descriptor de la tarea. NO es clasificación inteligente — es atajo para
175
+ * callers que no quieren razonar el nivel manualmente.
176
+ *
177
+ * Heurística:
178
+ * - menciona "typo", "rename", "format", "comment" → simple
179
+ * - menciona "feature", "refactor", "migration", "arquitectura" → complex
180
+ * - resto → medium
181
+ *
182
+ * @param {string} description - Descripción libre de la tarea.
183
+ * @returns {ComplexityLevel}
184
+ */
185
+ function suggestComplexity(description) {
186
+ if (typeof description !== 'string' || description.length === 0) {
187
+ return 'medium';
188
+ }
189
+ const lower = description.toLowerCase();
190
+
191
+ const simpleHints = ['typo', 'rename', 'format', 'comment', 'whitespace', 'comentario', 'formato'];
192
+ if (simpleHints.some((hint) => lower.includes(hint))) {
193
+ return 'simple';
194
+ }
195
+
196
+ const complexHints = [
197
+ 'refactor',
198
+ 'migration',
199
+ 'migración',
200
+ 'feature nueva',
201
+ 'cross-module',
202
+ 'cross-modulo',
203
+ 'arquitectura',
204
+ 'rewrite',
205
+ 'reescritura',
206
+ ];
207
+ if (complexHints.some((hint) => lower.includes(hint))) {
208
+ return 'complex';
209
+ }
210
+
211
+ return 'medium';
212
+ }
213
+
214
+ module.exports = {
215
+ TaskBudget,
216
+ DEFAULT_LIMITS,
217
+ suggestComplexity,
218
+ };