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.
- package/README.md +4 -0
- package/dist/agregados.js +496 -0
- package/dist/analyze.js +40 -2
- package/dist/friccionDelBrief.js +102 -0
- package/dist/friction.js +1016 -0
- package/dist/git.js +42 -2
- package/dist/guard.js +184 -2
- package/dist/impact.js +172 -17
- package/dist/index.js +31 -1
- package/dist/respuestas.js +111 -0
- package/dist/supabase.js +38 -2
- package/dist/sync.js +36 -5
- package/dist/toolActionPlan.js +98 -0
- package/dist/toolProjectBrief.js +310 -0
- package/dist/toolUsage.js +128 -0
- package/dist/tools.js +332 -332
- package/dist/usage.js +120 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/friction.js
ADDED
|
@@ -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
|