@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,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;
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cron Jobs — Parse de schedules y gestión de estado de jobs.
5
+ *
6
+ * Soporta 4 formatos de schedule (adoptados de Hermes Agent cron/jobs.py):
7
+ * 1. Duración única: "30m", "2h", "1d" → ejecutar una vez en N minutos
8
+ * 2. Intervalo recurrente: "every 30m", "every 2h" → ejecutar cada N minutos
9
+ * 3. Expresión cron: "0 9 * * 1-5" → cron estándar (5 campos)
10
+ * 4. Timestamp ISO: "2026-04-15T09:00" → una vez a hora exacta
11
+ *
12
+ * Grace windows (adoptados de Hermes):
13
+ * - Jobs one-shot: 120s de tolerancia para creación retrasada
14
+ * - Jobs recurrentes: grace = period/2, clamped [120s, 7200s]
15
+ *
16
+ * @module gateway/cron/jobs
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constantes
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const ONESHOT_GRACE_SECONDS = 120;
24
+ const MIN_GRACE_SECONDS = 120;
25
+ const MAX_GRACE_SECONDS = 7200; // 2 horas
26
+
27
+ /** Regex para parse de duraciones: "30m", "2h", "1d" */
28
+ const DURATION_RE = /^(\d+)\s*(m|min|h|hr|d)$/i;
29
+
30
+ /** Regex para intervalo recurrente: "every 30m", "every 2h" */
31
+ const INTERVAL_RE = /^every\s+(\d+)\s*(m|min|h|hr|d)$/i;
32
+
33
+ /** Regex para expresión cron: "0 9 * * 1-5" (5 campos) */
34
+ const CRON_RE = /^(\S+\s+){4}\S+$/;
35
+
36
+ /** Multiplicadores de duración a minutos */
37
+ const DURATION_MULTIPLIERS = {
38
+ m: 1, min: 1,
39
+ h: 60, hr: 60,
40
+ d: 1440,
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Parse de schedules
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Parsea un string de schedule en un objeto estructurado.
49
+ *
50
+ * @param {string} schedule - String del schedule.
51
+ * @returns {{ kind: 'once'|'interval'|'cron', minutes?: number, runAt?: string, expr?: string }}
52
+ * @throws {Error} Si el formato no es reconocido.
53
+ */
54
+ function parseSchedule(schedule) {
55
+ if (!schedule || typeof schedule !== 'string') {
56
+ throw new Error(`Schedule inválido: "${schedule}"`);
57
+ }
58
+
59
+ const s = schedule.trim();
60
+
61
+ // 1. Intervalo recurrente: "every 30m"
62
+ const intervalMatch = s.match(INTERVAL_RE);
63
+ if (intervalMatch) {
64
+ const value = parseInt(intervalMatch[1], 10);
65
+ const unit = intervalMatch[2].toLowerCase();
66
+ const mult = DURATION_MULTIPLIERS[unit] || DURATION_MULTIPLIERS[unit.charAt(0)];
67
+ return { kind: 'interval', minutes: value * mult };
68
+ }
69
+
70
+ // 2. Duración única: "30m", "2h"
71
+ const durationMatch = s.match(DURATION_RE);
72
+ if (durationMatch) {
73
+ const value = parseInt(durationMatch[1], 10);
74
+ const unit = durationMatch[2].toLowerCase();
75
+ const mult = DURATION_MULTIPLIERS[unit] || DURATION_MULTIPLIERS[unit.charAt(0)];
76
+ const runAt = new Date(Date.now() + value * mult * 60000);
77
+ return { kind: 'once', minutes: value * mult, runAt: runAt.toISOString() };
78
+ }
79
+
80
+ // 3. Timestamp ISO: "2026-04-15T09:00", "2026-04-15T09:00:00Z"
81
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
82
+ const dt = new Date(s);
83
+ if (isNaN(dt.getTime())) throw new Error(`Timestamp ISO inválido: "${s}"`);
84
+ return { kind: 'once', runAt: dt.toISOString() };
85
+ }
86
+
87
+ // 4. Expresión cron: "0 9 * * 1-5"
88
+ if (CRON_RE.test(s)) {
89
+ return { kind: 'cron', expr: s };
90
+ }
91
+
92
+ throw new Error(`Formato de schedule no reconocido: "${s}". Formatos válidos: "30m", "every 2h", "0 9 * * 1-5", "2026-04-15T09:00"`);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Cálculo de próxima ejecución
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Calcula la próxima ejecución de un job.
101
+ *
102
+ * @param {object} job - Objeto del job con schedule parseado y lastRunAt.
103
+ * @returns {string|null} ISO timestamp de la próxima ejecución, o null si completado.
104
+ */
105
+ function computeNextRun(job) {
106
+ const schedule = job.parsedSchedule || parseSchedule(job.schedule);
107
+ const now = new Date();
108
+
109
+ switch (schedule.kind) {
110
+ case 'once': {
111
+ // Si ya se ejecutó, no hay próxima
112
+ if (job.lastRunAt) return null;
113
+ return schedule.runAt;
114
+ }
115
+ case 'interval': {
116
+ const intervalMs = schedule.minutes * 60000;
117
+ if (job.lastRunAt) {
118
+ return new Date(new Date(job.lastRunAt).getTime() + intervalMs).toISOString();
119
+ }
120
+ // Primera ejecución: ahora + intervalo
121
+ return new Date(now.getTime() + intervalMs).toISOString();
122
+ }
123
+ case 'cron': {
124
+ // Cálculo simplificado de next cron (sin dependencia croniter)
125
+ // Para cron complejo, usar computeNextCronRun()
126
+ return _computeSimpleCronNext(schedule.expr, now);
127
+ }
128
+ default:
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Calcula grace seconds para un job según su periodicidad.
135
+ * Jobs más frecuentes tienen grace más corto; diarios más largo.
136
+ *
137
+ * @param {object} job
138
+ * @returns {number} Segundos de tolerancia.
139
+ */
140
+ function computeGraceSeconds(job) {
141
+ const schedule = job.parsedSchedule || parseSchedule(job.schedule);
142
+
143
+ if (schedule.kind === 'once') return ONESHOT_GRACE_SECONDS;
144
+
145
+ if (schedule.kind === 'interval') {
146
+ const periodSeconds = schedule.minutes * 60;
147
+ const grace = Math.floor(periodSeconds / 2);
148
+ return Math.max(MIN_GRACE_SECONDS, Math.min(grace, MAX_GRACE_SECONDS));
149
+ }
150
+
151
+ // Cron: asumir grace de 2 minutos por defecto
152
+ return MIN_GRACE_SECONDS;
153
+ }
154
+
155
+ /**
156
+ * Verifica si un job es elegible para ejecución.
157
+ *
158
+ * @param {object} job
159
+ * @param {Date} [now=new Date()]
160
+ * @returns {boolean}
161
+ */
162
+ function isEligible(job, now = new Date()) {
163
+ if (job.status !== 'scheduled') return false;
164
+ if (!job.nextRun) return false;
165
+
166
+ // Verificar límite de repeticiones
167
+ if (job.repeat && job.repeat.times !== null && job.repeat.times !== undefined) {
168
+ if ((job.repeat.completed || 0) >= job.repeat.times) return false;
169
+ }
170
+
171
+ const nextRunDate = new Date(job.nextRun);
172
+ const graceMs = computeGraceSeconds(job) * 1000;
173
+
174
+ // Elegible si: nextRun ya pasó pero dentro de la ventana de grace
175
+ // (now - grace) <= nextRun <= now
176
+ // Un job con nextRun más allá del grace se considera perdido (fast-forward)
177
+ const nextRunMs = nextRunDate.getTime();
178
+ const nowMs = now.getTime();
179
+ return nextRunMs <= nowMs && nextRunMs >= (nowMs - graceMs);
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Cron simple (sin dependencia externa)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Cálculo simplificado de próxima ejecución cron.
188
+ * Soporta: minuto, hora, día del mes, mes, día de la semana.
189
+ * Para expresiones complejas (rangos, listas), busca fuerza bruta en 48h.
190
+ *
191
+ * @param {string} expr - Expresión cron de 5 campos.
192
+ * @param {Date} from - Fecha desde la cual buscar.
193
+ * @returns {string|null} ISO timestamp o null si no se encuentra en 48h.
194
+ */
195
+ function _computeSimpleCronNext(expr, from) {
196
+ const fields = expr.split(/\s+/);
197
+ if (fields.length !== 5) return null;
198
+
199
+ const [minF, hourF, domF, monF, dowF] = fields;
200
+
201
+ // Buscar en los próximos 2880 minutos (48 horas)
202
+ const candidate = new Date(from);
203
+ candidate.setSeconds(0, 0);
204
+ candidate.setMinutes(candidate.getMinutes() + 1); // Empezar desde el próximo minuto
205
+
206
+ for (let i = 0; i < 2880; i++) {
207
+ if (_cronFieldMatches(minF, candidate.getMinutes()) &&
208
+ _cronFieldMatches(hourF, candidate.getHours()) &&
209
+ _cronFieldMatches(domF, candidate.getDate()) &&
210
+ _cronFieldMatches(monF, candidate.getMonth() + 1) &&
211
+ _cronFieldMatches(dowF, candidate.getDay())) {
212
+ return candidate.toISOString();
213
+ }
214
+ candidate.setMinutes(candidate.getMinutes() + 1);
215
+ }
216
+
217
+ return null; // No encontrado en 48h
218
+ }
219
+
220
+ /**
221
+ * Verifica si un valor coincide con un campo cron.
222
+ * Soporta: *, N, N-M, N/step, listas (N,M,O).
223
+ */
224
+ function _cronFieldMatches(field, value) {
225
+ if (field === '*') return true;
226
+
227
+ // Lista: "1,3,5"
228
+ if (field.includes(',')) {
229
+ return field.split(',').some(f => _cronFieldMatches(f.trim(), value));
230
+ }
231
+
232
+ // Step: "*/5" o "1-10/2"
233
+ if (field.includes('/')) {
234
+ const [range, step] = field.split('/');
235
+ const stepN = parseInt(step, 10);
236
+ if (range === '*') return value % stepN === 0;
237
+ const [start] = range.split('-').map(Number);
238
+ return value >= start && (value - start) % stepN === 0;
239
+ }
240
+
241
+ // Rango: "1-5"
242
+ if (field.includes('-')) {
243
+ const [start, end] = field.split('-').map(Number);
244
+ return value >= start && value <= end;
245
+ }
246
+
247
+ // Valor exacto
248
+ return parseInt(field, 10) === value;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Exports
253
+ // ---------------------------------------------------------------------------
254
+
255
+ module.exports = {
256
+ parseSchedule,
257
+ computeNextRun,
258
+ computeGraceSeconds,
259
+ isEligible,
260
+ ONESHOT_GRACE_SECONDS,
261
+ MIN_GRACE_SECONDS,
262
+ MAX_GRACE_SECONDS,
263
+ };