@saulwade/swl-ses 1.3.7 → 1.4.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 (129) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.md +1 -1
  3. package/bin/swl-mcp-server.js +187 -187
  4. package/bin/swl-webhook-server.js +198 -0
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/adoptar-proyecto.md +21 -1
  7. package/comandos/swl/claudemd.md +14 -1
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/exportar-vault.md +207 -7
  10. package/comandos/swl/nuevo-proyecto.md +24 -2
  11. package/gateway/adapters/base.js +109 -0
  12. package/gateway/adapters/discord.js +167 -0
  13. package/gateway/adapters/email.js +221 -0
  14. package/gateway/adapters/slack.js +192 -0
  15. package/gateway/adapters/telegram.js +183 -0
  16. package/gateway/adapters/webhook.js +113 -0
  17. package/gateway/adapters/whatsapp.js +214 -0
  18. package/gateway/agent-executor.js +322 -0
  19. package/gateway/command-relay.js +271 -0
  20. package/gateway/cron/jobs.js +263 -0
  21. package/gateway/cron/scheduler.js +322 -0
  22. package/gateway/cron/store.js +335 -0
  23. package/gateway/index.js +320 -0
  24. package/gateway/lib/event-channel.js +191 -0
  25. package/gateway/session.js +131 -0
  26. package/gateway/webhook-server.js +324 -0
  27. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  28. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  29. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  30. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  31. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  32. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  33. package/habilidades/eval-framework/SKILL.md +212 -212
  34. package/habilidades/extractor-de-aprendizajes/SKILL.md +24 -10
  35. package/habilidades/harness-claude-code/SKILL.md +299 -299
  36. package/habilidades/infra-github-actions/SKILL.md +166 -166
  37. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  38. package/habilidades/manejo-errores/.evolved.json +8 -8
  39. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  40. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  41. package/habilidades/nextjs-testing/SKILL.md +89 -5
  42. package/habilidades/node-experto/SKILL.md +37 -1
  43. package/habilidades/patrones-python/SKILL.md +229 -229
  44. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  45. package/habilidades/planear-fase/SKILL.md +319 -319
  46. package/habilidades/react-experto/SKILL.md +45 -4
  47. package/habilidades/release-semver/.evolved.json +8 -8
  48. package/habilidades/swl-claudemd/SKILL.md +15 -1
  49. package/habilidades/tdd-workflow/SKILL.md +36 -4
  50. package/habilidades/testing-python/SKILL.md +340 -340
  51. package/hooks/claudemd-bloat-detector.js +161 -161
  52. package/hooks/inyeccion-contexto.js +8 -3
  53. package/hooks/lib/agent-routing.js +107 -107
  54. package/hooks/lib/auto-consolidator.js +335 -335
  55. package/hooks/lib/error-classifier.js +308 -308
  56. package/hooks/lib/merkle-audit.js +96 -96
  57. package/hooks/lib/provenance-tracker.js +191 -191
  58. package/hooks/lib/rate-limit-ip.js +177 -0
  59. package/hooks/lib/rate-limit-tracker.js +253 -253
  60. package/hooks/lib/resource-quota.js +122 -122
  61. package/hooks/lib/retry-jitter.js +165 -165
  62. package/hooks/lib/skill-auditor.js +588 -588
  63. package/hooks/lib/sync-status.js +228 -228
  64. package/hooks/lib/taint-tracker.js +107 -107
  65. package/hooks/lib/text-similarity.js +241 -241
  66. package/hooks/lib/toon-compressor.js +245 -245
  67. package/hooks/lib/webhook-dedup.js +184 -0
  68. package/hooks/lib/webhook-verify.js +123 -0
  69. package/hooks/proteccion-rutas.js +120 -15
  70. package/hooks/registro-turnos.js +209 -209
  71. package/hooks/sugerir-regenerar-inventario.js +170 -170
  72. package/hooks/validar-formato-post-subagente.js +140 -140
  73. package/hooks/validar-memoria-hook.js +218 -218
  74. package/instintos/prompt-appendices.yaml +57 -57
  75. package/manifiestos/agent-output-schemas.json +57 -57
  76. package/manifiestos/modulos.json +1 -0
  77. package/manifiestos/skills-lock.json +37 -37
  78. package/package.json +5 -3
  79. package/plantillas/auditor-veto-template.md +105 -105
  80. package/plantillas/github-workflows/README.md +47 -47
  81. package/plantillas/github-workflows/release-please.yml +44 -44
  82. package/plantillas/github-workflows/swl-ci.yml +107 -107
  83. package/plantillas/github-workflows/swl-security.yml +51 -51
  84. package/plugin.json +1 -1
  85. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  86. package/reglas/arreglar-al-detectar.md +147 -147
  87. package/reglas/fragmentos-compartidos.md +152 -152
  88. package/reglas/harness-claude-code.md +213 -213
  89. package/reglas/usar-context7.md +226 -226
  90. package/reglas/usar-sistema-swl.md +251 -0
  91. package/schemas/diary-entry.schema.json +80 -80
  92. package/scripts/benchmark-memoria.js +167 -167
  93. package/scripts/comandos/skills.js +251 -2
  94. package/scripts/configurar-branch-protection.js +418 -418
  95. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  96. package/scripts/field-report.js +199 -199
  97. package/scripts/generar-checklists-consolidados.js +273 -273
  98. package/scripts/generar-inventario.js +420 -420
  99. package/scripts/generar-matriz-lenguajes.js +271 -271
  100. package/scripts/lib/artefactos-python.js +43 -43
  101. package/scripts/lib/benchmark-metrics.js +160 -160
  102. package/scripts/lib/budget-enforcer.js +252 -252
  103. package/scripts/lib/configurar-ci.js +380 -380
  104. package/scripts/lib/contadores-inventario.js +217 -217
  105. package/scripts/lib/detectar-stack-detallado.js +307 -307
  106. package/scripts/lib/diary-entry.js +234 -234
  107. package/scripts/lib/eval-metrics-store.js +218 -218
  108. package/scripts/lib/eval-quality.js +171 -171
  109. package/scripts/lib/eval-schemas.js +144 -144
  110. package/scripts/lib/eval-self-correct.js +106 -106
  111. package/scripts/lib/eval-validator.js +185 -185
  112. package/scripts/lib/jaccard-similarity.js +98 -98
  113. package/scripts/lib/longmemeval-runner.js +125 -125
  114. package/scripts/lib/npm-version.js +261 -261
  115. package/scripts/lib/paquetes-conocidos.js +50 -50
  116. package/scripts/lib/prompt-builder.js +264 -264
  117. package/scripts/lib/rrf-fusion.js +175 -175
  118. package/scripts/lib/scoring-instintos.js +277 -277
  119. package/scripts/lib/semantic-search.js +252 -252
  120. package/scripts/limpiar-artefactos-python.js +131 -131
  121. package/scripts/mcp-server/README.md +128 -128
  122. package/scripts/mcp-server/handlers.js +206 -206
  123. package/scripts/migrar-csv-a-array.js +168 -168
  124. package/scripts/migrar-fase-dominio.js +201 -201
  125. package/scripts/publicar.js +511 -511
  126. package/scripts/run-eval.js +141 -141
  127. package/scripts/validar-manifest.js +195 -195
  128. package/scripts/validar-userland-vacio.js +110 -110
  129. package/scripts/verificar-release.js +110 -0
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Gateway Runner — Orquestador multi-plataforma para SWL-SES.
6
+ *
7
+ * Conecta el sistema SWL con plataformas de mensajería externas
8
+ * (Telegram, Discord, Webhook) para notificaciones bidireccionales.
9
+ *
10
+ * Arquitectura:
11
+ * Claude Code (SWL) ←→ .planning/comms/ ←→ Gateway ←→ Plataformas
12
+ *
13
+ * Flujos:
14
+ * SWL → Plataforma: Polling de .planning/comms/ cada N segundos
15
+ * Plataforma → SWL: Escribir comandos en .planning/comms/
16
+ *
17
+ * Inspirado en Hermes Agent (gateway/run.py).
18
+ *
19
+ * Uso:
20
+ * node gateway/index.js [baseDir]
21
+ * node gateway/index.js --config manifiestos/gateway-config.json
22
+ *
23
+ * @module gateway/index
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Constantes
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const DEFAULT_POLL_INTERVAL = 2000; // 2 segundos
34
+ const COMMS_DIR = '.planning/comms';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Carga de configuración
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Carga la configuración del gateway desde archivo o defaults.
42
+ * @param {string} baseDir
43
+ * @returns {object}
44
+ */
45
+ function loadConfig(baseDir) {
46
+ const configPath = path.join(baseDir, 'manifiestos', 'gateway-config.json');
47
+ const defaults = {
48
+ enabled: false,
49
+ pollIntervalMs: DEFAULT_POLL_INTERVAL,
50
+ adapters: {
51
+ telegram: { enabled: false },
52
+ discord: { enabled: false },
53
+ webhook: { enabled: false },
54
+ },
55
+ notifications: {
56
+ onSessionComplete: true,
57
+ onCheckpoint: true,
58
+ onError: true,
59
+ onRelease: true,
60
+ onBuildFail: true,
61
+ },
62
+ };
63
+
64
+ try {
65
+ if (fs.existsSync(configPath)) {
66
+ const loaded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
67
+ return { ...defaults, ...loaded, adapters: { ...defaults.adapters, ...loaded.adapters } };
68
+ }
69
+ } catch (_) {}
70
+
71
+ return defaults;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Gateway Runner
76
+ // ---------------------------------------------------------------------------
77
+
78
+ class GatewayRunner {
79
+ constructor(baseDir) {
80
+ this.baseDir = baseDir;
81
+ this.config = loadConfig(baseDir);
82
+ this.adapters = [];
83
+ this._pollInterval = null;
84
+ this._running = false;
85
+
86
+ // CommandRelay opt-in — activo si config.relay.enabled === true.
87
+ // Permite recibir comandos bidireccionales desde canales externos
88
+ // (ej. Telegram) hacia Claude Code con validaciones de seguridad.
89
+ try {
90
+ const CommandRelay = require('./command-relay');
91
+ this.relay = new CommandRelay(baseDir, this.config);
92
+ } catch (_) {
93
+ this.relay = null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Inicializa y arranca todos los adaptadores habilitados.
99
+ */
100
+ async start() {
101
+ if (!this.config.enabled) {
102
+ console.log('[gateway] Gateway deshabilitado en configuración. Activar con enabled: true.');
103
+ return;
104
+ }
105
+
106
+ console.log('[gateway] Iniciando gateway...');
107
+
108
+ // Cargar adaptadores habilitados
109
+ const adapterConfigs = this.config.adapters;
110
+
111
+ if (adapterConfigs.telegram?.enabled) {
112
+ const TelegramAdapter = require('./adapters/telegram');
113
+ const adapter = new TelegramAdapter(adapterConfigs.telegram);
114
+ adapter.onMessage((msg) => this._handleIncoming(msg));
115
+ await adapter.start();
116
+ if (adapter.running) this.adapters.push(adapter);
117
+ }
118
+
119
+ if (adapterConfigs.discord?.enabled) {
120
+ const DiscordAdapter = require('./adapters/discord');
121
+ const adapter = new DiscordAdapter(adapterConfigs.discord);
122
+ adapter.onMessage((msg) => this._handleIncoming(msg));
123
+ await adapter.start();
124
+ if (adapter.running) this.adapters.push(adapter);
125
+ }
126
+
127
+ if (adapterConfigs.webhook?.enabled) {
128
+ const WebhookAdapter = require('./adapters/webhook');
129
+ const adapter = new WebhookAdapter(adapterConfigs.webhook);
130
+ await adapter.start();
131
+ if (adapter.running) this.adapters.push(adapter);
132
+ }
133
+
134
+ if (adapterConfigs.whatsapp?.enabled) {
135
+ const WhatsAppAdapter = require('./adapters/whatsapp');
136
+ const adapter = new WhatsAppAdapter(adapterConfigs.whatsapp);
137
+ adapter.onMessage((msg) => this._handleIncoming(msg));
138
+ await adapter.start();
139
+ if (adapter.running) this.adapters.push(adapter);
140
+ }
141
+
142
+ if (adapterConfigs.slack?.enabled) {
143
+ const SlackAdapter = require('./adapters/slack');
144
+ const adapter = new SlackAdapter(adapterConfigs.slack);
145
+ adapter.onMessage((msg) => this._handleIncoming(msg));
146
+ await adapter.start();
147
+ if (adapter.running) this.adapters.push(adapter);
148
+ }
149
+
150
+ if (adapterConfigs.email?.enabled) {
151
+ const EmailAdapter = require('./adapters/email');
152
+ const adapter = new EmailAdapter(adapterConfigs.email);
153
+ await adapter.start();
154
+ if (adapter.running) this.adapters.push(adapter);
155
+ }
156
+
157
+ if (this.adapters.length === 0) {
158
+ console.log('[gateway] Ningún adaptador habilitado o disponible.');
159
+ return;
160
+ }
161
+
162
+ console.log(`[gateway] ${this.adapters.length} adaptador(es) activo(s): ${this.adapters.map(a => a.name).join(', ')}`);
163
+
164
+ // Iniciar polling de agent-comms
165
+ this._running = true;
166
+ this._pollInterval = setInterval(
167
+ () => this._pollComms(),
168
+ this.config.pollIntervalMs || DEFAULT_POLL_INTERVAL,
169
+ );
170
+
171
+ console.log(`[gateway] Polling activo (cada ${this.config.pollIntervalMs || DEFAULT_POLL_INTERVAL}ms).`);
172
+ }
173
+
174
+ /**
175
+ * Detiene todos los adaptadores y el polling.
176
+ */
177
+ async stop() {
178
+ this._running = false;
179
+ if (this._pollInterval) {
180
+ clearInterval(this._pollInterval);
181
+ this._pollInterval = null;
182
+ }
183
+
184
+ for (const adapter of this.adapters) {
185
+ await adapter.stop();
186
+ }
187
+
188
+ this.adapters = [];
189
+ console.log('[gateway] Detenido.');
190
+ }
191
+
192
+ /**
193
+ * Poll de .planning/comms/ buscando mensajes tipo gateway_notification.
194
+ * @private
195
+ */
196
+ _pollComms() {
197
+ const commsDir = path.join(this.baseDir, COMMS_DIR);
198
+ if (!fs.existsSync(commsDir)) return;
199
+
200
+ let files;
201
+ try {
202
+ files = fs.readdirSync(commsDir)
203
+ .filter(f => f.startsWith('msg-') && f.endsWith('.json'));
204
+ } catch (_) {
205
+ return;
206
+ }
207
+
208
+ for (const file of files) {
209
+ const filePath = path.join(commsDir, file);
210
+ let msg;
211
+ try {
212
+ msg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
213
+ } catch (_) {
214
+ continue;
215
+ }
216
+
217
+ // Solo procesar mensajes de gateway pendientes
218
+ if (msg.status !== 'pending') continue;
219
+ if (msg.type !== 'gateway_notification' && msg.type !== 'gateway_command_response') continue;
220
+
221
+ // Despachar a adaptador(es) correspondientes
222
+ const target = msg.to || 'all';
223
+ this._dispatch(target, msg);
224
+
225
+ // Marcar como procesado
226
+ try {
227
+ msg.status = 'processed';
228
+ msg.processedAt = new Date().toISOString();
229
+ fs.writeFileSync(filePath, JSON.stringify(msg, null, 2), 'utf8');
230
+ } catch (_) {}
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Despacha un mensaje a el/los adaptador(es) objetivo.
236
+ * @private
237
+ */
238
+ _dispatch(target, message) {
239
+ for (const adapter of this.adapters) {
240
+ if (target === 'all' || target === adapter.name || target === 'broadcast') {
241
+ adapter.send(message).catch(err => {
242
+ console.error(`[gateway] Error despachando a ${adapter.name}: ${err.message}`);
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Maneja un mensaje entrante de una plataforma.
250
+ * Escribe en .planning/comms/ para que Claude Code lo procese.
251
+ * Adicionalmente, si el CommandRelay está habilitado para la plataforma
252
+ * y el usuario, encola en .planning/inbox/ para consumo vía /swl:inbox.
253
+ * @private
254
+ */
255
+ _handleIncoming(message) {
256
+ const commsDir = path.join(this.baseDir, COMMS_DIR);
257
+ if (!fs.existsSync(commsDir)) {
258
+ fs.mkdirSync(commsDir, { recursive: true });
259
+ }
260
+
261
+ const id = `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
262
+ const msg = {
263
+ id,
264
+ type: 'gateway_command',
265
+ from: `${message.platform}:${message.userId || 'unknown'}`,
266
+ to: 'swl-system',
267
+ payload: {
268
+ platform: message.platform,
269
+ chatId: message.chatId,
270
+ userId: message.userId,
271
+ userName: message.userName,
272
+ text: message.text,
273
+ command: message.command,
274
+ args: message.args,
275
+ },
276
+ timestamp: new Date().toISOString(),
277
+ status: 'pending',
278
+ };
279
+
280
+ const filePath = path.join(commsDir, `${id}.json`);
281
+ try {
282
+ fs.writeFileSync(filePath, JSON.stringify(msg, null, 2), 'utf8');
283
+ } catch (_) {}
284
+
285
+ // Relay bidireccional: encolar también en .planning/inbox/ si aplica.
286
+ if (this.relay && this.relay.habilitado()) {
287
+ try {
288
+ this.relay.recibirComando(message);
289
+ } catch (_) { /* silencioso */ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Entrypoint CLI
296
+ // ---------------------------------------------------------------------------
297
+
298
+ if (require.main === module) {
299
+ const baseDir = process.argv[2] || process.cwd();
300
+
301
+ const gateway = new GatewayRunner(baseDir);
302
+
303
+ const cleanup = async () => {
304
+ await gateway.stop();
305
+ process.exit(0);
306
+ };
307
+ process.on('SIGTERM', cleanup);
308
+ process.on('SIGINT', cleanup);
309
+
310
+ gateway.start().catch(err => {
311
+ console.error(`[gateway] Error fatal: ${err.message}`);
312
+ process.exit(1);
313
+ });
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Exports
318
+ // ---------------------------------------------------------------------------
319
+
320
+ module.exports = { GatewayRunner, loadConfig };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * event-channel.js
5
+ *
6
+ * Pub/sub event channel para CommandRelay y otros componentes del gateway.
7
+ *
8
+ * Patrón adaptado de `temp/obsidian-agent-client-master/src/acp/acp-handler.ts`
9
+ * (Set<listeners> con callback unsubscribe). Diferencias:
10
+ * - Zero-deps Node.js (sin RxJS, sin EventEmitter de node — Set nativo).
11
+ * - Backward compat: si no hay listeners, comportamiento idéntico al actual.
12
+ * - Tipos de evento explícitos para evitar typos.
13
+ *
14
+ * Casos de uso en SWL:
15
+ * - CommandRelay emite eventos al recibir/aceptar/rechazar/procesar comandos.
16
+ * - Múltiples adaptadores (Telegram, Discord) pueden suscribirse simultáneamente.
17
+ * - El consumidor /swl:inbox puede mostrar progreso sin polling.
18
+ *
19
+ * Uso:
20
+ * const channel = new EventChannel();
21
+ * const off = channel.on('cmd:queued', (event) => console.log(event));
22
+ * channel.emit({ type: 'cmd:queued', commandId: 'cmd-abc', userId: '123' });
23
+ * off(); // unsubscribe
24
+ *
25
+ * @module gateway/lib/event-channel
26
+ */
27
+
28
+ // ── tipos de evento ──────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Tipos válidos de evento. Se usan strings para serializar fácilmente
32
+ * a JSONL si se requiere persistencia (no obligatorio).
33
+ */
34
+ const EVENTS = Object.freeze({
35
+ // Lifecycle de comandos en CommandRelay
36
+ CMD_RECEIVED: 'cmd:received', // mensaje llegó al relay
37
+ CMD_REJECTED: 'cmd:rejected', // rechazado por validación (auth/rate/dedup)
38
+ CMD_QUEUED: 'cmd:queued', // encolado en .planning/inbox/
39
+ CMD_PROCESSED: 'cmd:processed', // marcado como procesado por consumidor
40
+
41
+ // Lifecycle de notificaciones
42
+ NOTIFICATION_SENT: 'notification:sent',
43
+ NOTIFICATION_FAILED: 'notification:failed',
44
+ });
45
+
46
+ // ── implementación ────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Canal de eventos pub/sub con escucha por tipo o wildcard.
50
+ *
51
+ * Mantiene dos estructuras:
52
+ * - listenersByType: Map<eventType, Set<callback>>
53
+ * - wildcardListeners: Set<callback> (escuchan TODOS los eventos)
54
+ *
55
+ * Una excepción en un listener NO interrumpe la propagación a los demás —
56
+ * patrón de aislamiento del mismo origen (acp-handler.ts:47-50).
57
+ */
58
+ class EventChannel {
59
+ constructor() {
60
+ this.listenersByType = new Map();
61
+ this.wildcardListeners = new Set();
62
+ this.errorHandler = null; // opcional: callback para errores en listeners
63
+ }
64
+
65
+ /**
66
+ * Suscribe un listener a un tipo de evento (o '*' para todos).
67
+ *
68
+ * @param {string} eventType - tipo de evento o '*'
69
+ * @param {function} callback - recibe el objeto event
70
+ * @returns {function} función de unsubscribe (idempotente)
71
+ */
72
+ on(eventType, callback) {
73
+ if (typeof callback !== 'function') {
74
+ throw new TypeError('callback debe ser función');
75
+ }
76
+ if (typeof eventType !== 'string' || !eventType) {
77
+ throw new TypeError('eventType debe ser string no vacío');
78
+ }
79
+
80
+ if (eventType === '*') {
81
+ this.wildcardListeners.add(callback);
82
+ return () => this.wildcardListeners.delete(callback);
83
+ }
84
+
85
+ if (!this.listenersByType.has(eventType)) {
86
+ this.listenersByType.set(eventType, new Set());
87
+ }
88
+ const set = this.listenersByType.get(eventType);
89
+ set.add(callback);
90
+ return () => set.delete(callback);
91
+ }
92
+
93
+ /**
94
+ * Suscribe un listener que se invoca UNA sola vez y luego se desuscribe.
95
+ */
96
+ once(eventType, callback) {
97
+ const off = this.on(eventType, (event) => {
98
+ off();
99
+ callback(event);
100
+ });
101
+ return off;
102
+ }
103
+
104
+ /**
105
+ * Emite un evento a todos los listeners suscriptos al tipo + wildcards.
106
+ * El evento debe tener al menos `type`. Se enriquece con `ts` (ISO).
107
+ *
108
+ * @param {object} event - objeto con `type` y datos arbitrarios
109
+ * @returns {number} cantidad de listeners notificados
110
+ */
111
+ emit(event) {
112
+ if (!event || typeof event !== 'object') {
113
+ throw new TypeError('event debe ser objeto');
114
+ }
115
+ if (typeof event.type !== 'string' || !event.type) {
116
+ throw new TypeError('event.type es obligatorio');
117
+ }
118
+
119
+ const enriched = { ts: new Date().toISOString(), ...event };
120
+ let notified = 0;
121
+
122
+ // Listeners específicos del tipo
123
+ const typedSet = this.listenersByType.get(enriched.type);
124
+ if (typedSet) {
125
+ for (const cb of typedSet) {
126
+ notified++;
127
+ this._safeInvoke(cb, enriched);
128
+ }
129
+ }
130
+
131
+ // Listeners wildcard
132
+ for (const cb of this.wildcardListeners) {
133
+ notified++;
134
+ this._safeInvoke(cb, enriched);
135
+ }
136
+
137
+ return notified;
138
+ }
139
+
140
+ /**
141
+ * Cuenta de listeners para un tipo (o total si no se pasa tipo).
142
+ */
143
+ listenerCount(eventType) {
144
+ if (eventType == null) {
145
+ let total = this.wildcardListeners.size;
146
+ for (const set of this.listenersByType.values()) total += set.size;
147
+ return total;
148
+ }
149
+ if (eventType === '*') return this.wildcardListeners.size;
150
+ const set = this.listenersByType.get(eventType);
151
+ return (set ? set.size : 0) + this.wildcardListeners.size;
152
+ }
153
+
154
+ /**
155
+ * Limpia todos los listeners. Útil para tests.
156
+ */
157
+ clear() {
158
+ this.listenersByType.clear();
159
+ this.wildcardListeners.clear();
160
+ }
161
+
162
+ /**
163
+ * Asigna un handler para errores en listeners.
164
+ * Si no se asigna, los errores se silencian (no rompen la propagación).
165
+ */
166
+ onError(handler) {
167
+ if (typeof handler !== 'function') {
168
+ throw new TypeError('handler debe ser función');
169
+ }
170
+ this.errorHandler = handler;
171
+ }
172
+
173
+ // ── internos ────────────────────────────────────────────────────────────────
174
+
175
+ _safeInvoke(cb, event) {
176
+ try {
177
+ cb(event);
178
+ } catch (err) {
179
+ if (this.errorHandler) {
180
+ try { this.errorHandler(err, event); } catch (_) { /* silencioso */ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // ── exports ───────────────────────────────────────────────────────────────────
187
+
188
+ module.exports = {
189
+ EventChannel,
190
+ EVENTS,
191
+ };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gateway Session — Gestión de sesiones por plataforma.
5
+ *
6
+ * Mantiene un mapeo entre usuarios de plataformas externas y sesiones SWL.
7
+ * Permite que un usuario de Telegram tenga una "sesión" persistente con el gateway.
8
+ *
9
+ * Persistencia en .planning/gateway-sessions.json.
10
+ *
11
+ * @module gateway/session
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const { atomicWriteJSON } = require('../hooks/lib/atomic-write');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constantes
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const SESSIONS_FILE = '.planning/gateway-sessions.json';
24
+ const INACTIVITY_MS = 30 * 60 * 1000; // 30 minutos
25
+ const MAX_SESSIONS = 100;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // API
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Obtiene o crea una sesión para un usuario de plataforma.
33
+ *
34
+ * @param {string} baseDir
35
+ * @param {string} platformUserId - Identificador único: "telegram:12345"
36
+ * @returns {object} Sesión con lastActivity actualizada.
37
+ */
38
+ function getOrCreateSession(baseDir, platformUserId) {
39
+ const sessions = _loadSessions(baseDir);
40
+ const now = Date.now();
41
+
42
+ let session = sessions[platformUserId];
43
+
44
+ if (session) {
45
+ // Verificar timeout de inactividad
46
+ if (now - session.lastActivity > INACTIVITY_MS) {
47
+ // Sesión expirada — crear nueva
48
+ session = _newSession(platformUserId);
49
+ } else {
50
+ session.lastActivity = now;
51
+ }
52
+ } else {
53
+ session = _newSession(platformUserId);
54
+ }
55
+
56
+ sessions[platformUserId] = session;
57
+
58
+ // Limitar número total de sesiones (FIFO)
59
+ const keys = Object.keys(sessions);
60
+ if (keys.length > MAX_SESSIONS) {
61
+ // Eliminar las más antiguas
62
+ const sorted = keys.sort((a, b) => sessions[a].lastActivity - sessions[b].lastActivity);
63
+ for (let i = 0; i < keys.length - MAX_SESSIONS; i++) {
64
+ delete sessions[sorted[i]];
65
+ }
66
+ }
67
+
68
+ _saveSessions(baseDir, sessions);
69
+ return session;
70
+ }
71
+
72
+ /**
73
+ * Lista todas las sesiones activas (no expiradas).
74
+ *
75
+ * @param {string} baseDir
76
+ * @returns {object[]}
77
+ */
78
+ function listActiveSessions(baseDir) {
79
+ const sessions = _loadSessions(baseDir);
80
+ const now = Date.now();
81
+
82
+ return Object.entries(sessions)
83
+ .filter(([, s]) => now - s.lastActivity <= INACTIVITY_MS)
84
+ .map(([userId, s]) => ({ userId, ...s }));
85
+ }
86
+
87
+ /**
88
+ * Invalida una sesión específica.
89
+ *
90
+ * @param {string} baseDir
91
+ * @param {string} platformUserId
92
+ */
93
+ function invalidateSession(baseDir, platformUserId) {
94
+ const sessions = _loadSessions(baseDir);
95
+ delete sessions[platformUserId];
96
+ _saveSessions(baseDir, sessions);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Helpers internos
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function _newSession(platformUserId) {
104
+ const [platform, userId] = platformUserId.split(':');
105
+ return {
106
+ id: `gw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
107
+ platform,
108
+ userId,
109
+ createdAt: Date.now(),
110
+ lastActivity: Date.now(),
111
+ context: {},
112
+ };
113
+ }
114
+
115
+ function _loadSessions(baseDir) {
116
+ const p = path.join(baseDir, SESSIONS_FILE);
117
+ try {
118
+ if (!fs.existsSync(p)) return {};
119
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
120
+ } catch (_) {
121
+ return {};
122
+ }
123
+ }
124
+
125
+ function _saveSessions(baseDir, sessions) {
126
+ const dir = path.dirname(path.join(baseDir, SESSIONS_FILE));
127
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
128
+ atomicWriteJSON(path.join(baseDir, SESSIONS_FILE), sessions);
129
+ }
130
+
131
+ module.exports = { getOrCreateSession, listActiveSessions, invalidateSession };