@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.
- package/CLAUDE.md +12 -4
- package/README.md +1 -1
- package/bin/swl-mcp-server.js +187 -187
- package/bin/swl-webhook-server.js +198 -0
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/adoptar-proyecto.md +21 -1
- package/comandos/swl/claudemd.md +14 -1
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/exportar-vault.md +108 -0
- package/comandos/swl/nuevo-proyecto.md +24 -2
- package/gateway/adapters/base.js +109 -0
- package/gateway/adapters/discord.js +167 -0
- package/gateway/adapters/email.js +221 -0
- package/gateway/adapters/slack.js +192 -0
- package/gateway/adapters/telegram.js +183 -0
- package/gateway/adapters/webhook.js +113 -0
- package/gateway/adapters/whatsapp.js +214 -0
- package/gateway/agent-executor.js +322 -0
- package/gateway/command-relay.js +271 -0
- package/gateway/cron/jobs.js +263 -0
- package/gateway/cron/scheduler.js +322 -0
- package/gateway/cron/store.js +335 -0
- package/gateway/index.js +320 -0
- package/gateway/lib/event-channel.js +191 -0
- package/gateway/session.js +131 -0
- package/gateway/webhook-server.js +324 -0
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/build-errors-nextjs/SKILL.md +55 -1
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/extractor-de-aprendizajes/SKILL.md +20 -10
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/nextjs-testing/SKILL.md +89 -5
- package/habilidades/node-experto/SKILL.md +37 -1
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/react-experto/SKILL.md +45 -4
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/tdd-workflow/SKILL.md +36 -4
- package/habilidades/testing-python/SKILL.md +340 -340
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/inyeccion-contexto.js +8 -3
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-ip.js +177 -0
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/lib/webhook-dedup.js +184 -0
- package/hooks/lib/webhook-verify.js +123 -0
- package/hooks/proteccion-rutas.js +120 -15
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +1 -0
- package/manifiestos/skills-lock.json +34 -34
- package/package.json +5 -3
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +1 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/reglas/usar-sistema-swl.md +251 -0
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/comandos/skills.js +251 -2
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +195 -195
- package/scripts/validar-userland-vacio.js +110 -110
- 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;
|