@saulwade/swl-ses 1.3.8 → 1.4.1

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 (148) hide show
  1. package/CLAUDE.md +15 -6
  2. package/README.md +15 -14
  3. package/agentes/nemesis-auditor-swl.md +161 -0
  4. package/bin/swl-mcp-server.js +187 -187
  5. package/bin/swl-webhook-server.js +198 -0
  6. package/comandos/swl/.evolved.json +22 -22
  7. package/comandos/swl/adoptar-proyecto.md +21 -1
  8. package/comandos/swl/claudemd.md +14 -1
  9. package/comandos/swl/contribuir.md +233 -233
  10. package/comandos/swl/exportar-vault.md +108 -0
  11. package/comandos/swl/nemesis.md +122 -0
  12. package/comandos/swl/nuevo-proyecto.md +24 -2
  13. package/comandos/swl/salud.md +34 -0
  14. package/comandos/swl/verificar.md +45 -0
  15. package/gateway/adapters/base.js +109 -0
  16. package/gateway/adapters/discord.js +167 -0
  17. package/gateway/adapters/email.js +221 -0
  18. package/gateway/adapters/slack.js +192 -0
  19. package/gateway/adapters/telegram.js +183 -0
  20. package/gateway/adapters/webhook.js +113 -0
  21. package/gateway/adapters/whatsapp.js +214 -0
  22. package/gateway/agent-executor.js +322 -0
  23. package/gateway/command-relay.js +271 -0
  24. package/gateway/cron/jobs.js +263 -0
  25. package/gateway/cron/scheduler.js +322 -0
  26. package/gateway/cron/store.js +335 -0
  27. package/gateway/index.js +320 -0
  28. package/gateway/lib/event-channel.js +191 -0
  29. package/gateway/session.js +131 -0
  30. package/gateway/webhook-server.js +324 -0
  31. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  32. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  33. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  34. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  35. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  36. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  37. package/habilidades/eval-framework/SKILL.md +212 -212
  38. package/habilidades/extractor-de-aprendizajes/SKILL.md +20 -10
  39. package/habilidades/feynman-auditor-swl/SKILL.md +123 -0
  40. package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -0
  41. package/habilidades/harness-claude-code/SKILL.md +299 -299
  42. package/habilidades/infra-github-actions/SKILL.md +166 -166
  43. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  44. package/habilidades/manejo-errores/.evolved.json +8 -8
  45. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  46. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  47. package/habilidades/nextjs-testing/SKILL.md +89 -5
  48. package/habilidades/node-experto/SKILL.md +37 -1
  49. package/habilidades/patrones-python/SKILL.md +229 -229
  50. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  51. package/habilidades/planear-fase/SKILL.md +319 -319
  52. package/habilidades/react-experto/SKILL.md +45 -4
  53. package/habilidades/release-semver/.evolved.json +8 -8
  54. package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -0
  55. package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -0
  56. package/habilidades/tdd-workflow/SKILL.md +36 -4
  57. package/habilidades/testing-python/SKILL.md +340 -340
  58. package/habilidades/web-fetcher-routing/SKILL.md +75 -0
  59. package/hooks/claudemd-bloat-detector.js +161 -161
  60. package/hooks/inyeccion-contexto.js +8 -3
  61. package/hooks/lib/agent-routing.js +107 -107
  62. package/hooks/lib/auto-consolidator.js +335 -335
  63. package/hooks/lib/error-classifier.js +308 -308
  64. package/hooks/lib/merkle-audit.js +96 -96
  65. package/hooks/lib/provenance-tracker.js +191 -191
  66. package/hooks/lib/rate-limit-ip.js +177 -0
  67. package/hooks/lib/rate-limit-tracker.js +253 -253
  68. package/hooks/lib/resource-quota.js +122 -122
  69. package/hooks/lib/retry-jitter.js +165 -165
  70. package/hooks/lib/security-net.js +201 -0
  71. package/hooks/lib/skill-auditor.js +588 -588
  72. package/hooks/lib/sync-status.js +228 -228
  73. package/hooks/lib/taint-tracker.js +107 -107
  74. package/hooks/lib/text-similarity.js +241 -241
  75. package/hooks/lib/toon-compressor.js +245 -245
  76. package/hooks/lib/webhook-dedup.js +184 -0
  77. package/hooks/lib/webhook-verify.js +123 -0
  78. package/hooks/proteccion-rutas.js +120 -15
  79. package/hooks/registro-turnos.js +209 -209
  80. package/hooks/sugerir-regenerar-inventario.js +170 -170
  81. package/hooks/validar-formato-post-subagente.js +140 -140
  82. package/hooks/validar-memoria-hook.js +218 -218
  83. package/instintos/prompt-appendices.yaml +57 -57
  84. package/manifiestos/agent-output-schemas.json +57 -57
  85. package/manifiestos/modulos.json +31 -0
  86. package/manifiestos/skills-lock.json +1114 -1093
  87. package/package.json +6 -4
  88. package/plantillas/auditor-veto-template.md +105 -105
  89. package/plantillas/github-workflows/README.md +47 -47
  90. package/plantillas/github-workflows/release-please.yml +44 -44
  91. package/plantillas/github-workflows/swl-ci.yml +107 -107
  92. package/plantillas/github-workflows/swl-security.yml +51 -51
  93. package/plugin.json +2 -2
  94. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  95. package/reglas/arreglar-al-detectar.md +147 -147
  96. package/reglas/fragmentos-compartidos.md +152 -152
  97. package/reglas/harness-claude-code.md +213 -213
  98. package/reglas/usar-context7.md +226 -226
  99. package/reglas/usar-sistema-swl.md +251 -0
  100. package/schemas/diary-entry.schema.json +80 -80
  101. package/scripts/audit-tools/audit-history.js +330 -0
  102. package/scripts/audit-tools/bundle-tracker.js +290 -0
  103. package/scripts/audit-tools/canary-monitor.js +352 -0
  104. package/scripts/audit-tools/code-profiler.js +605 -0
  105. package/scripts/audit-tools/dep-doctor.js +320 -0
  106. package/scripts/audit-tools/env-validator.js +206 -0
  107. package/scripts/audit-tools/lib/fs-walk.js +48 -0
  108. package/scripts/audit-tools/lib/output.js +23 -0
  109. package/scripts/audit-tools/migration-checker.js +392 -0
  110. package/scripts/audit-tools/pentest-scanner.js +1436 -0
  111. package/scripts/benchmark-memoria.js +167 -167
  112. package/scripts/comandos/skills.js +251 -2
  113. package/scripts/configurar-branch-protection.js +418 -418
  114. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  115. package/scripts/field-report.js +199 -199
  116. package/scripts/generar-checklists-consolidados.js +273 -273
  117. package/scripts/generar-inventario.js +420 -420
  118. package/scripts/generar-matriz-lenguajes.js +271 -271
  119. package/scripts/lib/artefactos-python.js +43 -43
  120. package/scripts/lib/benchmark-metrics.js +160 -160
  121. package/scripts/lib/budget-enforcer.js +252 -252
  122. package/scripts/lib/configurar-ci.js +380 -380
  123. package/scripts/lib/contadores-inventario.js +217 -217
  124. package/scripts/lib/detectar-stack-detallado.js +307 -307
  125. package/scripts/lib/diary-entry.js +234 -234
  126. package/scripts/lib/eval-metrics-store.js +218 -218
  127. package/scripts/lib/eval-quality.js +171 -171
  128. package/scripts/lib/eval-schemas.js +144 -144
  129. package/scripts/lib/eval-self-correct.js +106 -106
  130. package/scripts/lib/eval-validator.js +185 -185
  131. package/scripts/lib/jaccard-similarity.js +98 -98
  132. package/scripts/lib/longmemeval-runner.js +125 -125
  133. package/scripts/lib/npm-version.js +261 -261
  134. package/scripts/lib/paquetes-conocidos.js +50 -50
  135. package/scripts/lib/prompt-builder.js +264 -264
  136. package/scripts/lib/rrf-fusion.js +175 -175
  137. package/scripts/lib/scoring-instintos.js +277 -277
  138. package/scripts/lib/semantic-search.js +252 -252
  139. package/scripts/limpiar-artefactos-python.js +131 -131
  140. package/scripts/mcp-server/README.md +128 -128
  141. package/scripts/mcp-server/handlers.js +206 -206
  142. package/scripts/migrar-csv-a-array.js +168 -168
  143. package/scripts/migrar-fase-dominio.js +201 -201
  144. package/scripts/publicar.js +511 -511
  145. package/scripts/run-eval.js +141 -141
  146. package/scripts/validar-manifest.js +195 -195
  147. package/scripts/validar-userland-vacio.js +110 -110
  148. 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 };