agentic-kdd 3.3.1 → 3.5.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.
package/telemetry.cjs ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Agentic KDD — Telemetry v1.0
3
+ * Brecha (c): Observabilidad — requisito duro de L4
4
+ *
5
+ * Sin telemetría no hay L4. L4 (Approver) exige que el usuario pueda
6
+ * auditar la ejecución. La aprobación a nivel de objetivo requiere trazas
7
+ * inmutables de qué hizo el agente y por qué.
8
+ *
9
+ * Implementa:
10
+ * - JSONL append-only en .agentic/telemetria/ (auditable en git)
11
+ * - Spans con: timestamp, agente, fase, acción, resultado, duración
12
+ * - Export opcional a Langfuse (self-hosted MIT, ~21k ★)
13
+ * - STOPs siempre registrados con razón completa
14
+ * - Memory reads/writes registrados (cuándo se usó recall, qué se recordó)
15
+ *
16
+ * La filosofía: cada ciclo genera un archivo trace_[ciclo_id].jsonl
17
+ * Cada línea es un span. El archivo completo es la auditoría del ciclo.
18
+ * Git lo versiona. El equipo lo puede revisar. El EU AI Act lo exige.
19
+ *
20
+ * Uso:
21
+ * const { startSpan, endSpan, recordStop, recordMemoryRead } = require('./telemetry.cjs');
22
+ * const span = startSpan({ agent: 'Analista', phase: 'plan', task: 'fix auth' });
23
+ * // ... work ...
24
+ * endSpan(span, { outcome: 'PASS', files_touched: ['auth.ts'] });
25
+ *
26
+ * CLI:
27
+ * node telemetry.cjs view [ciclo_id] — ver trazas de un ciclo
28
+ * node telemetry.cjs summary — resumen de telemetría
29
+ * node telemetry.cjs export langfuse — exportar a Langfuse
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const path = require('path');
35
+ const fs = require('fs');
36
+ const crypto = require('crypto');
37
+
38
+ const TELEMETRIA_DIR = '.agentic/telemetria';
39
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB por archivo
40
+
41
+ // ─── ESTRUCTURA DE UN SPAN ────────────────────────────────────────────────────
42
+ /**
43
+ * Span de telemetría. Inmutable una vez cerrado.
44
+ *
45
+ * {
46
+ * span_id: "abc123",
47
+ * trace_id: "ciclo_xyz", // = ciclo_id del ciclo actual
48
+ * parent_id: "def456", // null si es root span
49
+ * timestamp: "2026-06-24T...", // ISO8601 UTC
50
+ * duration_ms: 1234, // null si el span no cerró
51
+ * agent: "Analista", // Orquestador|Analista|Dev|QA|Memoria|ContractGuard
52
+ * phase: "plan", // plan|build|tdd|qa|preservation|review|memory|creative
53
+ * action: "recall", // recall|remember|decide|execute|verify|stop|warn
54
+ * input: { query: "..." }, // lo que recibió
55
+ * output: { results: 3 }, // lo que produjo
56
+ * outcome: "PASS", // PASS|FAIL|STOP|WARN|SKIP
57
+ * metadata: {}
58
+ * }
59
+ */
60
+
61
+ // ─── GESTIÓN DE ARCHIVOS ──────────────────────────────────────────────────────
62
+
63
+ function ensureDir(projectRoot) {
64
+ const dir = path.join(projectRoot, TELEMETRIA_DIR);
65
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
66
+ return dir;
67
+ }
68
+
69
+ function getTraceFile(projectRoot, traceId) {
70
+ const dir = ensureDir(projectRoot);
71
+ return path.join(dir, `trace_${traceId || 'session'}.jsonl`);
72
+ }
73
+
74
+ function appendLine(filePath, obj) {
75
+ try {
76
+ const line = JSON.stringify(obj) + '\n';
77
+ fs.appendFileSync(filePath, line, 'utf8');
78
+ return true;
79
+ } catch { return false; }
80
+ }
81
+
82
+ function readTrace(filePath) {
83
+ if (!fs.existsSync(filePath)) return [];
84
+ try {
85
+ return fs.readFileSync(filePath, 'utf8')
86
+ .split('\n')
87
+ .filter(Boolean)
88
+ .map(line => JSON.parse(line));
89
+ } catch { return []; }
90
+ }
91
+
92
+ // ─── SPAN MANAGEMENT ─────────────────────────────────────────────────────────
93
+
94
+ const activeSpans = {};
95
+
96
+ /**
97
+ * Inicia un span de telemetría.
98
+ * @returns span object con span_id
99
+ */
100
+ function startSpan(params = {}, projectRoot) {
101
+ const spanId = crypto.randomBytes(4).toString('hex');
102
+ const traceId = params.trace_id || params.ciclo_id || 'session';
103
+
104
+ const span = {
105
+ span_id: spanId,
106
+ trace_id: traceId,
107
+ parent_id: params.parent_id || null,
108
+ timestamp: new Date().toISOString(),
109
+ _start_ms: Date.now(),
110
+ agent: params.agent || 'unknown',
111
+ phase: params.phase || 'unknown',
112
+ action: params.action || 'execute',
113
+ input: params.input || {},
114
+ outcome: null,
115
+ output: null,
116
+ metadata: params.metadata || {},
117
+ };
118
+
119
+ activeSpans[spanId] = { span, projectRoot: projectRoot || process.cwd() };
120
+
121
+ // Escribir span abierto (para auditoría de crashes)
122
+ const file = getTraceFile(span._start_ms && projectRoot, traceId);
123
+ try {
124
+ const { _start_ms, ...logSpan } = span;
125
+ appendLine(file, { ...logSpan, status: 'open' });
126
+ } catch {}
127
+
128
+ return span;
129
+ }
130
+
131
+ /**
132
+ * Cierra un span con resultado.
133
+ */
134
+ function endSpan(span, result = {}, projectRoot) {
135
+ if (!span || !span.span_id) return;
136
+
137
+ const root = projectRoot || activeSpans[span.span_id]?.projectRoot || process.cwd();
138
+ const durationMs = Date.now() - (span._start_ms || Date.now());
139
+
140
+ const closedSpan = {
141
+ span_id: span.span_id,
142
+ trace_id: span.trace_id,
143
+ parent_id: span.parent_id,
144
+ timestamp: span.timestamp,
145
+ closed_at: new Date().toISOString(),
146
+ duration_ms: durationMs,
147
+ agent: span.agent,
148
+ phase: span.phase,
149
+ action: span.action,
150
+ input: span.input,
151
+ output: result.output || {},
152
+ outcome: result.outcome || 'PASS',
153
+ files_touched: result.files_touched || [],
154
+ metadata: { ...span.metadata, ...result.metadata },
155
+ };
156
+
157
+ const file = getTraceFile(root, span.trace_id);
158
+ appendLine(file, closedSpan);
159
+
160
+ delete activeSpans[span.span_id];
161
+ return closedSpan;
162
+ }
163
+
164
+ // ─── HELPERS PARA EVENTOS ESPECÍFICOS ────────────────────────────────────────
165
+
166
+ function recordStop(reason, context = {}, projectRoot) {
167
+ const root = projectRoot || process.cwd();
168
+ const file = getTraceFile(root, context.ciclo_id || 'session');
169
+
170
+ const stopEvent = {
171
+ span_id: crypto.randomBytes(4).toString('hex'),
172
+ trace_id: context.ciclo_id || 'session',
173
+ timestamp: new Date().toISOString(),
174
+ agent: context.agent || 'Harness',
175
+ phase: context.phase || 'gate',
176
+ action: 'stop',
177
+ outcome: 'STOP',
178
+ reason,
179
+ context,
180
+ };
181
+
182
+ appendLine(file, stopEvent);
183
+ return stopEvent;
184
+ }
185
+
186
+ function recordMemoryRead(query, results = [], context = {}, projectRoot) {
187
+ const root = projectRoot || process.cwd();
188
+ const file = getTraceFile(root, context.ciclo_id || 'session');
189
+
190
+ appendLine(file, {
191
+ span_id: crypto.randomBytes(4).toString('hex'),
192
+ trace_id: context.ciclo_id || 'session',
193
+ timestamp: new Date().toISOString(),
194
+ agent: context.agent || 'Analista',
195
+ phase: context.phase || 'plan',
196
+ action: 'recall',
197
+ outcome: 'PASS',
198
+ input: { query },
199
+ output: { results_count: results.length, top_result: results[0]?.titulo },
200
+ tokens_saved: context.tokens_saved || null,
201
+ });
202
+ }
203
+
204
+ function recordMemoryWrite(entry, result = {}, context = {}, projectRoot) {
205
+ const root = projectRoot || process.cwd();
206
+ const file = getTraceFile(root, context.ciclo_id || 'session');
207
+
208
+ appendLine(file, {
209
+ span_id: crypto.randomBytes(4).toString('hex'),
210
+ trace_id: context.ciclo_id || 'session',
211
+ timestamp: new Date().toISOString(),
212
+ agent: context.agent || 'Memoria',
213
+ phase: context.phase || 'memory',
214
+ action: 'remember',
215
+ outcome: result.ok ? 'PASS' : 'FAIL',
216
+ input: { entry: (entry || '').substring(0, 100) },
217
+ output: { id: result.id, reason: result.reason },
218
+ });
219
+ }
220
+
221
+ function recordDecision(decision, context = {}, projectRoot) {
222
+ const root = projectRoot || process.cwd();
223
+ const file = getTraceFile(root, context.ciclo_id || 'session');
224
+
225
+ appendLine(file, {
226
+ span_id: crypto.randomBytes(4).toString('hex'),
227
+ trace_id: context.ciclo_id || 'session',
228
+ timestamp: new Date().toISOString(),
229
+ agent: 'AutonomousDecision',
230
+ phase: context.phase || 'analysis',
231
+ action: 'decide',
232
+ outcome: decision.decision,
233
+ input: { files: context.files, task: context.task },
234
+ output: { decision: decision.decision, summary: decision.summary },
235
+ blast_radius: decision.blast?.level,
236
+ reasons: decision.reasons,
237
+ });
238
+ }
239
+
240
+ // ─── EXPORT A LANGFUSE ────────────────────────────────────────────────────────
241
+ /**
242
+ * Export opcional a Langfuse self-hosted.
243
+ * Config en .agentic/config.md: langfuse_host, langfuse_pk, langfuse_sk
244
+ */
245
+ async function exportToLangfuse(traceId, projectRoot) {
246
+ projectRoot = projectRoot || process.cwd();
247
+
248
+ // Leer config de Langfuse
249
+ let langfuseConfig = null;
250
+ try {
251
+ const config = fs.readFileSync(path.join(projectRoot, '.agentic/config.md'), 'utf8');
252
+ const host = config.match(/langfuse_host:\s*(.+)/)?.[1]?.trim();
253
+ const pk = config.match(/langfuse_pk:\s*(.+)/)?.[1]?.trim();
254
+ const sk = config.match(/langfuse_sk:\s*(.+)/)?.[1]?.trim();
255
+ if (host && pk && sk) langfuseConfig = { host, pk, sk };
256
+ } catch {}
257
+
258
+ if (!langfuseConfig) {
259
+ return { ok: false, reason: 'Langfuse not configured. Add langfuse_host/pk/sk to .agentic/config.md' };
260
+ }
261
+
262
+ const dir = ensureDir(projectRoot);
263
+ const file = path.join(dir, `trace_${traceId}.jsonl`);
264
+ const spans = readTrace(file);
265
+
266
+ if (spans.length === 0) return { ok: false, reason: 'No spans found' };
267
+
268
+ // Enviar a Langfuse API (OTEL format)
269
+ try {
270
+ const body = {
271
+ batch: spans.map(span => ({
272
+ id: span.span_id,
273
+ timestamp: span.timestamp,
274
+ type: 'span',
275
+ body: {
276
+ id: span.span_id,
277
+ traceId: span.trace_id,
278
+ name: `${span.agent}.${span.action}`,
279
+ startTime: span.timestamp,
280
+ endTime: span.closed_at || span.timestamp,
281
+ metadata: { ...span.metadata, outcome: span.outcome },
282
+ input: span.input,
283
+ output: span.output,
284
+ statusMessage:span.outcome === 'STOP' ? span.reason : null,
285
+ },
286
+ })),
287
+ };
288
+
289
+ const resp = await fetch(`${langfuseConfig.host}/api/public/ingestion`, {
290
+ method: 'POST',
291
+ headers: {
292
+ 'Content-Type': 'application/json',
293
+ 'Authorization': `Basic ${Buffer.from(`${langfuseConfig.pk}:${langfuseConfig.sk}`).toString('base64')}`,
294
+ },
295
+ body: JSON.stringify(body),
296
+ });
297
+
298
+ return { ok: resp.ok, status: resp.status, spans_exported: spans.length };
299
+ } catch (e) {
300
+ return { ok: false, error: e.message };
301
+ }
302
+ }
303
+
304
+ // ─── VIEW & SUMMARY ───────────────────────────────────────────────────────────
305
+
306
+ function viewTrace(traceId, projectRoot) {
307
+ projectRoot = projectRoot || process.cwd();
308
+ const dir = path.join(projectRoot, TELEMETRIA_DIR);
309
+
310
+ if (!fs.existsSync(dir)) return [];
311
+
312
+ let file;
313
+ if (traceId) {
314
+ file = path.join(dir, `trace_${traceId}.jsonl`);
315
+ } else {
316
+ // Último archivo
317
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort().reverse();
318
+ if (!files.length) return [];
319
+ file = path.join(dir, files[0]);
320
+ }
321
+
322
+ return readTrace(file);
323
+ }
324
+
325
+ function getSummary(projectRoot) {
326
+ projectRoot = projectRoot || process.cwd();
327
+ const dir = path.join(projectRoot, TELEMETRIA_DIR);
328
+
329
+ if (!fs.existsSync(dir)) return { traces: 0, total_spans: 0, stops: 0 };
330
+
331
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
332
+ let totalSpans = 0, stops = 0, recalls = 0, remembers = 0;
333
+
334
+ files.forEach(f => {
335
+ const spans = readTrace(path.join(dir, f));
336
+ totalSpans += spans.length;
337
+ stops += spans.filter(s => s.outcome === 'STOP').length;
338
+ recalls += spans.filter(s => s.action === 'recall').length;
339
+ remembers += spans.filter(s => s.action === 'remember').length;
340
+ });
341
+
342
+ return {
343
+ traces: files.length,
344
+ total_spans: totalSpans,
345
+ stops,
346
+ recalls,
347
+ remembers,
348
+ avg_spans_per_trace: files.length > 0 ? Math.round(totalSpans / files.length) : 0,
349
+ };
350
+ }
351
+
352
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
353
+
354
+ if (require.main === module) {
355
+ const [,, cmd, arg] = process.argv;
356
+ const projectRoot = process.cwd();
357
+
358
+ switch (cmd) {
359
+ case 'view': {
360
+ const spans = viewTrace(arg, projectRoot);
361
+ if (!spans.length) { console.log('\n No traces found.\n'); break; }
362
+ console.log(`\n Trace ${arg || 'latest'} — ${spans.length} spans\n`);
363
+ spans.forEach(s => {
364
+ const icon = s.outcome === 'STOP' ? '🛑' : s.outcome === 'WARN' ? '⚠️' : '✅';
365
+ const dur = s.duration_ms ? `${s.duration_ms}ms` : '';
366
+ console.log(` ${icon} ${s.agent}.${s.action} [${s.phase}] ${dur}`);
367
+ if (s.outcome === 'STOP') console.log(` STOP: ${s.reason}`);
368
+ });
369
+ console.log('');
370
+ break;
371
+ }
372
+
373
+ case 'summary': {
374
+ const s = getSummary(projectRoot);
375
+ console.log('\n Telemetry Summary');
376
+ console.log(` Trace files: ${s.traces}`);
377
+ console.log(` Total spans: ${s.total_spans}`);
378
+ console.log(` STOPs: ${s.stops}`);
379
+ console.log(` Recalls: ${s.recalls}`);
380
+ console.log(` Remembers: ${s.remembers}\n`);
381
+ break;
382
+ }
383
+
384
+ case 'export':
385
+ if (arg === 'langfuse') {
386
+ exportToLangfuse(process.argv[4], projectRoot)
387
+ .then(r => console.log(r.ok ? `✅ Exported ${r.spans_exported} spans` : `❌ ${r.reason || r.error}`));
388
+ }
389
+ break;
390
+
391
+ default:
392
+ console.log('Uso: node telemetry.cjs [view [trace_id] | summary | export langfuse <trace_id>]');
393
+ }
394
+ }
395
+
396
+ module.exports = {
397
+ startSpan, endSpan,
398
+ recordStop, recordMemoryRead, recordMemoryWrite, recordDecision,
399
+ exportToLangfuse, viewTrace, getSummary,
400
+ };