@cristobalbn/chaski 0.0.0-stage → 0.1.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.
@@ -0,0 +1,566 @@
1
+ // Capa de facts: todo lo que se puede derivar de git y del transcript sin
2
+ // llamar a ningún modelo. Cero tokens, milisegundos, y exacto por construcción.
3
+ //
4
+ // Invariante del proyecto: el modelo NO puede escribir en estos campos.
5
+ // El estado de los tests sale del exit code y de ningún otro lado.
6
+ import { readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
7
+ import { git, currentBranch } from './paths.mjs';
8
+ import { isExcludedPath, redact } from './secrets.mjs';
9
+ import { hasTime } from './budget.mjs';
10
+
11
+ // Costo de `diff --numstat` por archivo modificado, calibrado contra git
12
+ // real: 1.000/5.000/10.000 archivos sin commitear tardaron ~0,45 ms/archivo
13
+ // de forma consistente en el caso de prueba (ver docs/auditoria-2026-11.md).
14
+ // Se usa 0,6 con un piso fijo para quedar con margen sin ser tan conservador
15
+ // que un turno de 1.000 archivos —un `prettier --write` normal— pierda el
16
+ // detalle que sí le cabe en el presupuesto.
17
+ const MS_BASE_NUMSTAT = 30;
18
+ const MS_POR_ARCHIVO_NUMSTAT = 0.6;
19
+ // Lo que se reserva después de numstat para upstream, render, sign y las dos
20
+ // escrituras atómicas. Medido: ese resto cuesta decenas de ms, no cientos;
21
+ // 200 ms es margen generoso, no ajustado al límite.
22
+ const RESERVA_DESPUES_DE_NUMSTAT_MS = 200;
23
+
24
+ /** Hechos de git: rama, archivos sin commitear, diff y divergencia con upstream. */
25
+ export function gitFacts(cwd, config, budget = null, branch = undefined) {
26
+ const h = {
27
+ branch: branch === undefined ? currentBranch(cwd, budget) : branch,
28
+ isRepo: false, dirty: [], commits: [], upstream: null,
29
+ trimmed: [],
30
+ };
31
+ if (h.branch === null) return h;
32
+ h.isRepo = true;
33
+
34
+ // status --porcelain --branch es la fuente PRIMARIA, no numstat. Antes era al
35
+ // revés, y eso es justo el defecto: numstat calcula diff real por archivo
36
+ // y con miles de archivos sin commitear tarda segundos (medido: ~4,5 s con
37
+ // 10.000, contra ~0,1 s de status --porcelain sobre el mismo árbol), más
38
+ // que el presupuesto entero del hook. status ya trae todo lo que el relevo
39
+ // necesita para la lista y el total — rama, upstream, divergencia, ruta, si
40
+ // es nuevo, incluso renombres (`viejo -> nuevo`) — y numstat solo agrega el
41
+ // detalle de líneas +/-, que es justamente lo que un relevo con miles de
42
+ // archivos no necesita mostrar.
43
+ //
44
+ // El parseo NO usa columnas fijas: el stdout llega sin el espacio inicial de
45
+ // la primera línea, así que un slice(3) cortaría un carácter del name.
46
+ //
47
+ // Si no queda presupuesto ni para ESTE llamado, no hay que confundir "no lo
48
+ // sabemos" con "está limpio": sin status no hay con qué decir que el árbol
49
+ // está limpio, y afirmarlo sería mentir. Por eso se distingue null (no se
50
+ // pudo pedir) de '' (se pidió y no hay nada sucio) en vez de usar `?? ''`
51
+ // como hacían todas las llamadas de esta función en la versión anterior.
52
+ const status = hasTime(budget, 100)
53
+ ? git(cwd, ['status', '--porcelain', '--branch'], { budget }) : null;
54
+ const porArchivo = new Map();
55
+ let statusUpstream = null;
56
+ if (status !== null) {
57
+ for (const linea of status.split('\n')) {
58
+ if (linea.startsWith('## ')) {
59
+ statusUpstream = parseStatusBranch(linea);
60
+ continue;
61
+ }
62
+ const m = /^\s*(\S{1,2})\s+(.+?)\s*$/.exec(linea);
63
+ if (!m) continue;
64
+ const ruta = m[2].replace(/^"|"$/g, '').split(' -> ').pop();
65
+ if (!ruta || isExcludedPath(ruta, config)) continue;
66
+ porArchivo.set(ruta, { ruta, mas: null, menos: null, nuevo: m[1].includes('?') });
67
+ }
68
+ } else {
69
+ h.dirtyUnknown = true;
70
+ h.trimmed.push(
71
+ 'lista de archivos sin commitear: se omitió "git status --porcelain" por presupuesto de tiempo '
72
+ + '-- no se puede afirmar que el árbol está limpio, solo que no se comprobó',
73
+ );
74
+ }
75
+
76
+ // Últimos commits: barato (cinco líneas fijas) y de alto valor — el relevo
77
+ // nuevo necesita saber qué ya quedó cerrado. Va antes de numstat a
78
+ // propósito: es justo el tipo de paso que SÍ debe sobrevivir un recorte.
79
+ if (hasTime(budget)) {
80
+ const log = git(cwd, ['log', '-5', '--format=%h %s'], { budget }) ?? '';
81
+ h.commits = log.split('\n').filter(Boolean);
82
+ } else {
83
+ h.trimmed.push('últimos commits: se omitió, no quedaba presupuesto de tiempo');
84
+ }
85
+
86
+ // El detalle +/- por archivo (numstat) es el paso caro y de bajo valor
87
+ // informativo cuando hay miles de archivos: el relevo solo lista 20 y el
88
+ // total aparte, así que el detalle de los otros 9.980 no aporta nada. Se
89
+ // omite si no cabe en lo que queda del presupuesto — y si se omite, el
90
+ // archivo no desaparece del relevo: sigue viniendo de status, solo que sin
91
+ // el "+X -Y".
92
+ const estimadoNumstat = MS_BASE_NUMSTAT + porArchivo.size * MS_POR_ARCHIVO_NUMSTAT;
93
+ const alcanza = porArchivo.size > 0
94
+ && hasTime(budget, estimadoNumstat + RESERVA_DESPUES_DE_NUMSTAT_MS);
95
+
96
+ let numstatOk = porArchivo.size === 0; // nada que enriquecer: no es un recorte
97
+ if (alcanza) {
98
+ const numstat = git(cwd, ['diff', 'HEAD', '--numstat'], { budget });
99
+ // `null` es "no corrió" (timeout, presupuesto, repo sin HEAD); `''` es
100
+ // "corrió y no hay nada que informar", que es el caso NORMAL cuando todo
101
+ // lo sucio está sin seguimiento: numstat no lista archivos untracked.
102
+ // Tratar los dos igual hacía que el relevo declarara un recorte que nunca
103
+ // ocurrió — la misma clase de mentira que el recorte vino a evitar, en la
104
+ // dirección contraria.
105
+ if (numstat !== null) {
106
+ numstatOk = true;
107
+ for (const linea of numstat.split('\n').filter(Boolean)) {
108
+ const [mas, menos, bruto] = linea.split('\t');
109
+ const ruta = renameTarget(bruto);
110
+ if (!ruta || isExcludedPath(ruta, config)) continue;
111
+ const previo = porArchivo.get(ruta);
112
+ porArchivo.set(ruta, { ruta, mas: num(mas), menos: num(menos), nuevo: previo?.nuevo ?? false });
113
+ }
114
+ }
115
+ }
116
+ if (!numstatOk) {
117
+ h.trimmed.push(
118
+ `detalle de líneas +/- de los ${porArchivo.size} archivo(s) sin commitear: se omitió, no quedaba `
119
+ + 'presupuesto de tiempo para "git diff --numstat" (la lista de archivos y el total siguen completos)',
120
+ );
121
+ }
122
+
123
+ // Tope deliberado. El relevo muestra 20, pero el estado guardaba todos: en
124
+ // un repo con miles de archivos dirty —tras un `npm install` sin
125
+ // gitignore, o al generar código— el estado.json pasaba del megabyte y se
126
+ // reescribía completo en cada turno. Se guarda un margen sobre lo que se
127
+ // muestra y el total aparte, que es lo único que el relevo necesita.
128
+ const todos = [...porArchivo.values()];
129
+ h.dirty = todos.slice(0, 50);
130
+ h.dirtyTotal = todos.length;
131
+
132
+ // Divergencia con upstream: si `status --branch` ya trajo la referencia y el
133
+ // conteo, solo lanza git cuando upstream efectivamente tiene commits nuevos.
134
+ if (hasTime(budget, 150)) {
135
+ h.upstream = upstreamDivergence(cwd, h.dirty.map((s) => s.ruta), budget, statusUpstream);
136
+ } else {
137
+ h.trimmed.push('divergencia con upstream: se omitió, no quedaba presupuesto de tiempo');
138
+ }
139
+
140
+ return h;
141
+ }
142
+
143
+ /**
144
+ * Extrae el upstream y cuántos commits tiene por delante de HEAD a partir de
145
+ * la cabecera `## rama...upstream [ahead X, behind Y]` de `git status --branch`.
146
+ */
147
+ function parseStatusBranch(linea) {
148
+ const cuerpo = linea.slice(3).trim();
149
+ const idx = cuerpo.indexOf('...');
150
+ if (idx === -1) return { hasUpstream: false };
151
+ const resto = cuerpo.slice(idx + 3);
152
+ const m = /^([^\s\[]+)(?:\s+\[([^\]]+)\])?/.exec(resto);
153
+ if (!m) return { hasUpstream: false };
154
+ const ref = m[1];
155
+ const detalle = m[2] ?? '';
156
+ if (/\bgone\b/.test(detalle)) return { hasUpstream: false };
157
+ const behind = /\bbehind\s+(\d+)\b/.exec(detalle);
158
+ return {
159
+ hasUpstream: true,
160
+ ref,
161
+ ahead: behind ? (parseInt(behind[1], 10) || 0) : 0,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Qué hicieron los demás en los archivos que estás tocando.
167
+ * Sale de la información que git ya tiene local: no hace fetch, no usa red.
168
+ */
169
+ function upstreamDivergence(cwd, misArchivos, budget = null, statusUpstream = null) {
170
+ let up = null;
171
+ let ahead = 0;
172
+ if (statusUpstream) {
173
+ if (!statusUpstream.hasUpstream) return null;
174
+ up = statusUpstream.ref;
175
+ ahead = statusUpstream.ahead;
176
+ } else {
177
+ up = git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { budget });
178
+ if (!up) return null;
179
+ if (!hasTime(budget)) return null;
180
+
181
+ const cuenta = git(cwd, ['rev-list', '--count', `HEAD..${up}`], { budget });
182
+ ahead = parseInt(cuenta ?? '0', 10) || 0;
183
+ }
184
+ if (ahead === 0) return { ref: up, ahead: 0, collisions: [] };
185
+ if (!hasTime(budget)) return { ref: up, ahead, collisions: [] };
186
+
187
+ const tocados = git(cwd, ['diff', '--name-only', `HEAD..${up}`], { budget }) ?? '';
188
+ const suyos = new Set(tocados.split('\n').filter(Boolean));
189
+ const collisions = misArchivos.filter((r) => suyos.has(r));
190
+ return { ref: up, ahead, collisions };
191
+ }
192
+
193
+ /**
194
+ * Comandos ejecutados y su resultado, leídos del transcript desde la marca de
195
+ * agua anterior. Solo se lee el delta: el costo no crece con la sesión.
196
+ */
197
+ export function transcriptFacts(rutaTranscript, desdeOffset = 0, config = null, budget = null) {
198
+ const res = { commands: [], filesRead: [], lastMessage: null, offset: desdeOffset, turns: 0 };
199
+ if (!rutaTranscript) return res;
200
+ // Medido: hasta 8 MB (el techo de MAX más abajo) tarda ~35-40 ms, muy por
201
+ // debajo de cualquier presupuesto razonable. No necesita cortes internos;
202
+ // esta única guarda es para el caso en que algo ANTES ya agotó el reloj.
203
+ if (budget?.expired()) return res;
204
+
205
+ let bruto;
206
+ try {
207
+ const st = statSync(rutaTranscript);
208
+ // Si el archivo se truncó o rotó, se relee desde el principio.
209
+ const inicio = desdeOffset > st.size ? 0 : desdeOffset;
210
+ const largo = st.size - inicio;
211
+ res.offset = st.size;
212
+ if (largo <= 0) return res;
213
+
214
+ // Techo de lectura: un transcript enorme no puede hacernos pasar el
215
+ // presupuesto de 1,5 s del hook Stop.
216
+ const MAX = 8 * 1024 * 1024;
217
+ const desde = largo > MAX ? st.size - MAX : inicio;
218
+ const buf = Buffer.alloc(st.size - desde);
219
+ const fd = openSync(rutaTranscript, 'r');
220
+ try { readSync(fd, buf, 0, buf.length, desde); } finally { closeSync(fd); }
221
+ bruto = buf.toString('utf8');
222
+ } catch {
223
+ return res;
224
+ }
225
+
226
+ const pending = [];
227
+ for (const linea of bruto.split('\n')) {
228
+ if (!linea.trim()) continue;
229
+ let ev;
230
+ try { ev = JSON.parse(linea); } catch { continue; }
231
+ extract(ev, res, config, pending);
232
+ }
233
+
234
+ // Solo los últimos, para no inflar el relevo.
235
+ res.commands = res.commands.slice(-12);
236
+ res.filesRead = [...new Set(res.filesRead)].slice(-15);
237
+ return res;
238
+ }
239
+
240
+ /**
241
+ * Extrae de un event del transcript. Defensivo a propósito: cada CLI tiene
242
+ * su propia forma y ninguno prometió mantenerla entre versiones. Ante un
243
+ * formato desconocido se ignora el event en lugar de fallar.
244
+ */
245
+ function extract(ev, res, config, pending) {
246
+ // Antigravity: pasos con step_index, type y content en texto plano.
247
+ if (ev.step_index !== undefined || ev.stepIndex !== undefined) {
248
+ return extractStep(ev, res, config, pending);
249
+ }
250
+ // Codex: wrapper {timestamp, ordinal, type, payload}.
251
+ if (ev.payload !== undefined && ev.ordinal !== undefined) {
252
+ return extractRollout(ev, res, config);
253
+ }
254
+
255
+ const kind = ev.type ?? ev.role ?? ev.kind;
256
+ if (kind === 'assistant' || kind === 'user') res.turns++;
257
+
258
+ const bloques = [];
259
+ const msg = ev.message ?? ev;
260
+ if (Array.isArray(msg?.content)) bloques.push(...msg.content);
261
+ else if (Array.isArray(ev.content)) bloques.push(...ev.content);
262
+
263
+ for (const b of bloques) {
264
+ if (!b || typeof b !== 'object') continue;
265
+
266
+ if (b.type === 'tool_use' && b.name) {
267
+ const inp = b.input ?? {};
268
+ if (/^bash$|^shell$|^run/i.test(b.name) && inp.command) {
269
+ const { texto } = redact(meaningfulLine(String(inp.command)), config);
270
+ res.commands.push({ cmd: texto, id: b.id, exit: null });
271
+ } else if (/^read$|^view$/i.test(b.name) && (inp.file_path ?? inp.path)) {
272
+ const r = String(inp.file_path ?? inp.path);
273
+ if (!isExcludedPath(r, config)) res.filesRead.push(r);
274
+ }
275
+ }
276
+
277
+ // Resultado del comando: se busca el exit code, nunca el stdout.
278
+ if (b.type === 'tool_result') {
279
+ const c = res.commands.find((x) => x.id === b.tool_use_id);
280
+ if (c) c.exit = b.is_error ? 1 : exitFrom(b) ?? 0;
281
+ }
282
+
283
+ if (b.type === 'text' && typeof b.text === 'string' && kind === 'assistant') {
284
+ res.lastMessage = b.text;
285
+ }
286
+ }
287
+
288
+ if (typeof ev.last_assistant_message === 'string') res.lastMessage = ev.last_assistant_message;
289
+ }
290
+
291
+ /**
292
+ * Un paso del transcript de Antigravity.
293
+ *
294
+ * Formato verificado contra sesiones reales: cada línea es un paso con
295
+ * step_index, source, type, status, created_at y content. En el formato actual
296
+ * las llamadas a herramientas viajan en `tool_calls` de `PLANNER_RESPONSE` y
297
+ * sus salidas llegan en los pasos `GENERIC` siguientes, en el mismo orden.
298
+ * También se conserva el formato anterior de pasos `RUN_COMMAND` / `VIEW_FILE`.
299
+ */
300
+ function extractStep(ev, res, config, pending = []) {
301
+ const kind = String(ev.type ?? '').toUpperCase();
302
+ const contenido = typeof ev.content === 'string' ? ev.content : '';
303
+ const stepIndex = ev.step_index ?? ev.stepIndex ?? null;
304
+
305
+ // Cada turno del usuario empieza en USER_INPUT; dentro de un mismo turno hay
306
+ // un PLANNER_RESPONSE por cada ronda de herramientas más la respuesta final,
307
+ // así que sumarlos infla el contador y adelanta la petición de narrativa.
308
+ if (kind === 'USER_INPUT') {
309
+ res.turns++;
310
+ pending.length = 0;
311
+ }
312
+
313
+ if (kind === 'PLANNER_RESPONSE') {
314
+ pending.length = 0;
315
+ if (contenido) res.lastMessage = contenido;
316
+ const calls = ev.tool_calls ?? ev.toolCalls;
317
+ if (Array.isArray(calls)) {
318
+ calls.forEach((call, index) => {
319
+ const name = String(call?.name ?? '');
320
+ const args = stepToolArgs(call);
321
+ if (/^(run_command|bash|shell|exec_command)$/i.test(name)) {
322
+ const rawCmd = unwrapJsonString(args.CommandLine ?? args.command ?? args.cmd ?? '');
323
+ const { texto } = redact(meaningfulLine(rawCmd), config);
324
+ if (texto) {
325
+ const id = call?.id ?? (stepIndex !== null ? `${stepIndex}:${index}` : null);
326
+ const command = { cmd: texto, id, exit: null };
327
+ res.commands.push(command);
328
+ pending.push({ kind: 'command', command });
329
+ return;
330
+ }
331
+ } else if (/^(read_file|view_file|read|view)$/i.test(name)) {
332
+ const rawPath = unwrapJsonString(
333
+ args.Path ?? args.AbsolutePath ?? args.file_path ?? args.path ?? args.file ?? '',
334
+ ).trim();
335
+ if (rawPath && !isExcludedPath(rawPath, config)) res.filesRead.push(rawPath);
336
+ }
337
+ pending.push({ kind: 'other' });
338
+ });
339
+ }
340
+ return;
341
+ }
342
+
343
+ if (kind === 'GENERIC') {
344
+ const next = pending.shift() ?? null;
345
+ if (next) {
346
+ if (next.kind !== 'command') return;
347
+ const exit = stepCommandExit(ev, contenido);
348
+ if (exit !== null) next.command.exit = exit;
349
+ return;
350
+ }
351
+ // Si la lectura incremental arrancó después del PLANNER_RESPONSE, solo una
352
+ // cabecera explícita de comando confirma el código de salida.
353
+ const exit = stepHeaderExit(contenido);
354
+ const unresolved = exit !== null ? res.commands.find((c) => c.exit === null) : null;
355
+ if (unresolved) unresolved.exit = exit;
356
+ return;
357
+ }
358
+
359
+ if (/RUN_COMMAND|COMMAND|TERMINAL/.test(kind)) {
360
+ // El comando aparece como CommandLine en el contenido, o como primera
361
+ // línea útil si viene en texto libre.
362
+ const m = /"?CommandLine"?\s*[:=]\s*"?([^"\n]+)/i.exec(contenido);
363
+ const bruto = m ? m[1] : contenido;
364
+ const { texto } = redact(meaningfulLine(bruto), config);
365
+ if (texto) {
366
+ const fallo = /FAILED|ERROR/.test(String(ev.status ?? '')) || /exit (?:code )?[1-9]/i.test(contenido);
367
+ res.commands.push({ cmd: texto, id: stepIndex, exit: fallo ? 1 : 0 });
368
+ }
369
+ }
370
+
371
+ if (/VIEW_FILE|READ_FILE/.test(kind)) {
372
+ const m = /"?(?:AbsolutePath|path|file)"?\s*[:=]\s*"?([^"\n]+)/i.exec(contenido);
373
+ if (m && !isExcludedPath(m[1], config)) res.filesRead.push(m[1].trim());
374
+ }
375
+ }
376
+
377
+ function stepToolArgs(call) {
378
+ let args = call?.args ?? call?.arguments ?? call?.input;
379
+ if (typeof args === 'string') {
380
+ try { args = JSON.parse(args); } catch { return {}; }
381
+ }
382
+ return (args && typeof args === 'object') ? args : {};
383
+ }
384
+
385
+ function unwrapJsonString(value) {
386
+ if (typeof value !== 'string') return '';
387
+ const trimmed = value.trim();
388
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
389
+ try {
390
+ const parsed = JSON.parse(trimmed);
391
+ if (typeof parsed === 'string') return parsed;
392
+ } catch {}
393
+ }
394
+ return value;
395
+ }
396
+
397
+ function stepHeaderExit(contenido) {
398
+ // Solo la cabecera previa a "Command output:" pertenece al runner; el stdout
399
+ // del proceso puede contener líneas con el mismo texto.
400
+ const header = contenido.split(/\r?\n\r?\nCommand output:\r?\n/)[0];
401
+ const match = /^Command exited with code (-?\d+)\.\s*$/m.exec(header);
402
+ return match ? Number(match[1]) : null;
403
+ }
404
+
405
+ function stepCommandExit(ev, contenido) {
406
+ const exit = stepHeaderExit(contenido);
407
+ if (exit !== null) return exit;
408
+ const header = contenido.split(/\r?\n\r?\nCommand output:\r?\n/)[0];
409
+ if (/^(FAILED|ERROR)$/i.test(String(ev.status ?? ''))) return 1;
410
+ if (/^Encountered error in (?:step|tool) execution:/m.test(header)) return 1;
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * Un registro del rollout de Codex.
416
+ *
417
+ * Formato verificado contra una sesión real: cada línea envuelve un payload
418
+ * en {timestamp, ordinal, type, payload}. Los tipos que informan son
419
+ * `response_item` (mensajes y llamadas a herramientas) y `event_msg`
420
+ * (task_started, item_completed, task_complete).
421
+ */
422
+ function extractRollout(ev, res, config) {
423
+ const p = ev.payload ?? {};
424
+ const kind = String(p.type ?? '');
425
+
426
+ if (ev.type === 'event_msg' && kind === 'task_started') res.turns++;
427
+
428
+ if (ev.type === 'event_msg' && kind === 'task_complete' && p.last_agent_message) {
429
+ res.lastMessage = String(p.last_agent_message);
430
+ }
431
+
432
+ if (ev.type === 'response_item' && kind === 'message') {
433
+ // Un turno puede incluir usuario, comentarios y respuesta final. Su inicio
434
+ // ya se contó con task_started; sumarlos adelanta la petición de narrativa.
435
+ if (p.role === 'assistant') {
436
+ const txt = textFrom(p.content);
437
+ if (txt) res.lastMessage = txt;
438
+ }
439
+ }
440
+
441
+ if (kind === 'function_call_output') {
442
+ const command = res.commands.find((c) => c.id === p.call_id);
443
+ const exit = rolloutResultExit(p.output);
444
+ if (command && exit !== null) command.exit = exit;
445
+ return;
446
+ }
447
+
448
+ // Code mode usa una herramienta llamada exec que ejecuta JavaScript, no un
449
+ // comando de shell. Sus procesos reales llegan como CommandExecution.
450
+ const item = kind === 'item_completed' ? (p.item ?? {}) : p;
451
+ const name = String(item.name ?? item.type ?? '');
452
+ const execution = /^(CommandExecution|command_execution|local_shell_call)$/i.test(item.type ?? '');
453
+ if (!execution && !/(?:^|\.)(exec_command|shell_command|shell|bash|run_command)$/i.test(name)) return;
454
+
455
+ const line = rolloutCommand(item);
456
+ if (!line) return;
457
+ const { texto } = redact(meaningfulLine(line), config);
458
+ if (!texto) return;
459
+ const id = item.call_id ?? item.id ?? ev.ordinal;
460
+ const exit = Number.isInteger(item.exit_code) ? item.exit_code
461
+ : execution && !('exit_code' in item)
462
+ ? (/error|fail/i.test(item.status ?? '') ? 1 : item.status === 'completed' ? 0 : null)
463
+ : null;
464
+ const previous = res.commands.find((c) => c.id === id);
465
+ if (previous) {
466
+ previous.cmd = texto;
467
+ if (exit !== null) previous.exit = exit;
468
+ } else {
469
+ // Una llamada generada todavía no es un resultado: sin confirmación,
470
+ // queda desconocido. Antes se escribía exit 0 por defecto.
471
+ res.commands.push({ cmd: texto, id, exit });
472
+ }
473
+ }
474
+
475
+ function rolloutCommand(item) {
476
+ let args = item.arguments ?? item.input;
477
+ if (typeof args === 'string') {
478
+ try { args = JSON.parse(args); } catch { return null; }
479
+ }
480
+ const command = args?.cmd ?? args?.command ?? item.command;
481
+ if (typeof command === 'string') return command;
482
+ if (!Array.isArray(command) || !command.every((arg) => typeof arg === 'string')) return null;
483
+
484
+ // Windows registra el intérprete y sus flags como argv. El script es lo
485
+ // que informa; serializar todo el array oculta el comando entre escapes.
486
+ const executable = (command[0] ?? '').split(/[\\/]/).pop();
487
+ const flag = /^(powershell|pwsh)(\.exe)?$/i.test(executable) ? /^-(command|c)$/i
488
+ : /^cmd(\.exe)?$/i.test(executable) ? /^\/c$/i
489
+ : /^(ba|da|z|k)?sh(\.exe)?$/i.test(executable) ? /^-[a-z]*c[a-z]*$/i : null;
490
+ const at = flag ? command.findIndex((arg, i) => i > 0 && flag.test(arg)) : -1;
491
+ return (at >= 0 ? command.slice(at + 1) : command).join(' ');
492
+ }
493
+
494
+ function rolloutResultExit(output) {
495
+ let result = output;
496
+ if (typeof output === 'string') {
497
+ try { result = JSON.parse(output); } catch {}
498
+ }
499
+ if (result && typeof result === 'object') {
500
+ return Number.isInteger(result.exit_code) ? result.exit_code : null;
501
+ }
502
+ if (typeof output !== 'string') return null;
503
+ // El stdout puede contener ejemplos de códigos de salida. Solo la cabecera
504
+ // generada por la herramienta confirma cómo terminó el proceso.
505
+ const header = output.split(/^Output:\s*$/m)[0];
506
+ const match = /^Process exited with code (-?\d+)\s*$/m.exec(header);
507
+ return match ? Number(match[1]) : null;
508
+ }
509
+
510
+ function textFrom(contenido) {
511
+ if (typeof contenido === 'string') return contenido;
512
+ if (!Array.isArray(contenido)) return null;
513
+ for (const b of contenido) {
514
+ if (typeof b === 'string') return b;
515
+ if (b && typeof b.text === 'string') return b.text;
516
+ }
517
+ return null;
518
+ }
519
+
520
+ function exitFrom(b) {
521
+ const txt = typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? '');
522
+ const m = /exit (?:code|status)[: ]+(\d+)/i.exec(txt);
523
+ return m ? parseInt(m[1], 10) : null;
524
+ }
525
+
526
+ /**
527
+ * De un comando de varias líneas, la que dice qué se hizo.
528
+ * Tomar la primera línea a secas dejaba el relevo lleno de `cd ...` y
529
+ * `set -e`, que no informan nada de lo que pasó en la sesión.
530
+ */
531
+ function meaningfulLine(comando) {
532
+ const RUIDO = /^\s*(cd\s|set\s+-|export\s|source\s|\.\s|#|echo\s+"?===|if\s|fi$|then$|else$|done$|do$)/i;
533
+ const lineas = comando.split('\n').map((l) => l.trim()).filter(Boolean);
534
+ const util = lineas.find((l) => !RUIDO.test(l));
535
+ let elegida = util ?? lineas[0] ?? '';
536
+ // `cd <ruta> && comando` es el patrón más común de todos: lo que informa
537
+ // es el comando, no el directorio.
538
+ elegida = elegida.replace(/^cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*/i, '');
539
+ return elegida.slice(0, 160);
540
+ }
541
+
542
+ /**
543
+ * El archivo de destino, at git informa un renombre.
544
+ *
545
+ * `--numstat` usa `viejo.txt => nuevo.txt`, y con prefijo común
546
+ * `src/{viejo.txt => nuevo.txt}`. Sin desarmarlo, el relevo mostraba el
547
+ * renombre como un archivo llamado "viejo.txt => nuevo.txt" **y además**
548
+ * el destino por separado: dos entradas para un solo archivo, una con un
549
+ * name que no existe.
550
+ */
551
+ function renameTarget(bruto) {
552
+ if (!bruto) return null;
553
+ const s = bruto.trim();
554
+ if (!s.includes('=>')) return s;
555
+
556
+ // Forma con llaves: prefijo{antes => después}sufijo
557
+ const conLlaves = /^(.*)\{(.*?) => (.*?)\}(.*)$/.exec(s);
558
+ if (conLlaves) {
559
+ const [, pre, , despues, post] = conLlaves;
560
+ return (pre + despues + post).replace(/\/{2,}/g, '/');
561
+ }
562
+ // Forma simple: antes => después
563
+ return s.split('=>').pop().trim();
564
+ }
565
+
566
+ function num(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }