@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,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
+ };
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cron Scheduler — Tick loop para ejecución de jobs programados.
5
+ *
6
+ * Inspirado en Hermes Agent (cron/scheduler.py):
7
+ * - File lock exclusivo para prevenir ejecución concurrente
8
+ * - Tick cada 60 segundos
9
+ * - Grace windows para jobs retrasados
10
+ * - Entrega de resultados local o vía gateway
11
+ *
12
+ * Uso:
13
+ * node gateway/cron/scheduler.js [baseDir]
14
+ *
15
+ * También exporta funciones para uso programático desde otros módulos.
16
+ *
17
+ * @module gateway/cron/scheduler
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execSync } = require('child_process');
23
+
24
+ const { loadJobs, markExecuted, updateAgentState } = require('./store');
25
+ const { isEligible } = require('./jobs');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constantes
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Intervalo entre ticks en milisegundos. */
32
+ const TICK_INTERVAL_MS = 60000;
33
+
34
+ /** Nombre del archivo de lock. */
35
+ const LOCK_FILENAME = '.tick.lock';
36
+
37
+ /** Timeout de ejecución de un job individual (5 minutos). */
38
+ const JOB_TIMEOUT_MS = 300000;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // File lock (cross-platform)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ let _lockFd = null;
45
+ let _lockPath = null;
46
+
47
+ /**
48
+ * Adquiere un lock exclusivo basado en archivo.
49
+ * Previene múltiples instancias del scheduler ejecutándose simultáneamente.
50
+ *
51
+ * @param {string} baseDir
52
+ * @returns {boolean} true si se adquirió el lock, false si ya está tomado.
53
+ */
54
+ function acquireLock(baseDir) {
55
+ const cronDir = path.join(baseDir, '.planning', 'cron');
56
+ if (!fs.existsSync(cronDir)) fs.mkdirSync(cronDir, { recursive: true });
57
+
58
+ _lockPath = path.join(cronDir, LOCK_FILENAME);
59
+
60
+ try {
61
+ // O_WRONLY | O_CREAT | O_EXCL — falla si el archivo ya existe
62
+ _lockFd = fs.openSync(_lockPath, 'wx');
63
+ fs.writeSync(_lockFd, `${process.pid}\n${new Date().toISOString()}\n`);
64
+ return true;
65
+ } catch (err) {
66
+ if (err.code === 'EEXIST') {
67
+ // Lock ya existe — verificar si el proceso que lo creó sigue vivo
68
+ try {
69
+ const content = fs.readFileSync(_lockPath, 'utf8');
70
+ const pid = parseInt(content.split('\n')[0], 10);
71
+ if (pid && _isProcessAlive(pid)) {
72
+ return false; // Otro proceso lo tiene
73
+ }
74
+ // Proceso muerto — robar el lock
75
+ fs.unlinkSync(_lockPath);
76
+ return acquireLock(baseDir); // Reintentar
77
+ } catch (_) {
78
+ return false;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Libera el lock.
87
+ */
88
+ function releaseLock() {
89
+ if (_lockFd !== null) {
90
+ try { fs.closeSync(_lockFd); } catch (e) { /* fd ya cerrado */ }
91
+ _lockFd = null;
92
+ }
93
+ if (_lockPath) {
94
+ try { fs.unlinkSync(_lockPath); } catch (e) { /* lock ya eliminado */ }
95
+ _lockPath = null;
96
+ }
97
+ }
98
+
99
+ function _isProcessAlive(pid) {
100
+ try {
101
+ process.kill(pid, 0); // Signal 0 = check existence
102
+ return true;
103
+ } catch (_) {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Ejecución de jobs
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Ejecuta un job individual.
114
+ *
115
+ * @param {object} job - Job a ejecutar.
116
+ * @param {string} baseDir
117
+ * @returns {{ status: string, output: string }}
118
+ */
119
+ function executeJob(job, baseDir) {
120
+ const command = job.command;
121
+ if (!command) {
122
+ return { status: 'error', output: 'Job sin comando definido' };
123
+ }
124
+
125
+ try {
126
+ const output = execSync(command, {
127
+ cwd: baseDir,
128
+ encoding: 'utf8',
129
+ timeout: JOB_TIMEOUT_MS,
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ env: {
132
+ ...process.env,
133
+ SWL_CRON_JOB_ID: job.id,
134
+ SWL_CRON_JOB_NAME: job.name,
135
+ },
136
+ });
137
+
138
+ return { status: 'completed', output: output.trim() };
139
+ } catch (err) {
140
+ const output = err.stdout ? err.stdout.toString() : err.message;
141
+ const stderr = err.stderr ? err.stderr.toString() : '';
142
+ return {
143
+ status: 'error',
144
+ output: `${output}\n${stderr}`.trim().substring(0, 2000),
145
+ };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Entrega el resultado de un job al destino configurado.
151
+ *
152
+ * @param {object} job - Job ejecutado.
153
+ * @param {object} result - Resultado de executeJob.
154
+ * @param {string} baseDir
155
+ */
156
+ function deliverResult(job, result, baseDir) {
157
+ const deliver = job.deliver || 'local';
158
+
159
+ if (deliver === 'local') {
160
+ // Guardar en .planning/cron/output/{jobId}/
161
+ const outputDir = path.join(baseDir, '.planning', 'cron', 'output', job.id);
162
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
163
+
164
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
165
+ const filePath = path.join(outputDir, `${timestamp}.md`);
166
+
167
+ const content = [
168
+ `# Cron: ${job.name}`,
169
+ ``,
170
+ `**Job ID**: ${job.id}`,
171
+ `**Ejecutado**: ${new Date().toISOString()}`,
172
+ `**Comando**: \`${job.command}\``,
173
+ `**Estado**: ${result.status}`,
174
+ ``,
175
+ '## Output',
176
+ '```',
177
+ result.output || '(sin output)',
178
+ '```',
179
+ ].join('\n');
180
+
181
+ try {
182
+ const { atomicWriteSync } = require('../../hooks/lib/atomic-write');
183
+ atomicWriteSync(filePath, content);
184
+ } catch (_) {
185
+ fs.writeFileSync(filePath, content, 'utf8');
186
+ }
187
+ return;
188
+ }
189
+
190
+ // Entrega vía gateway (telegram, discord, webhook)
191
+ // Escribir en agent-comms para que el gateway lo recoja
192
+ try {
193
+ const { enviarMensaje } = require('../../hooks/lib/agent-comms');
194
+ enviarMensaje(baseDir, {
195
+ type: 'gateway_notification',
196
+ from: 'cron-scheduler',
197
+ to: deliver,
198
+ payload: {
199
+ jobId: job.id,
200
+ jobName: job.name,
201
+ status: result.status,
202
+ output: result.output?.substring(0, 1000),
203
+ },
204
+ });
205
+ } catch (_) {
206
+ // Si agent-comms falla, guardar localmente como fallback
207
+ deliverResult({ ...job, deliver: 'local' }, result, baseDir);
208
+ }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Tick
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Ejecuta un tick del scheduler: evalúa y ejecuta jobs elegibles.
217
+ *
218
+ * @param {string} baseDir
219
+ * @returns {{ executed: number, errors: number }}
220
+ */
221
+ function tick(baseDir) {
222
+ const jobs = loadJobs(baseDir);
223
+ const now = new Date();
224
+ let executed = 0;
225
+ let errors = 0;
226
+
227
+ for (const job of jobs) {
228
+ if (!isEligible(job, now)) continue;
229
+
230
+ const result = executeJob(job, baseDir);
231
+ markExecuted(baseDir, job.id, result);
232
+ updateAgentState(baseDir, job.id, result); // state-per-agent: consecutiveErrors, runCount
233
+ deliverResult(job, result, baseDir);
234
+
235
+ if (result.status === 'completed') {
236
+ executed++;
237
+ } else {
238
+ errors++;
239
+ }
240
+ }
241
+
242
+ return { executed, errors };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Scheduler loop
247
+ // ---------------------------------------------------------------------------
248
+
249
+ let _intervalId = null;
250
+
251
+ /**
252
+ * Inicia el scheduler loop.
253
+ *
254
+ * @param {string} baseDir
255
+ * @returns {boolean} true si se inició, false si ya hay uno corriendo.
256
+ */
257
+ function startScheduler(baseDir) {
258
+ if (!acquireLock(baseDir)) {
259
+ return false;
260
+ }
261
+
262
+ // Registrar cleanup
263
+ const cleanup = () => {
264
+ stopScheduler();
265
+ process.exit(0);
266
+ };
267
+ process.on('SIGTERM', cleanup);
268
+ process.on('SIGINT', cleanup);
269
+
270
+ // Tick inicial
271
+ tick(baseDir);
272
+
273
+ // Loop
274
+ _intervalId = setInterval(() => tick(baseDir), TICK_INTERVAL_MS);
275
+
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Detiene el scheduler y libera el lock.
281
+ */
282
+ function stopScheduler() {
283
+ if (_intervalId !== null) {
284
+ clearInterval(_intervalId);
285
+ _intervalId = null;
286
+ }
287
+ releaseLock();
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Entrypoint CLI
292
+ // ---------------------------------------------------------------------------
293
+
294
+ if (require.main === module) {
295
+ const baseDir = process.argv[2] || process.cwd();
296
+
297
+ console.log(`[cron-scheduler] Iniciando en ${baseDir}`);
298
+ console.log(`[cron-scheduler] Tick interval: ${TICK_INTERVAL_MS / 1000}s`);
299
+
300
+ const started = startScheduler(baseDir);
301
+ if (!started) {
302
+ console.error('[cron-scheduler] Otro scheduler ya está corriendo. Saliendo.');
303
+ process.exit(1);
304
+ }
305
+
306
+ console.log('[cron-scheduler] Scheduler activo. Ctrl+C para detener.');
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Exports
311
+ // ---------------------------------------------------------------------------
312
+
313
+ module.exports = {
314
+ tick,
315
+ startScheduler,
316
+ stopScheduler,
317
+ executeJob,
318
+ deliverResult,
319
+ acquireLock,
320
+ releaseLock,
321
+ TICK_INTERVAL_MS,
322
+ };