@saulwade/swl-ses 1.3.8 → 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 (128) 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 +108 -0
  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 +20 -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/tdd-workflow/SKILL.md +36 -4
  49. package/habilidades/testing-python/SKILL.md +340 -340
  50. package/hooks/claudemd-bloat-detector.js +161 -161
  51. package/hooks/inyeccion-contexto.js +8 -3
  52. package/hooks/lib/agent-routing.js +107 -107
  53. package/hooks/lib/auto-consolidator.js +335 -335
  54. package/hooks/lib/error-classifier.js +308 -308
  55. package/hooks/lib/merkle-audit.js +96 -96
  56. package/hooks/lib/provenance-tracker.js +191 -191
  57. package/hooks/lib/rate-limit-ip.js +177 -0
  58. package/hooks/lib/rate-limit-tracker.js +253 -253
  59. package/hooks/lib/resource-quota.js +122 -122
  60. package/hooks/lib/retry-jitter.js +165 -165
  61. package/hooks/lib/skill-auditor.js +588 -588
  62. package/hooks/lib/sync-status.js +228 -228
  63. package/hooks/lib/taint-tracker.js +107 -107
  64. package/hooks/lib/text-similarity.js +241 -241
  65. package/hooks/lib/toon-compressor.js +245 -245
  66. package/hooks/lib/webhook-dedup.js +184 -0
  67. package/hooks/lib/webhook-verify.js +123 -0
  68. package/hooks/proteccion-rutas.js +120 -15
  69. package/hooks/registro-turnos.js +209 -209
  70. package/hooks/sugerir-regenerar-inventario.js +170 -170
  71. package/hooks/validar-formato-post-subagente.js +140 -140
  72. package/hooks/validar-memoria-hook.js +218 -218
  73. package/instintos/prompt-appendices.yaml +57 -57
  74. package/manifiestos/agent-output-schemas.json +57 -57
  75. package/manifiestos/modulos.json +1 -0
  76. package/manifiestos/skills-lock.json +34 -34
  77. package/package.json +5 -3
  78. package/plantillas/auditor-veto-template.md +105 -105
  79. package/plantillas/github-workflows/README.md +47 -47
  80. package/plantillas/github-workflows/release-please.yml +44 -44
  81. package/plantillas/github-workflows/swl-ci.yml +107 -107
  82. package/plantillas/github-workflows/swl-security.yml +51 -51
  83. package/plugin.json +1 -1
  84. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  85. package/reglas/arreglar-al-detectar.md +147 -147
  86. package/reglas/fragmentos-compartidos.md +152 -152
  87. package/reglas/harness-claude-code.md +213 -213
  88. package/reglas/usar-context7.md +226 -226
  89. package/reglas/usar-sistema-swl.md +251 -0
  90. package/schemas/diary-entry.schema.json +80 -80
  91. package/scripts/benchmark-memoria.js +167 -167
  92. package/scripts/comandos/skills.js +251 -2
  93. package/scripts/configurar-branch-protection.js +418 -418
  94. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  95. package/scripts/field-report.js +199 -199
  96. package/scripts/generar-checklists-consolidados.js +273 -273
  97. package/scripts/generar-inventario.js +420 -420
  98. package/scripts/generar-matriz-lenguajes.js +271 -271
  99. package/scripts/lib/artefactos-python.js +43 -43
  100. package/scripts/lib/benchmark-metrics.js +160 -160
  101. package/scripts/lib/budget-enforcer.js +252 -252
  102. package/scripts/lib/configurar-ci.js +380 -380
  103. package/scripts/lib/contadores-inventario.js +217 -217
  104. package/scripts/lib/detectar-stack-detallado.js +307 -307
  105. package/scripts/lib/diary-entry.js +234 -234
  106. package/scripts/lib/eval-metrics-store.js +218 -218
  107. package/scripts/lib/eval-quality.js +171 -171
  108. package/scripts/lib/eval-schemas.js +144 -144
  109. package/scripts/lib/eval-self-correct.js +106 -106
  110. package/scripts/lib/eval-validator.js +185 -185
  111. package/scripts/lib/jaccard-similarity.js +98 -98
  112. package/scripts/lib/longmemeval-runner.js +125 -125
  113. package/scripts/lib/npm-version.js +261 -261
  114. package/scripts/lib/paquetes-conocidos.js +50 -50
  115. package/scripts/lib/prompt-builder.js +264 -264
  116. package/scripts/lib/rrf-fusion.js +175 -175
  117. package/scripts/lib/scoring-instintos.js +277 -277
  118. package/scripts/lib/semantic-search.js +252 -252
  119. package/scripts/limpiar-artefactos-python.js +131 -131
  120. package/scripts/mcp-server/README.md +128 -128
  121. package/scripts/mcp-server/handlers.js +206 -206
  122. package/scripts/migrar-csv-a-array.js +168 -168
  123. package/scripts/migrar-fase-dominio.js +201 -201
  124. package/scripts/publicar.js +511 -511
  125. package/scripts/run-eval.js +141 -141
  126. package/scripts/validar-manifest.js +195 -195
  127. package/scripts/validar-userland-vacio.js +110 -110
  128. package/scripts/verificar-release.js +110 -0
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * agent-executor.js
5
+ *
6
+ * Contrato unificado de ejecución de agentes SWL.
7
+ *
8
+ * Implementa el patrón "execute(name, input) → string" de Managed Agents:
9
+ * cualquier agente SWL expone la misma interfaz al orquestador, independientemente
10
+ * de su tipo (implementador, planificador, revisor, etc.).
11
+ *
12
+ * Esto permite que el gateway/cron y el orquestador-swl invoquen agentes
13
+ * de forma programática con trazabilidad completa via run-log.js y HandoffContext.
14
+ *
15
+ * Integra con:
16
+ * - manifiestos/handoff-context.json — schema de trazabilidad entre agentes
17
+ * - hooks/lib/run-log.js — observabilidad de invocaciones
18
+ * - hooks/lib/abort-registry.js — cancelación cooperativa
19
+ *
20
+ * Uso:
21
+ * const executor = require('./agent-executor');
22
+ *
23
+ * // Invocación simple
24
+ * const result = await executor.executeAgent('planificador-swl', 'Planifica la feature X');
25
+ *
26
+ * // Con HandoffContext (trazabilidad multi-agente)
27
+ * const result = await executor.executeAgent('implementador-swl', prompt, {
28
+ * handoff: {
29
+ * reason: 'task_delegation',
30
+ * parentAgent: 'orquestador-swl',
31
+ * transferCount: 1,
32
+ * sessionId: 'abc123',
33
+ * },
34
+ * timeoutMs: 300_000,
35
+ * });
36
+ *
37
+ * console.log(result.output); // string con respuesta del agente
38
+ * console.log(result.status); // 'completed' | 'failed' | 'aborted' | 'timeout'
39
+ * console.log(result.handoff); // HandoffContext actualizado (transferCount++)
40
+ *
41
+ * @module gateway/agent-executor
42
+ */
43
+
44
+ const { execFile } = require('child_process');
45
+ const path = require('path');
46
+
47
+ // run-log puede no estar disponible fuera del repo — importación defensiva
48
+ let runLog;
49
+ try { runLog = require('../hooks/lib/run-log'); } catch (_) { runLog = null; }
50
+
51
+ // abort-registry — cancelación cooperativa
52
+ let abortRegistry;
53
+ try { abortRegistry = require('../hooks/lib/abort-registry'); } catch (_) { abortRegistry = null; }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constantes
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Máximo de transferencias en cadena antes de rechazar (anti-loop). */
60
+ const MAX_TRANSFER_COUNT = 10;
61
+
62
+ /** Timeout por defecto en ms (5 minutos). */
63
+ const DEFAULT_TIMEOUT_MS = 300_000;
64
+
65
+ /** Modelo Claude por defecto para agentes programáticos. */
66
+ const DEFAULT_MODEL = 'claude-sonnet-4-6';
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Tipos (JSDoc)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * @typedef {{
74
+ * reason: string,
75
+ * parentAgent: string,
76
+ * transferCount: number,
77
+ * sessionId?: string,
78
+ * metadata?: object,
79
+ * }} HandoffContext
80
+ *
81
+ * @typedef {{
82
+ * output: string,
83
+ * sessionId: string|null,
84
+ * status: 'completed'|'failed'|'aborted'|'timeout',
85
+ * handoff: HandoffContext|null,
86
+ * durationMs: number,
87
+ * error?: string,
88
+ * }} ExecuteResult
89
+ *
90
+ * @typedef {{
91
+ * handoff?: HandoffContext,
92
+ * sessionId?: string,
93
+ * timeoutMs?: number,
94
+ * model?: string,
95
+ * cwd?: string,
96
+ * }} ExecuteOptions
97
+ */
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // API principal
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Ejecuta un agente SWL con una entrada dada y retorna su output.
105
+ * Implementa el contrato unificado execute(name, input) → string de Managed Agents.
106
+ *
107
+ * @param {string} agentName - Nombre del agente SWL (ej: 'planificador-swl').
108
+ * @param {string} input - Prompt de entrada para el agente.
109
+ * @param {ExecuteOptions} [opts] - Opciones de ejecución.
110
+ * @returns {Promise<ExecuteResult>}
111
+ */
112
+ async function executeAgent(agentName, input, opts) {
113
+ const {
114
+ handoff = null,
115
+ sessionId = null,
116
+ timeoutMs = DEFAULT_TIMEOUT_MS,
117
+ model = DEFAULT_MODEL,
118
+ cwd = process.cwd(),
119
+ } = opts || {};
120
+
121
+ const startMs = Date.now();
122
+
123
+ // --- Validación de anti-loop de HandoffContext ---
124
+ if (handoff && handoff.transferCount >= MAX_TRANSFER_COUNT) {
125
+ return {
126
+ output: '',
127
+ sessionId,
128
+ status: 'failed',
129
+ handoff: null,
130
+ durationMs: 0,
131
+ error: `transferCount (${handoff.transferCount}) supera el máximo permitido (${MAX_TRANSFER_COUNT}). Posible loop de agentes.`,
132
+ };
133
+ }
134
+
135
+ // --- Verificar si hay abort activo ---
136
+ if (abortRegistry) {
137
+ try {
138
+ const estado = abortRegistry.getStatus();
139
+ if (estado === 'force' || estado === 'graceful') {
140
+ return {
141
+ output: '',
142
+ sessionId,
143
+ status: 'aborted',
144
+ handoff: null,
145
+ durationMs: Date.now() - startMs,
146
+ error: `Ejecución cancelada: abort ${estado} activo.`,
147
+ };
148
+ }
149
+ } catch (_) { /* abort-registry no crítico */ }
150
+ }
151
+
152
+ // --- Registrar invocación en run-log ---
153
+ if (runLog && sessionId) {
154
+ try {
155
+ runLog.agentInvoked(sessionId, agentName);
156
+ } catch (_) { /* observabilidad no crítica */ }
157
+ }
158
+
159
+ // --- Construir HandoffContext actualizado ---
160
+ const handoffActualizado = handoff ? {
161
+ ...handoff,
162
+ transferCount: handoff.transferCount + 1,
163
+ sessionId: sessionId || handoff.sessionId,
164
+ } : null;
165
+
166
+ // --- Construir prompt con contexto de agente ---
167
+ const promptCompleto = _buildPrompt(agentName, input, handoffActualizado);
168
+
169
+ // --- Ejecutar via claude CLI ---
170
+ return new Promise((resolve) => {
171
+ const args = [
172
+ '--print',
173
+ '--model', model,
174
+ '--no-update-check',
175
+ promptCompleto,
176
+ ];
177
+
178
+ let stdout = '';
179
+ let stderr = '';
180
+ let timedOut = false;
181
+
182
+ const proc = execFile('claude', args, {
183
+ cwd,
184
+ timeout: timeoutMs,
185
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
186
+ env: { ...process.env },
187
+ }, (err, out, err2) => {
188
+ stdout = out || '';
189
+ stderr = err2 || '';
190
+
191
+ const durationMs = Date.now() - startMs;
192
+
193
+ if (timedOut) {
194
+ resolve({ output: stdout, sessionId, status: 'timeout', handoff: handoffActualizado, durationMs, error: 'timeout' });
195
+ return;
196
+ }
197
+
198
+ if (err && !stdout) {
199
+ resolve({
200
+ output: stderr || err.message,
201
+ sessionId,
202
+ status: 'failed',
203
+ handoff: handoffActualizado,
204
+ durationMs,
205
+ error: err.message,
206
+ });
207
+ return;
208
+ }
209
+
210
+ resolve({
211
+ output: stdout.trim(),
212
+ sessionId,
213
+ status: 'completed',
214
+ handoff: handoffActualizado,
215
+ durationMs,
216
+ });
217
+ });
218
+
219
+ proc.on('error', (err) => {
220
+ if (err.code === 'ETIMEDOUT') timedOut = true;
221
+ });
222
+ });
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Helpers
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Construye el prompt completo incluyendo el rol del agente y el HandoffContext.
231
+ * El agente recibirá suficiente contexto para actuar sin conocer la conversación padre.
232
+ *
233
+ * @param {string} agentName
234
+ * @param {string} input
235
+ * @param {HandoffContext|null} handoff
236
+ * @returns {string}
237
+ */
238
+ function _buildPrompt(agentName, input, handoff) {
239
+ const partes = [];
240
+
241
+ if (handoff) {
242
+ partes.push(`[Contexto de transferencia]`);
243
+ partes.push(`Agente solicitante: ${handoff.parentAgent}`);
244
+ partes.push(`Motivo: ${handoff.reason}`);
245
+ partes.push(`Transferencia #${handoff.transferCount}`);
246
+ if (handoff.sessionId) partes.push(`Sesión: ${handoff.sessionId}`);
247
+ partes.push('');
248
+ }
249
+
250
+ partes.push(`Eres el agente ${agentName} del sistema SWL.`);
251
+ partes.push('');
252
+ partes.push(input);
253
+
254
+ return partes.join('\n');
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Ejecución en lote (pipeline)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Ejecuta una secuencia de agentes en pipeline, pasando el output de cada uno
263
+ * como parte del input del siguiente. Implementa el patrón PipelineContext.
264
+ *
265
+ * @param {Array<{ agentName: string, buildInput: (prevOutput: string, stepResults: object[]) => string }>} pasos
266
+ * @param {{ pipelineName: string, sessionId?: string, timeoutMs?: number }} opts
267
+ * @returns {Promise<{ status: string, stepResults: object[], finalOutput: string }>}
268
+ */
269
+ async function executePipeline(pasos, opts) {
270
+ const { pipelineName, sessionId = null, timeoutMs = DEFAULT_TIMEOUT_MS } = opts || {};
271
+ const stepResults = [];
272
+ let prevOutput = '';
273
+ let finalOutput = '';
274
+
275
+ for (let i = 0; i < pasos.length; i++) {
276
+ const { agentName, buildInput } = pasos[i];
277
+ const input = buildInput(prevOutput, stepResults);
278
+
279
+ const handoff = {
280
+ reason: 'pipeline_execution',
281
+ parentAgent: pipelineName,
282
+ transferCount: i,
283
+ sessionId,
284
+ metadata: {
285
+ pipelineName,
286
+ currentStep: i + 1,
287
+ totalSteps: pasos.length,
288
+ isLastStep: i === pasos.length - 1,
289
+ },
290
+ };
291
+
292
+ const result = await executeAgent(agentName, input, { handoff, sessionId, timeoutMs });
293
+
294
+ stepResults.push({
295
+ step: i + 1,
296
+ agentName,
297
+ output: { text: result.output },
298
+ status: result.status === 'completed' ? 'completed' : 'failed',
299
+ durationMs: result.durationMs,
300
+ });
301
+
302
+ if (result.status !== 'completed') {
303
+ return { status: result.status, stepResults, finalOutput: result.output };
304
+ }
305
+
306
+ prevOutput = result.output;
307
+ finalOutput = result.output;
308
+ }
309
+
310
+ return { status: 'completed', stepResults, finalOutput };
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Exports
315
+ // ---------------------------------------------------------------------------
316
+
317
+ module.exports = {
318
+ executeAgent,
319
+ executePipeline,
320
+ MAX_TRANSFER_COUNT,
321
+ DEFAULT_TIMEOUT_MS,
322
+ };
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Command Relay — Componente que recibe comandos entrantes desde cualquier
5
+ * adaptador del gateway (Telegram, Discord, Webhook, etc.) y los encola en
6
+ * `.planning/inbox/` para su procesamiento por Claude Code.
7
+ *
8
+ * Complementa a GatewayRunner._handleIncoming() añadiendo:
9
+ * - Whitelist estricta de usuarios (allowedUsers)
10
+ * - Whitelist de plataformas con relay habilitado
11
+ * - Sanitización de texto (previene payload injection)
12
+ * - Audit trail en .planning/inbox/audit.jsonl
13
+ * - Rate limiting por usuario (opcional)
14
+ * - Dedup por content hash en ventana corta
15
+ *
16
+ * Inspirado en Claude-Code-Remote (smart-injector.js) pero portable
17
+ * Windows/Linux/macOS: no depende de AppleScript ni tmux. El consumo se
18
+ * realiza vía el comando /swl:inbox o un daemon tmux opt-in.
19
+ *
20
+ * Uso (desde gateway/index.js):
21
+ * const relay = new CommandRelay(baseDir, config);
22
+ * relay.recibirComando(message);
23
+ *
24
+ * @module gateway/command-relay
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const { atomicWriteJSON } = require('../hooks/lib/atomic-write');
31
+ const { EventChannel, EVENTS } = require('./lib/event-channel');
32
+
33
+ const INBOX_DIR = '.planning/inbox';
34
+ const AUDIT_FILE = 'audit.jsonl';
35
+ const MAX_TEXT_LEN = 4000;
36
+ const DEDUP_WINDOW_MS = 30 * 1000; // 30 segundos
37
+
38
+ // Patrones prohibidos en el texto (prevención de payload injection básica)
39
+ const PATRONES_PROHIBIDOS = [
40
+ /<\s*script/i,
41
+ /javascript:/i,
42
+ /data:text\/html/i,
43
+ // Referencias directas a archivos sensibles
44
+ /\.env\b/,
45
+ /id_rsa\b/,
46
+ /\.ssh\/\b/,
47
+ ];
48
+
49
+ class CommandRelay {
50
+ /**
51
+ * @param {string} baseDir - Raíz del proyecto
52
+ * @param {object} config - Configuración del gateway (gateway-config.json)
53
+ */
54
+ constructor(baseDir, config = {}) {
55
+ this.baseDir = baseDir;
56
+ this.relayConfig = config.relay || {};
57
+ this._dedupCache = new Map(); // hash → ts
58
+ this._rateCache = new Map(); // userId → { ts, count }
59
+ this.events = new EventChannel(); // pub/sub: cmd:received|rejected|queued|processed
60
+ }
61
+
62
+ /**
63
+ * Suscribir a eventos del relay. Retorna función de unsubscribe.
64
+ * Tipos disponibles en EVENTS (gateway/lib/event-channel.js):
65
+ * 'cmd:received' | 'cmd:rejected' | 'cmd:queued' | 'cmd:processed' | '*'
66
+ */
67
+ on(eventType, callback) {
68
+ return this.events.on(eventType, callback);
69
+ }
70
+
71
+ /**
72
+ * Verifica si el relay está habilitado globalmente.
73
+ */
74
+ habilitado() {
75
+ return this.relayConfig.enabled === true;
76
+ }
77
+
78
+ /**
79
+ * Verifica si un usuario está autorizado para enviar comandos.
80
+ * @param {string} platform - nombre del adaptador (telegram, discord, etc.)
81
+ * @param {string} userId - ID del usuario en esa plataforma
82
+ */
83
+ usuarioAutorizado(platform, userId) {
84
+ if (!this.habilitado()) return false;
85
+ const platforms = this.relayConfig.platforms || {};
86
+ const pconf = platforms[platform];
87
+ if (!pconf || pconf.enabled !== true) return false;
88
+ const allowed = pconf.allowedUsers || [];
89
+ if (allowed.length === 0) return false; // sin whitelist, bloquear
90
+ return allowed.includes(String(userId));
91
+ }
92
+
93
+ /**
94
+ * Sanitiza un texto entrante. Retorna null si es inválido.
95
+ */
96
+ sanitizar(texto) {
97
+ if (typeof texto !== 'string') return null;
98
+ const t = texto.trim();
99
+ if (!t) return null;
100
+ if (t.length > MAX_TEXT_LEN) return null;
101
+ for (const re of PATRONES_PROHIBIDOS) {
102
+ if (re.test(t)) return null;
103
+ }
104
+ return t;
105
+ }
106
+
107
+ /**
108
+ * Rate limit simple: máx N mensajes por usuario cada M segundos.
109
+ */
110
+ dentroDeRateLimit(userId) {
111
+ const lim = this.relayConfig.rateLimit || { maxPerMinute: 10 };
112
+ const ventanaMs = 60 * 1000;
113
+ const ahora = Date.now();
114
+ const estado = this._rateCache.get(userId) || { resetAt: ahora + ventanaMs, count: 0 };
115
+ if (ahora > estado.resetAt) {
116
+ estado.resetAt = ahora + ventanaMs;
117
+ estado.count = 0;
118
+ }
119
+ estado.count += 1;
120
+ this._rateCache.set(userId, estado);
121
+ return estado.count <= (lim.maxPerMinute || 10);
122
+ }
123
+
124
+ /**
125
+ * Dedup por hash de contenido en ventana corta.
126
+ */
127
+ esDuplicado(platform, userId, texto) {
128
+ const hash = crypto.createHash('sha1')
129
+ .update(`${platform}:${userId}:${texto}`)
130
+ .digest('hex');
131
+ const ahora = Date.now();
132
+ // Limpiar entradas viejas
133
+ for (const [h, ts] of this._dedupCache.entries()) {
134
+ if (ahora - ts > DEDUP_WINDOW_MS) this._dedupCache.delete(h);
135
+ }
136
+ if (this._dedupCache.has(hash)) return true;
137
+ this._dedupCache.set(hash, ahora);
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Recibe un comando entrante y lo encola en .planning/inbox/ si pasa
143
+ * todas las validaciones. Retorna { success: boolean, reason?: string, id?: string }.
144
+ */
145
+ recibirComando(message) {
146
+ if (!this.habilitado()) {
147
+ return { success: false, reason: 'relay-disabled' };
148
+ }
149
+
150
+ const platform = message.platform || 'unknown';
151
+ const userId = String(message.userId || 'unknown');
152
+ const userName = message.userName || 'unknown';
153
+
154
+ this.events.emit({ type: EVENTS.CMD_RECEIVED, platform, userId, userName, textoPreview: (message.text || '').slice(0, 80) });
155
+
156
+ if (!this.usuarioAutorizado(platform, userId)) {
157
+ this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'usuario-no-autorizado', textoPreview: (message.text || '').slice(0, 80) });
158
+ this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'user-not-authorized' });
159
+ return { success: false, reason: 'user-not-authorized' };
160
+ }
161
+
162
+ const texto = this.sanitizar(message.text || message.args || '');
163
+ if (!texto) {
164
+ this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'texto-invalido' });
165
+ this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'invalid-text' });
166
+ return { success: false, reason: 'invalid-text' };
167
+ }
168
+
169
+ if (!this.dentroDeRateLimit(userId)) {
170
+ this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'rate-limit', textoPreview: texto.slice(0, 80) });
171
+ this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'rate-limit-exceeded' });
172
+ return { success: false, reason: 'rate-limit-exceeded' };
173
+ }
174
+
175
+ if (this.esDuplicado(platform, userId, texto)) {
176
+ this._auditar({ platform, userId, userName, accion: 'dedup', textoPreview: texto.slice(0, 80) });
177
+ this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'duplicate' });
178
+ return { success: false, reason: 'duplicate' };
179
+ }
180
+
181
+ // Encolar
182
+ const id = `cmd-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
183
+ const inboxDir = path.join(this.baseDir, INBOX_DIR);
184
+ if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
185
+
186
+ const cmd = {
187
+ id,
188
+ platform,
189
+ userId,
190
+ userName,
191
+ texto,
192
+ recibidoEn: new Date().toISOString(),
193
+ estado: 'pending',
194
+ chatId: message.chatId || null,
195
+ comando: message.command || null,
196
+ };
197
+
198
+ try {
199
+ atomicWriteJSON(path.join(inboxDir, `${id}.json`), cmd);
200
+ this._auditar({ platform, userId, userName, accion: 'encolado', id, textoPreview: texto.slice(0, 80) });
201
+ this.events.emit({ type: EVENTS.CMD_QUEUED, id, platform, userId, userName, textoPreview: texto.slice(0, 80) });
202
+ return { success: true, id };
203
+ } catch (err) {
204
+ this._auditar({ platform, userId, userName, accion: 'error-escritura', razon: err.message });
205
+ this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'write-error', error: err.message });
206
+ return { success: false, reason: 'write-error' };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Lista comandos pendientes en el inbox.
212
+ * @param {object} [opts]
213
+ * @param {number} [opts.limit=20]
214
+ * @returns {Array<object>}
215
+ */
216
+ listarPendientes(opts = {}) {
217
+ const limit = opts.limit || 20;
218
+ const inboxDir = path.join(this.baseDir, INBOX_DIR);
219
+ if (!fs.existsSync(inboxDir)) return [];
220
+ const archivos = fs.readdirSync(inboxDir)
221
+ .filter(f => f.startsWith('cmd-') && f.endsWith('.json'));
222
+ const items = [];
223
+ for (const a of archivos.sort()) {
224
+ try {
225
+ const obj = JSON.parse(fs.readFileSync(path.join(inboxDir, a), 'utf8'));
226
+ if (obj.estado === 'pending') items.push(obj);
227
+ } catch (err) {
228
+ // Archivo malformado o concurrencia con marcarProcesado: ignorar este
229
+ // archivo y continuar con el resto del inbox. No bloquear la lista.
230
+ }
231
+ if (items.length >= limit) break;
232
+ }
233
+ return items;
234
+ }
235
+
236
+ /**
237
+ * Marca un comando como procesado.
238
+ */
239
+ marcarProcesado(id, resultado = {}) {
240
+ const inboxDir = path.join(this.baseDir, INBOX_DIR);
241
+ const filePath = path.join(inboxDir, `${id}.json`);
242
+ if (!fs.existsSync(filePath)) return false;
243
+ try {
244
+ const obj = JSON.parse(fs.readFileSync(filePath, 'utf8'));
245
+ obj.estado = 'processed';
246
+ obj.procesadoEn = new Date().toISOString();
247
+ obj.resultado = resultado;
248
+ atomicWriteJSON(filePath, obj);
249
+ this._auditar({ platform: obj.platform, userId: obj.userId, accion: 'procesado', id });
250
+ this.events.emit({ type: EVENTS.CMD_PROCESSED, id, platform: obj.platform, userId: obj.userId, resultado });
251
+ return true;
252
+ } catch (_) {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Escribe una entrada al audit trail del inbox.
259
+ * @private
260
+ */
261
+ _auditar(entrada) {
262
+ try {
263
+ const inboxDir = path.join(this.baseDir, INBOX_DIR);
264
+ if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
265
+ const linea = JSON.stringify({ ts: new Date().toISOString(), ...entrada }) + '\n';
266
+ fs.appendFileSync(path.join(inboxDir, AUDIT_FILE), linea);
267
+ } catch (_) { /* silencioso */ }
268
+ }
269
+ }
270
+
271
+ module.exports = CommandRelay;