changebook 0.7.0 → 0.8.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,1016 @@
1
+ /**
2
+ * `friction.ts` — de dónde sale el mapa de fricción.
3
+ *
4
+ * Lee las transcripciones de Claude Code y saca de ellas ediciones ordenadas,
5
+ * clasifica cada reedición como corrección o continuación, y apila eventos SIN
6
+ * PROSA para que el drenado los suba.
7
+ *
8
+ * Vive aparte de `impact.ts` a propósito: aquél ya son 1.780 líneas.
9
+ *
10
+ * Todo lo de este fichero es LOCAL. La prosa que entra aquí no sale de la
11
+ * máquina; lo que se sube se construye en `eventoDeFriccion`, que es el único
12
+ * sitio donde se decide qué cruza la frontera.
13
+ *
14
+ * Diseño: docs/superpowers/specs/2026-08-23-mapa-de-friccion-design.md
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { gitPath } from './git.js';
20
+ const TIPOS = new Set([
21
+ 'Edit',
22
+ 'Write',
23
+ 'MultiEdit',
24
+ 'NotebookEdit',
25
+ ]);
26
+ /**
27
+ * ¿Esta línea `user` es un turno de verdad?
28
+ *
29
+ * Las líneas `user` que sólo llevan `tool_result` son el eco de una tool, no
30
+ * algo que dijera el humano. Contarlas rompería el discriminador entero: toda
31
+ * reedición caería en un "turno" distinto y todo parecería corrección.
32
+ */
33
+ /** El texto de un turno de usuario, o `null` si esa línea no es un turno. */
34
+ function textoDelTurno(mensaje) {
35
+ const contenido = mensaje?.content;
36
+ if (typeof contenido === 'string')
37
+ return contenido.trim() ? contenido : null;
38
+ if (!Array.isArray(contenido))
39
+ return null;
40
+ const texto = contenido
41
+ .filter((b) => b?.type === 'text')
42
+ .map((b) => String(b.text ?? ''))
43
+ .join(' ');
44
+ return texto.trim() ? texto : null;
45
+ }
46
+ /**
47
+ * Turnos que sólo dan permiso para seguir. NO PUEDEN SER UNA CORRECCIÓN.
48
+ *
49
+ * MEDIDO el 24/08 etiquetando a mano 20 reediciones que el discriminador de
50
+ * región llamaba corrección: **15 seguían a un turno así** («hazlo», «ok»,
51
+ * «sigue») y 3 a boilerplate de herramienta. Sólo 2 seguían a un turno con
52
+ * contenido. La puntería era del ~10%.
53
+ *
54
+ * El mecanismo: el agente construye un fichero A TROZOS y, para añadir lo
55
+ * siguiente, ancla su `old_string` en lo último que escribió. Eso solapa, y el
56
+ * solape se leía como «reescribe lo que acababa de escribir». Es el mismo fallo
57
+ * que el de los ficheros de apilar, que creí resuelto — pero para código.
58
+ */
59
+ const TURNO_DE_APROBACION = /^\s*(hazlo|haz|ok|okay|vale|sigue|seguimos|continua|continúa|continuamos|dale|perfecto|hecho|listo|si|sí|adelante|bien|genial|gracias|hazlo por m[ií]|\d+)([\s,.!]+(hazlo|todo|ya|por favor|porfa|gracias|continuamos|seguimos|adelante))*\s*[.!,]?\s*$/i;
60
+ /**
61
+ * Lo que NO lo escribió una persona para este turno.
62
+ *
63
+ * MEDIDO el 24/08: de las 23 «correcciones» que sobrevivían a la regla del turno
64
+ * de aprobación, **6 seguían a un pegote de éstos** —`<system-reminder>`, tres
65
+ * `<task-notification>`, el aviso de un hook de sesión y un texto de UI pegado—.
66
+ * Ninguno es alguien pidiendo un arreglo, así que ninguno puede ser el turno que
67
+ * provocó una corrección.
68
+ *
69
+ * Se casa por el PRINCIPIO del turno y con las cadenas exactas del corpus: un
70
+ * `includes` sobre «task» cazaría cualquier frase que hable de tareas.
71
+ */
72
+ const TURNO_DE_HERRAMIENTA = new RegExp([
73
+ '^<[a-z-]+>', // <system-reminder>, <task-notification>, <command-name>…
74
+ '^\\[(Request|Image)', // interrupciones y adjuntos de imagen
75
+ '^@"', // fichero adjuntado
76
+ '^Base directory for this skill',
77
+ '^A session-scoped Stop hook', // el aviso de /goal
78
+ '^Connected \\S+@', // el panel de la app, pegado
79
+ '^\\{"hookSpecificOutput"', // salida cruda de un hook
80
+ ].join('|'), 'i');
81
+ /**
82
+ * ¿Este turno puede haber pedido una corrección?
83
+ *
84
+ * Exportada para que el corpus la pruebe por su cuenta: es la mitad del
85
+ * discriminador que más falsos positivos quita.
86
+ */
87
+ export function turnoPuedeCorregir(texto) {
88
+ const t = (texto ?? '').trim();
89
+ if (!t)
90
+ return false;
91
+ return !TURNO_DE_APROBACION.test(t) && !TURNO_DE_HERRAMIENTA.test(t);
92
+ }
93
+ function edicionesDelBloque(bloque, sesion, turno, fecha, turnoTexto) {
94
+ const nombre = String(bloque.name ?? '');
95
+ if (!TIPOS.has(nombre))
96
+ return [];
97
+ const tipo = nombre;
98
+ const input = (bloque.input ?? {});
99
+ const ruta = String(input.file_path ?? input.notebook_path ?? '').trim();
100
+ if (!ruta)
101
+ return [];
102
+ const base = { sesion, turno, ruta, tipo, fecha, turnoTexto };
103
+ if (tipo === 'MultiEdit') {
104
+ const edits = Array.isArray(input.edits) ? input.edits : [];
105
+ return edits
106
+ .map((e) => e)
107
+ .filter((e) => typeof e?.new_string === 'string')
108
+ .map((e) => ({
109
+ ...base,
110
+ viejo: typeof e.old_string === 'string' ? e.old_string : null,
111
+ nuevo: String(e.new_string),
112
+ }));
113
+ }
114
+ if (tipo === 'Write') {
115
+ return typeof input.content === 'string'
116
+ ? [{ ...base, viejo: null, nuevo: input.content }]
117
+ : [];
118
+ }
119
+ const nuevo = input.new_string ?? input.new_source;
120
+ if (typeof nuevo !== 'string')
121
+ return [];
122
+ const viejo = input.old_string ?? input.old_source;
123
+ return [{ ...base, viejo: typeof viejo === 'string' ? viejo : null, nuevo }];
124
+ }
125
+ /**
126
+ * El texto entero de un `.jsonl` a ediciones ordenadas.
127
+ *
128
+ * Toma texto y no una ruta para que el corpus de pruebas se escriba en el propio
129
+ * test: un detector que sólo se puede probar montando ficheros de verdad acaba
130
+ * sin corpus.
131
+ */
132
+ export function edicionesDeTranscripcion(texto) {
133
+ const ediciones = [];
134
+ let turnosDeUsuario = 0;
135
+ let lineasIlegibles = 0;
136
+ let turnoEnCurso = '';
137
+ for (const linea of texto.split('\n')) {
138
+ if (!linea.trim())
139
+ continue;
140
+ let obj;
141
+ try {
142
+ obj = JSON.parse(linea);
143
+ }
144
+ catch {
145
+ lineasIlegibles += 1;
146
+ continue;
147
+ }
148
+ if (obj.type === 'user') {
149
+ const texto = textoDelTurno(obj.message);
150
+ if (texto !== null) {
151
+ turnosDeUsuario += 1;
152
+ // Recortado: sólo hace falta para decidir si pudo pedir una corrección.
153
+ turnoEnCurso = texto.replace(/\s+/g, ' ').slice(0, 300);
154
+ }
155
+ continue;
156
+ }
157
+ if (obj.type !== 'assistant')
158
+ continue;
159
+ const contenido = obj.message
160
+ ?.content;
161
+ if (!Array.isArray(contenido))
162
+ continue;
163
+ const sesion = String(obj.sessionId ?? '');
164
+ const fecha = String(obj.timestamp ?? '');
165
+ for (const bloque of contenido) {
166
+ if (bloque?.type !== 'tool_use')
167
+ continue;
168
+ ediciones.push(...edicionesDelBloque(bloque, sesion, turnosDeUsuario, fecha, turnoEnCurso));
169
+ }
170
+ }
171
+ return { ediciones, turnosDeUsuario, lineasIlegibles };
172
+ }
173
+ // ── El discriminador de región ───────────────────────────────────────────────
174
+ //
175
+ // La pregunta que contesta: cuando el agente vuelve a editar un fichero después
176
+ // de que tú hables, ¿está CORRIGIENDO lo que acababa de escribir, o SIGUIENDO
177
+ // con otra cosa? Sin esa distinción, la tasa mide "cuánto se trabaja en este
178
+ // fichero" y no "cuánto sale mal".
179
+ /** Bajo esto, una línea casa entre dos textos cualesquiera. */
180
+ const MIN_CARACTERES_SIGNIFICATIVOS = 8;
181
+ /** Sólo puntuación, cierres o espacios: no identifica nada. */
182
+ const SOLO_PUNTUACION = /^[\s{}()[\];,.:<>/*+\-=|&!?'"`]*$/;
183
+ /**
184
+ * Las líneas de un texto que sirven para decir "esto es lo mismo que aquello".
185
+ *
186
+ * El filtro NO es cosmético: sin él, dos fragmentos cualesquiera de TypeScript
187
+ * comparten `}` y ` );`, el solape sale siempre cierto y el discriminador se
188
+ * convierte en un "sí" constante que nadie nota porque parece que funciona.
189
+ */
190
+ export function lineasSignificativas(texto) {
191
+ const out = new Set();
192
+ for (const cruda of texto.split('\n')) {
193
+ const linea = cruda.trim();
194
+ if (linea.length < MIN_CARACTERES_SIGNIFICATIVOS)
195
+ continue;
196
+ if (SOLO_PUNTUACION.test(linea))
197
+ continue;
198
+ out.add(linea);
199
+ }
200
+ return out;
201
+ }
202
+ /** ¿Comparten al menos una línea significativa? */
203
+ export function solapan(a, b) {
204
+ const deA = lineasSignificativas(a);
205
+ if (deA.size === 0)
206
+ return false;
207
+ for (const linea of lineasSignificativas(b)) {
208
+ if (deA.has(linea))
209
+ return true;
210
+ }
211
+ return false;
212
+ }
213
+ /**
214
+ * ¿La reedición corrige lo que se acababa de escribir, o sigue con otra cosa?
215
+ *
216
+ * Las dos ediciones tienen que ser del mismo fichero; quien llama lo garantiza
217
+ * agrupando por ruta.
218
+ */
219
+ export function clasificarReedicion(previa, actual) {
220
+ // Un Write no tiene texto previo que comparar y reescribe el fichero entero:
221
+ // por definición se lleva por delante lo que la edición anterior escribió.
222
+ if (actual.viejo === null)
223
+ return 'correccion';
224
+ return solapan(previa.nuevo, actual.viejo) ? 'correccion' : 'continuacion';
225
+ }
226
+ /**
227
+ * El veredicto de una edición cualquiera, incluida la primera.
228
+ *
229
+ * `previa` es la última edición del MISMO fichero en la MISMA sesión, o `null`
230
+ * si no la hubo. Las reediciones dentro del mismo turno son el agente iterando
231
+ * solo —1.223 en el corpus del 23/08— y por eso tienen etiqueta propia: cuentan
232
+ * en el denominador pero no son fricción tuya.
233
+ */
234
+ export function clasificarEdicion(previa, actual) {
235
+ if (!previa)
236
+ return 'primera';
237
+ if (actual.turno <= previa.turno)
238
+ return 'iteracion';
239
+ // Un turno que sólo dice «hazlo» no corrige nada: aprueba. Sin esta guarda el
240
+ // discriminador tenía ~10% de puntería —15 de 20 «correcciones» etiquetadas a
241
+ // mano seguían a una aprobación— porque el agente ancla cada trozo nuevo en lo
242
+ // último que escribió y eso solapa.
243
+ if (!turnoPuedeCorregir(actual.turnoTexto))
244
+ return 'continuacion';
245
+ return clasificarReedicion(previa, actual);
246
+ }
247
+ /**
248
+ * ¿Esta ruta pertenece al repo?
249
+ *
250
+ * El mapa de fricción es DEL REPOSITORIO. En el corpus del 23/08, **481 de las
251
+ * 2.668 ediciones (18%) caían fuera**: ficheros de memoria del agente en
252
+ * `~/.claude/projects/.../memory/`, borradores en directorios temporales. Sin
253
+ * este filtro coronaban el ranking —`MEMORY.md` al 21%— y además su ruta
254
+ * relativa saldría como `../../...`, que no es un dato válido para subir.
255
+ *
256
+ * Es un criterio estructural y no una lista de nombres a propósito: una lista
257
+ * negra siempre se queda corta contra el fichero que nadie previó.
258
+ */
259
+ export function dentroDelRepo(ruta, raiz) {
260
+ const rel = path.relative(raiz, ruta);
261
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
262
+ }
263
+ // ── La tasa ──────────────────────────────────────────────────────────────────
264
+ /** Por debajo de esto, un porcentaje es ruido con pinta de dato. */
265
+ export const SUELO_DE_EXPOSICION = 15;
266
+ /**
267
+ * Correcciones mínimas para decir algo. Una es un suceso, no un patrón.
268
+ *
269
+ * Medido el 24/08 tras quitar los falsos positivos del turno de aprobación:
270
+ * quedan 23 correcciones en TODO el corpus, casi todas sueltas. Publicar por una
271
+ * sola llenaría el contexto de avisos que no distinguen accidente de costumbre.
272
+ */
273
+ export const MIN_CORRECCIONES_PARA_HABLAR = 2;
274
+ /**
275
+ * Las correcciones, UNA A UNA — el encadenado por (sesión, ruta), en un solo
276
+ * sitio.
277
+ *
278
+ * Existe porque hay dos vistas del mismo hecho: la TASA (cuántas sobre cuántas,
279
+ * para el aviso que se sirve antes de cada edición) y la LISTA (cuáles y
280
+ * cuándo, para el brief y para `changebook friction`). Medido el 24/08, la
281
+ * lista es la única que dice algo: 8 correcciones en 30 días no llegan a
282
+ * ninguna tasa publicable, y sin embargo son 8 hechos concretos con su fecha.
283
+ *
284
+ * Sólo cuenta la reedición que CRUZA un turno de usuario. Las del mismo turno
285
+ * son el agente iterando solo —1.223 de ellas en el corpus del 23/08, contra
286
+ * 473 que cruzan turno— y medirían su cabezonería, no tu corrección.
287
+ *
288
+ * El encadenado es por (sesión, ruta) y no por ruta a secas: dos sesiones a la
289
+ * vez sobre el mismo fichero producirían "reediciones" cruzadas que son un
290
+ * artefacto del orden en que se leyeron los ficheros, no un hecho.
291
+ */
292
+ export function correccionesDeEdiciones(ediciones) {
293
+ const ultima = new Map();
294
+ const out = [];
295
+ for (const e of ediciones) {
296
+ const clave = `${e.sesion} ${e.ruta}`;
297
+ const previa = ultima.get(clave);
298
+ // `clasificarEdicion` y no `clasificarReedicion`: es el MISMO clasificador
299
+ // que usa `procesarFriccion`. Con dos, el ranking del CLI y lo que se sube
300
+ // dirían cosas distintas — ya pasó con el módulo, y van tres veces.
301
+ if (previa &&
302
+ e.turno > previa.turno &&
303
+ clasificarEdicion(previa, e) === 'correccion') {
304
+ out.push(e);
305
+ }
306
+ ultima.set(clave, e);
307
+ }
308
+ return out;
309
+ }
310
+ /**
311
+ * De correcciones a filas de fricción, por fichero: la vista de TASA.
312
+ *
313
+ * El encadenado no se repite aquí — lo hace `correccionesDeEdiciones`, y este
314
+ * cuenta lo que aquélla marcó.
315
+ */
316
+ export function filasDeFriccion(ediciones, opts = {}) {
317
+ const suelo = opts.sueloDeExposicion ?? SUELO_DE_EXPOSICION;
318
+ const acc = new Map();
319
+ // El MISMO encadenado que alimenta la lista de sucesos, por identidad de
320
+ // objeto. Contar aquí por separado sería la cuarta copia de la regla, y la
321
+ // tercera ya discrepó.
322
+ const correcciones = new Set(correccionesDeEdiciones(ediciones));
323
+ for (const e of ediciones) {
324
+ const fila = acc.get(e.ruta) ?? {
325
+ ediciones: 0,
326
+ correcciones: 0,
327
+ sesiones: new Set(),
328
+ conCorreccion: new Set(),
329
+ };
330
+ fila.ediciones += 1;
331
+ fila.sesiones.add(e.sesion);
332
+ if (correcciones.has(e)) {
333
+ fila.correcciones += 1;
334
+ fila.conCorreccion.add(e.sesion);
335
+ }
336
+ acc.set(e.ruta, fila);
337
+ }
338
+ return [...acc.entries()]
339
+ .map(([ruta, v]) => ({
340
+ ruta,
341
+ ediciones: v.ediciones,
342
+ correcciones: v.correcciones,
343
+ sesiones: v.sesiones.size,
344
+ sesionesConCorreccion: v.conCorreccion.size,
345
+ // El suelo se compara con las ediciones y no con las correcciones: lo que
346
+ // hace ruidoso un porcentaje es un denominador pequeño.
347
+ tasa: v.ediciones >= suelo ? v.correcciones / v.ediciones : null,
348
+ }))
349
+ .sort((a, b) => (b.tasa ?? -1) - (a.tasa ?? -1));
350
+ }
351
+ /**
352
+ * El ÚNICO sitio donde se decide qué sale de la máquina.
353
+ *
354
+ * Se construye campo a campo desde una lista blanca, deliberadamente. Un
355
+ * `{...e}` traería `viejo` y `nuevo` —la prosa, que es lo que este producto
356
+ * promete no mover— y ningún test que compruebe los campos que espera lo vería.
357
+ * Por eso el contrato afirma la lista EXACTA de claves: es la única forma de que
358
+ * un campo de más ponga algo rojo.
359
+ */
360
+ export function eventoDeFriccion(e, veredicto, modulo, raiz) {
361
+ return {
362
+ ruta: path.relative(raiz, e.ruta) || e.ruta,
363
+ modulo,
364
+ veredicto,
365
+ fecha: e.fecha,
366
+ sesion_hash: createHash('sha256')
367
+ .update(e.sesion)
368
+ .digest('hex')
369
+ .slice(0, 16),
370
+ };
371
+ }
372
+ // El primer arranque emite el histórico ENTERO de la ventana: 2.187 ediciones
373
+ // dentro del repo en el corpus del 23/08, ~120 bytes cada una. Con el tope de
374
+ // 64 KB de la cola de impacto se recortaría a la mitad en la primera pasada y se
375
+ // perderían eventos justo cuando más hay. 2 MB caben ~16.000.
376
+ const COLA_MAX_BYTES = 2 * 1024 * 1024;
377
+ const COLA_KEEP_BYTES = 1024 * 1024;
378
+ export async function colaDeFriccion(dir) {
379
+ return gitPath(dir, 'changebook-friction-queue.jsonl').catch(() => null);
380
+ }
381
+ /**
382
+ * Apila un evento. JSONL y no JSON, por lo mismo que la cola de impacto: un
383
+ * append no lee lo que ya hay y dos procesos solapados no se pisan.
384
+ *
385
+ * Best-effort de principio a fin: registrar la fricción no puede tumbar el
386
+ * calentamiento que la provocó.
387
+ */
388
+ export function encolarFriccion(file, evento) {
389
+ if (!file)
390
+ return;
391
+ try {
392
+ try {
393
+ if (fs.statSync(file).size > COLA_MAX_BYTES) {
394
+ fs.writeFileSync(file, fs.readFileSync(file).subarray(-COLA_KEEP_BYTES));
395
+ }
396
+ }
397
+ catch {
398
+ // Aún no existe: nada que recortar.
399
+ }
400
+ fs.appendFileSync(file, `${JSON.stringify(evento)}\n`);
401
+ }
402
+ catch {
403
+ // Sin sitio donde anotar, el resto del warm sigue igual.
404
+ }
405
+ }
406
+ /**
407
+ * Órdenes de shell que casi seguro escriben en un fichero del repo.
408
+ *
409
+ * Deliberadamente conservador: prefiere no contar una escritura real a inflar el
410
+ * punto ciego con `grep` y `ls`. El número es una cota INFERIOR y se dice así.
411
+ */
412
+ const SHELL_QUE_ESCRIBE = /(^|[\s;&|])(sed\s+-i|tee\s|cat\s*>|python3?\s+-[cm]\b|>>?\s*\S+\.(?:ts|tsx|js|mjs|sql|md|json|yml|yaml))/;
413
+ /**
414
+ * Sin el corchete de cierre: la variante `for tool use` fue 3 de las 17 medidas
415
+ * el 23/08, y anclar a `]` se las come sin que nada se ponga rojo.
416
+ */
417
+ const INTERRUPCION = '[Request interrupted by user';
418
+ /**
419
+ * La frase ENTERA, no las palabras sueltas. `permission`, `denied` o `rejected`
420
+ * por separado casan con código fuente de este mismo repo, y con la denegación
421
+ * del clasificador de auto mode —25 en el corpus— que NO es un rechazo tuyo.
422
+ */
423
+ const RECHAZO = "The user doesn't want to proceed with this tool use";
424
+ /**
425
+ * Los actos inequívocos: 20 en las 31 sesiones medidas el 23/08.
426
+ *
427
+ * Escasos a propósito. Su valor no es medir fricción —para eso no llegan— sino
428
+ * poder contrastarlos con la tasa: si esto marca y la tasa no, el discriminador
429
+ * está roto. Es la mitad del Invariante 17 que permite distinguir «este repo no
430
+ * tiene fricción» de «el instrumento se murió».
431
+ *
432
+ * NUNCA se suman a la tasa: un escalar mezclado no se puede validar contra un
433
+ * corpus y esconde cuál de las dos mitades falló.
434
+ */
435
+ export function actosExplicitos(texto) {
436
+ let interrupciones = 0;
437
+ let rechazos = 0;
438
+ let edicionesPorShell = 0;
439
+ const rutasDeShell = new Set();
440
+ for (const linea of texto.split('\n')) {
441
+ if (!linea.trim())
442
+ continue;
443
+ let obj;
444
+ try {
445
+ obj = JSON.parse(linea);
446
+ }
447
+ catch {
448
+ continue; // Las ilegibles ya las cuenta `edicionesDeTranscripcion`.
449
+ }
450
+ const contenido = obj.message
451
+ ?.content;
452
+ if (!Array.isArray(contenido))
453
+ continue;
454
+ for (const b of contenido) {
455
+ const bloque = b;
456
+ if (bloque.type === 'text' &&
457
+ typeof bloque.text === 'string' &&
458
+ bloque.text.includes(INTERRUPCION)) {
459
+ interrupciones += 1;
460
+ }
461
+ if (bloque.type === 'tool_result') {
462
+ const txt = typeof bloque.content === 'string'
463
+ ? bloque.content
464
+ : JSON.stringify(bloque.content ?? '');
465
+ if (txt.includes(RECHAZO))
466
+ rechazos += 1;
467
+ }
468
+ const conTool = bloque;
469
+ if (bloque.type === 'tool_use' &&
470
+ conTool.name === 'Bash' &&
471
+ typeof conTool.input?.command === 'string' &&
472
+ SHELL_QUE_ESCRIBE.test(conTool.input.command)) {
473
+ edicionesPorShell += 1;
474
+ for (const r of rutasEscritasPorShell(conTool.input.command))
475
+ rutasDeShell.add(r);
476
+ }
477
+ }
478
+ }
479
+ return {
480
+ interrupciones,
481
+ rechazos,
482
+ edicionesPorShell,
483
+ rutasDeShell: [...rutasDeShell],
484
+ };
485
+ }
486
+ // ── La pasada: marca de agua, ventana y vitalidad ────────────────────────────
487
+ export const VENTANA_DIAS = 30;
488
+ /** Menos turnos que esto y no cabe el patrón «editó, hablaste, reeditó». */
489
+ export const MIN_TURNOS_DE_USUARIO = 3;
490
+ /**
491
+ * La marca del primer arranque.
492
+ *
493
+ * Los desplazamientos arrancan VACÍOS, o sea en cero, y NO al final de cada
494
+ * fichero. Poner la marca al final tiraría toda la historia previa —321 MB en
495
+ * este repo— y el mapa nacería vacío el día de la instalación. Lo viejo lo
496
+ * descarta la ventana, por FECHA, no por posición; así el histórico recorre el
497
+ * mismo camino que lo vivo y no hay una segunda implementación que pueda
498
+ * divergir (Invariante 15).
499
+ */
500
+ export function marcaInicial(ahoraMs, _ventanaDias = VENTANA_DIAS) {
501
+ return {
502
+ porFichero: {},
503
+ ultimaLecturaMs: ahoraMs,
504
+ bytesLeidos: 0,
505
+ sesionesVistas: 0,
506
+ eventos: 0,
507
+ lineasIlegibles: 0,
508
+ bordesDeLectura: 0,
509
+ sesionesFinas: 0,
510
+ fueraDelRepo: 0,
511
+ interrupciones: 0,
512
+ rechazos: 0,
513
+ edicionesPorShell: 0,
514
+ ultimoErrorDeSubida: null,
515
+ };
516
+ }
517
+ export async function marcaPath(dir) {
518
+ return gitPath(dir, 'changebook-friction-state.json').catch(() => null);
519
+ }
520
+ function leerMarca(file, ahoraMs) {
521
+ if (!file)
522
+ return marcaInicial(ahoraMs);
523
+ try {
524
+ const previa = JSON.parse(fs.readFileSync(file, 'utf8'));
525
+ // El formato viejo guardaba un número suelto por fichero. Se migra al leer
526
+ // en vez de tirarlo: tirarlo releería 274 MB y duplicaría todos los eventos.
527
+ const porFichero = {};
528
+ for (const [k, v] of Object.entries(previa.porFichero ?? {})) {
529
+ porFichero[k] = typeof v === 'number' ? { bytes: v, turnos: 0 } : v;
530
+ }
531
+ return { ...marcaInicial(ahoraMs), ...previa, porFichero };
532
+ }
533
+ catch {
534
+ return marcaInicial(ahoraMs);
535
+ }
536
+ }
537
+ /**
538
+ * El directorio donde Claude Code guarda las transcripciones de este repo.
539
+ *
540
+ * El nombre es la ruta absoluta con las barras y los puntos sustituidos por
541
+ * guiones, que es la convención de Claude Code. Se deriva, no se adivina.
542
+ */
543
+ export function dirDeTranscripciones(dir, home = process.env.HOME ?? '') {
544
+ return path.join(home, '.claude', 'projects', path.resolve(dir).replace(/[/.]/g, '-'));
545
+ }
546
+ /**
547
+ * Una pasada: lee lo nuevo de cada transcripción, clasifica y encola.
548
+ *
549
+ * Devuelve la marca resultante para que quien llama pueda enseñarla; los
550
+ * contadores son el canal de vitalidad, no adorno.
551
+ */
552
+ export async function procesarFriccion(dir, deps) {
553
+ const file = await marcaPath(dir);
554
+ const marca = leerMarca(file, deps.ahoraMs);
555
+ const cola = await colaDeFriccion(dir);
556
+ const pendientesDeShell = [];
557
+ const limite = deps.ahoraMs - VENTANA_DIAS * 24 * 60 * 60 * 1000;
558
+ let nombres;
559
+ try {
560
+ nombres = fs
561
+ .readdirSync(deps.dirTranscripciones)
562
+ .filter((n) => n.endsWith('.jsonl'));
563
+ }
564
+ catch {
565
+ return marca; // Sin directorio de transcripciones no hay nada que leer.
566
+ }
567
+ for (const nombre of nombres) {
568
+ const ruta = path.join(deps.dirTranscripciones, nombre);
569
+ let tamano;
570
+ try {
571
+ tamano = fs.statSync(ruta).size;
572
+ }
573
+ catch {
574
+ continue;
575
+ }
576
+ const estado = marca.porFichero[nombre] ?? { bytes: 0, turnos: 0 };
577
+ let desde = estado.bytes;
578
+ // Rotación o truncado: la marca apunta más allá del final. Volver a cero en
579
+ // vez de leer basura o callar.
580
+ if (desde > tamano)
581
+ desde = 0;
582
+ if (desde === tamano)
583
+ continue;
584
+ let texto;
585
+ try {
586
+ const fd = fs.openSync(ruta, 'r');
587
+ try {
588
+ const buf = Buffer.alloc(tamano - desde);
589
+ fs.readSync(fd, buf, 0, buf.length, desde);
590
+ texto = buf.toString('utf8');
591
+ }
592
+ finally {
593
+ fs.closeSync(fd);
594
+ }
595
+ }
596
+ catch {
597
+ continue;
598
+ }
599
+ marca.bytesLeidos += texto.length;
600
+ const actos = actosExplicitos(texto);
601
+ marca.interrupciones += actos.interrupciones;
602
+ marca.rechazos += actos.rechazos;
603
+ marca.edicionesPorShell += actos.edicionesPorShell;
604
+ // Para que el hook las vea en su próximo disparo. Aquí, y no en un hook de
605
+ // `Bash`: esto ya corre desacoplado y no cuesta latencia a nadie.
606
+ pendientesDeShell.push(...actos.rutasDeShell);
607
+ const lectura = edicionesDeTranscripcion(texto);
608
+ marca.lineasIlegibles += lectura.lineasIlegibles;
609
+ // Turnos de la SESIÓN, no del trozo. El desplazamiento se guarda aunque la
610
+ // sesión se salte por fina: si no, nunca dejaría de serlo.
611
+ const turnosPrevios = estado.turnos;
612
+ const turnosTotales = turnosPrevios + lectura.turnosDeUsuario;
613
+ marca.porFichero[nombre] = { bytes: tamano, turnos: turnosTotales };
614
+ if (turnosTotales < MIN_TURNOS_DE_USUARIO) {
615
+ marca.sesionesFinas += 1;
616
+ continue;
617
+ }
618
+ marca.sesionesVistas += 1;
619
+ const ultima = new Map();
620
+ for (const cruda of lectura.ediciones) {
621
+ // El índice de turno viene relativo al TROZO; se corre para que sea de la
622
+ // sesión. Sin esto, dos trozos de la misma sesión tendrían turnos 1,2,3 y
623
+ // 1,2,3 otra vez.
624
+ const e = { ...cruda, turno: cruda.turno + turnosPrevios };
625
+ // El mapa es DEL REPO: 18% de las ediciones del corpus caen fuera.
626
+ if (!dentroDelRepo(e.ruta, dir)) {
627
+ marca.fueraDelRepo += 1;
628
+ continue;
629
+ }
630
+ if (Date.parse(e.fecha) < limite)
631
+ continue;
632
+ const clave = `${e.sesion} ${e.ruta}`;
633
+ const previa = ultima.get(clave) ?? null;
634
+ // La pérdida conocida de leer por trozos: la edición anterior a ésta pudo
635
+ // quedar en el trozo previo, y entonces se clasifica `primera` en vez de
636
+ // compararse. Se cuenta; sesga a la baja, nunca al alza.
637
+ if (!previa && turnosPrevios > 0)
638
+ marca.bordesDeLectura += 1;
639
+ // Se emite UNA fila por edición, no sólo por reedición: sin las `primera`
640
+ // y las `iteracion` el servidor no tendría denominador y publicaría una
641
+ // tasa distinta a la del CLI bajo el mismo nombre.
642
+ encolarFriccion(cola, eventoDeFriccion(e, clasificarEdicion(previa, e), deps.moduloDe(e.ruta), dir));
643
+ marca.eventos += 1;
644
+ ultima.set(clave, e);
645
+ }
646
+ }
647
+ anotarPendientesDeShell(await pendientesDeShellPath(dir), pendientesDeShell);
648
+ marca.ultimaLecturaMs = deps.ahoraMs;
649
+ if (file) {
650
+ try {
651
+ fs.writeFileSync(file, `${JSON.stringify(marca, null, 2)}\n`);
652
+ }
653
+ catch {
654
+ // Sin marca persistida se relee la próxima vez: se repite trabajo, no se pierde.
655
+ }
656
+ }
657
+ return marca;
658
+ }
659
+ /**
660
+ * El canal de vitalidad (Invariante 17).
661
+ *
662
+ * La pregunta que contesta no es «¿cuánta fricción hay?» sino «¿sigo vivo?».
663
+ * Un repo tranquilo y un lector roto producen el mismo cero, y sin esta función
664
+ * no habría forma de distinguirlos.
665
+ */
666
+ export function diagnosticoDeFriccion(marca, edicionesDelRepo) {
667
+ const lineas = edicionesDelRepo > 0 && marca.bytesLeidos === 0
668
+ ? [
669
+ 'Lector de fricción: MUERTO.',
670
+ `El repo registró ${edicionesDelRepo} ediciones y el lector leyó 0 bytes de transcripción.`,
671
+ 'Eso no es un repo tranquilo: es que no está leyendo.',
672
+ 'Comprueba que ~/.claude/projects tiene el directorio de este repo.',
673
+ ]
674
+ : [
675
+ 'Lector de fricción: vivo.',
676
+ `Bytes leídos: ${marca.bytesLeidos}. Sesiones: ${marca.sesionesVistas}. Eventos: ${marca.eventos}.`,
677
+ `Descartes: ${marca.sesionesFinas} sesiones finas, ${marca.fueraDelRepo} ediciones fuera del repo, ${marca.lineasIlegibles} líneas ilegibles.`,
678
+ ];
679
+ // Los actos explícitos van en línea propia y NUNCA sumados a la tasa: son el
680
+ // canal limpio, y su utilidad es poder contrastarlo con el denso.
681
+ lineas.push(`Actos explícitos (aparte de la tasa): ${marca.interrupciones} interrupciones, ${marca.rechazos} rechazos.`);
682
+ // El punto ciego, dicho en voz alta. Sin esto, un fichero que se edita siempre
683
+ // por shell sale con fricción baja y se lee igual que uno que va bien.
684
+ if (marca.edicionesPorShell > 0) {
685
+ lineas.push(`⚠ Punto ciego: al menos ${marca.edicionesPorShell} escrituras por shell (sed, heredoc) que la fricción NO puede ver.`);
686
+ }
687
+ // El fallo de subida se dice en LAS DOS ramas, y por eso esto va fuera del
688
+ // if/else. Leer y no poder subir son hechos independientes: la primera versión
689
+ // salía por `return` en la rama MUERTO y se callaba el 403 justo en el caso más
690
+ // común —estado recién creado, cero bytes leídos esta pasada—. Lo cazó su test.
691
+ if (marca.ultimoErrorDeSubida) {
692
+ lineas.push(`⚠ La última subida FALLÓ: ${marca.ultimoErrorDeSubida}`, ' La cola local sigue entera y se reintenta en la próxima pasada.');
693
+ }
694
+ return lineas.join('\n');
695
+ }
696
+ /** La marca guardada, para el diagnóstico y para los tests. */
697
+ export async function estadoDeFriccion(dir) {
698
+ return leerMarca(await marcaPath(dir), Date.now());
699
+ }
700
+ /** Anota en la marca cómo fue la última subida. Best-effort. */
701
+ async function anotarSubida(dir, error) {
702
+ const file = await marcaPath(dir);
703
+ if (!file)
704
+ return;
705
+ try {
706
+ const marca = leerMarca(file, Date.now());
707
+ marca.ultimoErrorDeSubida = error;
708
+ fs.writeFileSync(file, `${JSON.stringify(marca, null, 2)}\n`);
709
+ }
710
+ catch {
711
+ // Si no se puede anotar, el drenado ya hizo lo importante: no perder la cola.
712
+ }
713
+ }
714
+ /**
715
+ * Vacía la cola de fricción contra `friction_event`.
716
+ *
717
+ * Corre en `impact --warm`, en la misma pasada que ya vacía `atlas_reads` y que
718
+ * ya paga red.
719
+ *
720
+ * NO LANZA, y esa es la decisión de diseño: `insertRow` sí lanza, y esto corre
721
+ * dentro del `try/catch` de `warmImpactCache`, que se lo tragaría. Un 403 por
722
+ * una policy mal puesta se leería EXACTAMENTE igual que «no había nada que
723
+ * subir». Así que el fallo se captura aquí, la cola **no se vacía** —nada se
724
+ * pierde, se reintenta— y queda anotado en la marca para que el diagnóstico
725
+ * pueda decirlo en voz alta.
726
+ */
727
+ export async function drenarFriccion(db, dir, projectId) {
728
+ const file = await colaDeFriccion(dir);
729
+ if (!file)
730
+ return { subidos: 0, ilegibles: 0, error: null };
731
+ let contenido;
732
+ try {
733
+ contenido = fs.readFileSync(file, 'utf8');
734
+ }
735
+ catch {
736
+ return { subidos: 0, ilegibles: 0, error: null }; // No hay cola: el caso normal.
737
+ }
738
+ const eventos = [];
739
+ let ilegibles = 0;
740
+ for (const linea of contenido.split('\n')) {
741
+ if (!linea.trim())
742
+ continue;
743
+ try {
744
+ eventos.push(JSON.parse(linea));
745
+ }
746
+ catch {
747
+ // Línea partida por el recorte de la cola: se tira sola, pero se cuenta.
748
+ ilegibles += 1;
749
+ }
750
+ }
751
+ let subidos = 0;
752
+ for (const e of eventos) {
753
+ try {
754
+ // La fila se construye campo a campo, igual que el evento: `user_id` lo
755
+ // pone el default `auth.uid()` del esquema, así que NO viaja aquí.
756
+ await db.insertRow('friction_event', {
757
+ project_id: projectId,
758
+ path: e.ruta,
759
+ module: e.modulo,
760
+ veredicto: e.veredicto,
761
+ occurred_at: e.fecha,
762
+ session_hash: e.sesion_hash,
763
+ });
764
+ subidos += 1;
765
+ }
766
+ catch (err) {
767
+ const error = (err instanceof Error ? err.message : String(err)).slice(0, 200);
768
+ // La cola se queda ENTERA a propósito. Reintentar y duplicar es peor que
769
+ // perder, pero perder en silencio es lo peor de los tres.
770
+ await anotarSubida(dir, error);
771
+ return { subidos, ilegibles, error };
772
+ }
773
+ }
774
+ // Sólo se borra DESPUÉS de subirlo todo.
775
+ try {
776
+ fs.writeFileSync(file, '');
777
+ }
778
+ catch {
779
+ // Se reintenta en la próxima pasada: duplicará, no perderá.
780
+ }
781
+ await anotarSubida(dir, null);
782
+ return { subidos, ilegibles, error: null };
783
+ }
784
+ // ── La entrega al agente ─────────────────────────────────────────────────────
785
+ /**
786
+ * De filas de `friction_event` a una `FilaDeFriccion`.
787
+ *
788
+ * El denominador son TODAS las filas, no sólo las correcciones, y por eso la
789
+ * tabla guarda los cuatro veredictos. Si guardara sólo las reediciones, esta
790
+ * función y `filasDeFriccion` —la del CLI, que cuenta sobre las ediciones de la
791
+ * transcripción— publicarían números distintos con el mismo nombre.
792
+ */
793
+ export function filaDesdeEventos(ruta, eventos, opts = {}) {
794
+ const suelo = opts.sueloDeExposicion ?? SUELO_DE_EXPOSICION;
795
+ const sesiones = new Set();
796
+ const conCorreccion = new Set();
797
+ let correcciones = 0;
798
+ for (const e of eventos) {
799
+ sesiones.add(e.session_hash);
800
+ if (e.veredicto === 'correccion') {
801
+ correcciones += 1;
802
+ conCorreccion.add(e.session_hash);
803
+ }
804
+ }
805
+ return {
806
+ ruta,
807
+ ediciones: eventos.length,
808
+ correcciones,
809
+ sesiones: sesiones.size,
810
+ sesionesConCorreccion: conCorreccion.size,
811
+ // Truncado ⇒ sin tasa. El conteo de correcciones sigue siendo cierto («vi
812
+ // al menos N»), pero el porcentaje sería sobre un denominador cortado.
813
+ tasa: !opts.truncado && eventos.length >= suelo
814
+ ? correcciones / eventos.length
815
+ : null,
816
+ ...(opts.truncado ? { truncado: true } : {}),
817
+ };
818
+ }
819
+ /**
820
+ * La línea que ve el agente. `null` cuando no hay nada que decir.
821
+ *
822
+ * Acepta `undefined` a propósito: quien la llama suele venir de un `Map.get`,
823
+ * y una guarda por cada sitio de llamada es una guarda que algún día falta.
824
+ *
825
+ * Callar sin correcciones es deliberado: esta línea se inyecta ANTES de cada
826
+ * edición, y un «Fricción: 0%» en todas entrena a ignorar el bloque entero —y
827
+ * entonces deja de servir el aviso que sí importa.
828
+ */
829
+ export function lineaDeFriccion(fila) {
830
+ // DOS correcciones como mínimo para decir nada. Una es un suceso, no un
831
+ // patrón, y esta línea se sirve ANTES DE CADA EDICIÓN de ese fichero: un aviso
832
+ // que no distingue accidente de costumbre entrena a ignorar el bloque entero,
833
+ // y entonces deja de servir el que sí importa.
834
+ if (!fila || fila.correcciones < MIN_CORRECCIONES_PARA_HABLAR)
835
+ return null;
836
+ if (fila.truncado) {
837
+ return `Fricción: al menos ${fila.correcciones} correcciones (el servidor cortó el histórico; sin tasa fiable).`;
838
+ }
839
+ if (fila.tasa === null) {
840
+ return `Fricción: ${fila.correcciones} correcciones en ${fila.ediciones} ediciones (pocas para una tasa).`;
841
+ }
842
+ const pct = Math.round(fila.tasa * 100);
843
+ return `Fricción: ${pct}% — ${fila.correcciones} de ${fila.ediciones} ediciones, en ${fila.sesionesConCorreccion} de ${fila.sesiones} sesiones (últimos ${VENTANA_DIAS} días).`;
844
+ }
845
+ /**
846
+ * Un suceso en una línea.
847
+ *
848
+ * La fecha se RECORTA de la cadena ISO en vez de parsearse: `new Date(iso)` y
849
+ * luego `getDate()` devuelve el día en la zona horaria de quien mire, así que
850
+ * un suceso de las 00:30 UTC se publicaría con el día anterior para media
851
+ * Europa y con el correcto para la otra media. `occurred_at` es UTC y el
852
+ * recorte lo mantiene UTC.
853
+ */
854
+ export function lineaDeSuceso(s) {
855
+ // Acepta una fecha ausente por el mismo motivo por el que `lineaDeFriccion`
856
+ // acepta `undefined`: una guarda por cada sitio de llamada es una guarda que
857
+ // algún día falta. `occurred_at` es `not null` en la tabla, pero esta función
858
+ // es pura y la llama quien quiera con las filas que quiera.
859
+ const dia = /^(\d{4})-(\d{2})-(\d{2})/.exec(s.fecha ?? '');
860
+ const cuando = dia ? `${dia[3]}/${dia[2]}` : (s.fecha ?? 'sin fecha');
861
+ return s.modulo
862
+ ? `${cuando} · ${s.ruta} — ${s.modulo}`
863
+ : `${cuando} · ${s.ruta}`;
864
+ }
865
+ /**
866
+ * El ranking local, para `changebook friction`.
867
+ *
868
+ * Relee las transcripciones enteras en vez de partir de la marca de agua: la
869
+ * marca sirve para no reprocesar en el camino caliente, pero esta orden se
870
+ * invoca a mano y tiene que contestar aunque el `--warm` ya haya drenado la
871
+ * cola. Si algún día molesta el coste, el arreglo es guardar agregados, no
872
+ * acelerar la lectura.
873
+ */
874
+ export function rankingDeFriccion(dir, home, tope = 10, ahoraMs = Date.now()) {
875
+ // LA MISMA VENTANA que `procesarFriccion`. Sin esto el ranking se calculaba
876
+ // sobre TODA la historia mientras su propia línea decía «últimos 30 días»: la
877
+ // etiqueta mentía, y mentía hacia arriba porque el numerador crecía sin tope.
878
+ const limite = ahoraMs - VENTANA_DIAS * 24 * 60 * 60 * 1000;
879
+ const dirT = dirDeTranscripciones(dir, home);
880
+ let nombres;
881
+ try {
882
+ nombres = fs.readdirSync(dirT).filter((n) => n.endsWith('.jsonl'));
883
+ }
884
+ catch {
885
+ return `Sin transcripciones en ${dirT}.`;
886
+ }
887
+ const ediciones = [];
888
+ for (const nombre of nombres) {
889
+ let texto;
890
+ try {
891
+ texto = fs.readFileSync(path.join(dirT, nombre), 'utf8');
892
+ }
893
+ catch {
894
+ continue;
895
+ }
896
+ const lectura = edicionesDeTranscripcion(texto);
897
+ if (lectura.turnosDeUsuario < MIN_TURNOS_DE_USUARIO)
898
+ continue;
899
+ // Mismo filtro que la pasada: el mapa es DEL REPO.
900
+ ediciones.push(...lectura.ediciones.filter((e) => dentroDelRepo(e.ruta, dir) && Date.parse(e.fecha) >= limite));
901
+ }
902
+ const sucesos = correccionesDeEdiciones(ediciones)
903
+ .map((e) => ({
904
+ fecha: e.fecha,
905
+ ruta: path.relative(dir, e.ruta),
906
+ modulo: null,
907
+ }))
908
+ .sort((a, b) => (b.fecha ?? '').localeCompare(a.fecha ?? ''));
909
+ // VACÍO ES UN DATO, y se dice. Un «no hay nada» a secas se lee igual que un
910
+ // instrumento averiado —Invariante 17—, y aquí el caso normal es el vacío:
911
+ // medido el 24/08, 8 correcciones en 30 días.
912
+ if (sucesos.length === 0) {
913
+ return (`Sin correcciones en los últimos ${VENTANA_DIAS} días.\n` +
914
+ `(Vacío significa que no has tenido que rehacer trabajo del agente. ` +
915
+ `Si el lector estuviera parado lo diría el diagnóstico de aquí arriba.)`);
916
+ }
917
+ const mostrados = sucesos.slice(0, tope);
918
+ const lineas = [
919
+ `${sucesos.length} ${sucesos.length === 1 ? 'corrección' : 'correcciones'} en los últimos ${VENTANA_DIAS} días:`,
920
+ ...mostrados.map((x) => ` ${lineaDeSuceso(x)}`),
921
+ ];
922
+ // Sin esto, un tope alcanzado se lee como «esto es todo». Es la regla de no
923
+ // recortar en silencio, y ya costó una medición este mes.
924
+ if (sucesos.length > mostrados.length) {
925
+ lineas.push(` … y ${sucesos.length - mostrados.length} más (sube el tope para verlas).`);
926
+ }
927
+ return lineas.join('\n');
928
+ }
929
+ // ── El punto ciego del shell, cerrado por el canal desacoplado ───────────────
930
+ //
931
+ // MEDIDO el 24/08 sobre 30 sesiones reales: de 153 pares (sesión, módulo)
932
+ // tocados por shell, **30 eran módulos que ninguna edición por tool avisó**, y
933
+ // ~19 de ésos SÍ valían un aviso —`Publicación` (4 dependientes, 3
934
+ // reincidencias), `Búsqueda de símbolos` (4 y 2), `Integración de MCP` (3 y 2)—.
935
+ // Un aviso perdido por sesión, y de los caros.
936
+ //
937
+ // Se resuelve AQUÍ y no enganchando `Bash` al `PreToolUse`: este código ya corre
938
+ // desacoplado dentro de `impact --warm`. Poner un hook en cada llamada a Bash
939
+ // pagaría latencia en el camino más frecuente que hay.
940
+ /**
941
+ * Las rutas que un comando de shell escribe, de las formas que se pueden leer
942
+ * **sin adivinar**.
943
+ *
944
+ * Deliberadamente corto: `sed -i`, las redirecciones y `tee`. No intenta
945
+ * `python -c` ni un heredoc con la ruta en una variable — un extractor que
946
+ * inventa rutas es peor que uno que se queda corto, porque avisa del fichero
947
+ * equivocado. El resultado es una COTA INFERIOR y se dice así.
948
+ */
949
+ export function rutasEscritasPorShell(cmd) {
950
+ // ANCLADA AL FINAL con `(?![\\w.])`, y no es cosmético: la alternancia de una
951
+ // regex gana con el PRIMERO que casa, así que `js` se llevaba `salida.json` y
952
+ // devolvía `salida.js` —un fichero que no existe—. Un extractor que inventa
953
+ // rutas avisa del fichero equivocado, que es peor que quedarse corto.
954
+ // Cazado por su propio test el 24/08.
955
+ const EXT = '(?:tsx|ts|mjs|cjs|js|sql|md|json|yaml|yml|css|html)(?![\\w.])';
956
+ const out = new Set();
957
+ const add = (re) => {
958
+ for (const m of cmd.matchAll(re))
959
+ if (m[1])
960
+ out.add(m[1]);
961
+ };
962
+ // Redirección: `> a.ts`, `>> a.ts`. No casa `2>/dev/null` porque exige extensión.
963
+ add(new RegExp(`>>?\\s*([^\\s;&|<>"']+\\.${EXT})`, 'g'));
964
+ // `sed -i` (con o sin el '' de BSD), con la ruta al final.
965
+ add(new RegExp(`sed\\s+-i\\b[^;&|]*?([^\\s;&|'"]+\\.${EXT})`, 'g'));
966
+ add(new RegExp(`tee\\s+(?:-a\\s+)?([^\\s;&|]+\\.${EXT})`, 'g'));
967
+ return [...out];
968
+ }
969
+ /** Fichero donde se apuntan las rutas de shell pendientes de avisar. */
970
+ export async function pendientesDeShellPath(dir) {
971
+ return gitPath(dir, 'changebook-impact-shell.json').catch(() => null);
972
+ }
973
+ /**
974
+ * Apunta las rutas escritas por shell para que el hook las vea en su próximo
975
+ * disparo. Best-effort: no poder anotarlas no puede tumbar el warm.
976
+ */
977
+ export function anotarPendientesDeShell(file, rutas) {
978
+ if (!file || rutas.length === 0)
979
+ return;
980
+ try {
981
+ let previas = [];
982
+ try {
983
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
984
+ if (Array.isArray(j.rutas))
985
+ previas = j.rutas.filter((x) => typeof x === 'string');
986
+ }
987
+ catch {
988
+ // Aún no existe o está roto: se empieza de cero.
989
+ }
990
+ // Tope para que una tanda enorme no haga crecer el fichero sin freno; se
991
+ // quedan las MÁS RECIENTES, que son las que el aviso todavía puede alcanzar.
992
+ const todas = [...new Set([...previas, ...rutas])].slice(-40);
993
+ fs.writeFileSync(file, `${JSON.stringify({ rutas: todas }, null, 2)}\n`);
994
+ }
995
+ catch {
996
+ // Ver arriba.
997
+ }
998
+ }
999
+ /** Las lee y las BORRA: un aviso pendiente se sirve una vez. */
1000
+ export function tomarPendientesDeShell(file) {
1001
+ if (!file)
1002
+ return [];
1003
+ try {
1004
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
1005
+ const rutas = Array.isArray(j.rutas)
1006
+ ? j.rutas.filter((x) => typeof x === 'string')
1007
+ : [];
1008
+ if (rutas.length > 0)
1009
+ fs.writeFileSync(file, `{"rutas":[]}\n`);
1010
+ return rutas;
1011
+ }
1012
+ catch {
1013
+ return [];
1014
+ }
1015
+ }
1016
+ //# sourceMappingURL=friction.js.map