@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,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
+ };
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cron Store — Persistencia de jobs CRON en JSON.
5
+ *
6
+ * Almacena jobs en .planning/cron/jobs.json con escritura atómica.
7
+ * Cada job tiene: id, name, schedule, command, deliver, status, nextRun, lastRunAt.
8
+ *
9
+ * @module gateway/cron/store
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const { atomicWriteJSON } = require('../../hooks/lib/atomic-write');
16
+ const { parseSchedule, computeNextRun } = require('./jobs');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constantes
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const CRON_DIR = '.planning/cron';
23
+ const JOBS_FILE = 'jobs.json';
24
+ const LOG_FILE = 'log.json';
25
+ const AGENT_STATE_FILE = 'agent-state.json';
26
+ const MAX_LOG_ENTRIES = 500;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function ensureDir(baseDir) {
33
+ const dir = path.join(baseDir, CRON_DIR);
34
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
35
+ return dir;
36
+ }
37
+
38
+ function jobsPath(baseDir) {
39
+ return path.join(ensureDir(baseDir), JOBS_FILE);
40
+ }
41
+
42
+ function logPath(baseDir) {
43
+ return path.join(ensureDir(baseDir), LOG_FILE);
44
+ }
45
+
46
+ function agentStatePath(baseDir) {
47
+ return path.join(ensureDir(baseDir), AGENT_STATE_FILE);
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // CRUD de jobs
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Carga todos los jobs.
56
+ * @param {string} baseDir
57
+ * @returns {object[]}
58
+ */
59
+ function loadJobs(baseDir) {
60
+ const p = jobsPath(baseDir);
61
+ try {
62
+ if (!fs.existsSync(p)) return [];
63
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
64
+ } catch (_) {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Persiste todos los jobs atómicamente.
71
+ * @param {string} baseDir
72
+ * @param {object[]} jobs
73
+ */
74
+ function saveJobs(baseDir, jobs) {
75
+ atomicWriteJSON(jobsPath(baseDir), jobs);
76
+ }
77
+
78
+ /**
79
+ * Agrega un job nuevo.
80
+ *
81
+ * @param {string} baseDir
82
+ * @param {object} opts
83
+ * @param {string} opts.name - Nombre descriptivo.
84
+ * @param {string} opts.schedule - Schedule string (ej: "every 2h", "0 9 * * 1-5").
85
+ * @param {string} opts.command - Comando a ejecutar.
86
+ * @param {string} [opts.deliver='local'] - Destino: "local", "telegram", "discord".
87
+ * @param {boolean} [opts.enabled=true]
88
+ * @returns {object} Job creado.
89
+ */
90
+ function addJob(baseDir, opts) {
91
+ const jobs = loadJobs(baseDir);
92
+
93
+ const parsed = parseSchedule(opts.schedule);
94
+ const id = `cron-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
95
+
96
+ const job = {
97
+ id,
98
+ name: opts.name,
99
+ schedule: opts.schedule,
100
+ parsedSchedule: parsed,
101
+ command: opts.command,
102
+ deliver: opts.deliver || 'local',
103
+ status: opts.enabled !== false ? 'scheduled' : 'paused',
104
+ nextRun: null,
105
+ lastRunAt: null,
106
+ createdAt: new Date().toISOString(),
107
+ repeat: parsed.kind === 'once' ? { times: 1, completed: 0 } : null,
108
+ };
109
+
110
+ // Calcular próxima ejecución
111
+ job.nextRun = computeNextRun(job);
112
+
113
+ jobs.push(job);
114
+ saveJobs(baseDir, jobs);
115
+ return job;
116
+ }
117
+
118
+ /**
119
+ * Elimina un job por ID.
120
+ * @param {string} baseDir
121
+ * @param {string} jobId
122
+ * @returns {boolean}
123
+ */
124
+ function removeJob(baseDir, jobId) {
125
+ const jobs = loadJobs(baseDir);
126
+ const idx = jobs.findIndex(j => j.id === jobId);
127
+ if (idx < 0) return false;
128
+ jobs.splice(idx, 1);
129
+ saveJobs(baseDir, jobs);
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Pausa un job.
135
+ * @param {string} baseDir
136
+ * @param {string} jobId
137
+ * @returns {boolean}
138
+ */
139
+ function pauseJob(baseDir, jobId) {
140
+ const jobs = loadJobs(baseDir);
141
+ const job = jobs.find(j => j.id === jobId);
142
+ if (!job) return false;
143
+ job.status = 'paused';
144
+ saveJobs(baseDir, jobs);
145
+ return true;
146
+ }
147
+
148
+ /**
149
+ * Reanuda un job pausado.
150
+ * @param {string} baseDir
151
+ * @param {string} jobId
152
+ * @returns {boolean}
153
+ */
154
+ function resumeJob(baseDir, jobId) {
155
+ const jobs = loadJobs(baseDir);
156
+ const job = jobs.find(j => j.id === jobId);
157
+ if (!job) return false;
158
+ job.status = 'scheduled';
159
+ if (!job.nextRun) job.nextRun = computeNextRun(job);
160
+ saveJobs(baseDir, jobs);
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Marca un job como ejecutado y calcula su próxima ejecución.
166
+ * @param {string} baseDir
167
+ * @param {string} jobId
168
+ * @param {object} [result={}] - Resultado de la ejecución.
169
+ */
170
+ function markExecuted(baseDir, jobId, result = {}) {
171
+ const jobs = loadJobs(baseDir);
172
+ const job = jobs.find(j => j.id === jobId);
173
+ if (!job) return;
174
+
175
+ job.lastRunAt = new Date().toISOString();
176
+
177
+ // Actualizar repeticiones
178
+ if (job.repeat) {
179
+ job.repeat.completed = (job.repeat.completed || 0) + 1;
180
+ if (job.repeat.times !== null && job.repeat.completed >= job.repeat.times) {
181
+ job.status = 'completed';
182
+ job.nextRun = null;
183
+ }
184
+ }
185
+
186
+ // Calcular próxima ejecución si aún está activo
187
+ if (job.status === 'scheduled') {
188
+ job.nextRun = computeNextRun(job);
189
+ }
190
+
191
+ saveJobs(baseDir, jobs);
192
+
193
+ // Registrar en log
194
+ _appendLog(baseDir, {
195
+ jobId,
196
+ jobName: job.name,
197
+ executedAt: job.lastRunAt,
198
+ status: result.status || 'completed',
199
+ output: (result.output || '').substring(0, 1000),
200
+ });
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Log de ejecuciones
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function _appendLog(baseDir, entry) {
208
+ const p = logPath(baseDir);
209
+ let log = [];
210
+ try {
211
+ if (fs.existsSync(p)) {
212
+ log = JSON.parse(fs.readFileSync(p, 'utf8'));
213
+ }
214
+ } catch (e) { /* log malformado — resetear */ }
215
+
216
+ log.push(entry);
217
+
218
+ // Mantener solo las últimas MAX_LOG_ENTRIES entradas
219
+ if (log.length > MAX_LOG_ENTRIES) {
220
+ log = log.slice(-MAX_LOG_ENTRIES);
221
+ }
222
+
223
+ atomicWriteJSON(p, log);
224
+ }
225
+
226
+ /**
227
+ * Lee el log de ejecuciones.
228
+ * @param {string} baseDir
229
+ * @param {number} [limit=20]
230
+ * @returns {object[]}
231
+ */
232
+ function readLog(baseDir, limit = 20) {
233
+ const p = logPath(baseDir);
234
+ try {
235
+ if (!fs.existsSync(p)) return [];
236
+ const log = JSON.parse(fs.readFileSync(p, 'utf8'));
237
+ return log.slice(-limit);
238
+ } catch (_) {
239
+ return [];
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // State per agent (portado de rowboat agent-schedule/state-repo.ts)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * Carga el estado histórico de un agente cron.
249
+ * Rastrea: lastRunAt, consecutiveErrors, runCount, lastError.
250
+ *
251
+ * @param {string} baseDir
252
+ * @param {string} jobId
253
+ * @returns {{ lastRunAt: string|null, consecutiveErrors: number, runCount: number, lastError: string|null, lastStatus: string|null }}
254
+ */
255
+ function getAgentState(baseDir, jobId) {
256
+ const p = agentStatePath(baseDir);
257
+ const DEFAULT = { lastRunAt: null, consecutiveErrors: 0, runCount: 0, lastError: null, lastStatus: null };
258
+ try {
259
+ if (!fs.existsSync(p)) return { ...DEFAULT };
260
+ const all = JSON.parse(fs.readFileSync(p, 'utf8'));
261
+ return all[jobId] ? { ...DEFAULT, ...all[jobId] } : { ...DEFAULT };
262
+ } catch (_) {
263
+ return { ...DEFAULT };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Actualiza el estado de un agente cron tras una ejecución.
269
+ *
270
+ * @param {string} baseDir
271
+ * @param {string} jobId
272
+ * @param {{ status: 'completed'|'failed'|'skipped', error?: string }} result
273
+ */
274
+ function updateAgentState(baseDir, jobId, result) {
275
+ const p = agentStatePath(baseDir);
276
+ let all = {};
277
+ try {
278
+ if (fs.existsSync(p)) all = JSON.parse(fs.readFileSync(p, 'utf8'));
279
+ } catch (e) { /* agent-state malformado — resetear */ }
280
+
281
+ const prev = all[jobId] || { lastRunAt: null, consecutiveErrors: 0, runCount: 0, lastError: null, lastStatus: null };
282
+
283
+ all[jobId] = {
284
+ lastRunAt: new Date().toISOString(),
285
+ runCount: (prev.runCount || 0) + 1,
286
+ lastStatus: result.status,
287
+ lastError: result.status === 'failed' ? (result.error || 'unknown error') : null,
288
+ consecutiveErrors: result.status === 'failed'
289
+ ? (prev.consecutiveErrors || 0) + 1
290
+ : 0, // reset en éxito
291
+ };
292
+
293
+ atomicWriteJSON(p, all);
294
+ }
295
+
296
+ /**
297
+ * Lee el historial resumido de todos los agentes cron.
298
+ * Útil para diagnóstico: ver qué jobs tienen errores consecutivos.
299
+ *
300
+ * @param {string} baseDir
301
+ * @returns {Array<{ jobId: string, jobName: string, state: object }>}
302
+ */
303
+ function getAllAgentStates(baseDir) {
304
+ const p = agentStatePath(baseDir);
305
+ try {
306
+ if (!fs.existsSync(p)) return [];
307
+ const all = JSON.parse(fs.readFileSync(p, 'utf8'));
308
+ const jobs = loadJobs(baseDir);
309
+ return Object.entries(all).map(([jobId, state]) => {
310
+ const job = jobs.find(j => j.id === jobId);
311
+ return { jobId, jobName: job?.name || jobId, state };
312
+ });
313
+ } catch (_) {
314
+ return [];
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Exports
320
+ // ---------------------------------------------------------------------------
321
+
322
+ module.exports = {
323
+ loadJobs,
324
+ saveJobs,
325
+ addJob,
326
+ removeJob,
327
+ pauseJob,
328
+ resumeJob,
329
+ markExecuted,
330
+ readLog,
331
+ getAgentState,
332
+ updateAgentState,
333
+ getAllAgentStates,
334
+ CRON_DIR,
335
+ };