changebook 0.4.10 → 0.6.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/dist/impact.js CHANGED
@@ -42,7 +42,8 @@ import * as fs from "node:fs";
42
42
  import * as path from "node:path";
43
43
  import { presupuestoDeTiempo } from "./context.js";
44
44
  import { execFileAsync, gitPath, projectNameFor } from "./git.js";
45
- import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
45
+ import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
46
+ import { coChangePairs } from "./sync.js";
46
47
  import { computeRecidivism, dependentsOf } from "./tools.js";
47
48
  /**
48
49
  * Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
@@ -52,6 +53,31 @@ import { computeRecidivism, dependentsOf } from "./tools.js";
52
53
  const IMPACT_TIMEOUT_MS = 1_200;
53
54
  /** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
54
55
  const CACHE_TTL_MS = 5 * 60_000;
56
+ /**
57
+ * LA VERSIÓN DE LA FORMA DE LA CACHÉ. Súbela cuando cambie QUÉ se guarda: las
58
+ * columnas de un select, un campo nuevo del que dependa el render, una clave de
59
+ * agrupación. No cuando cambie el código alrededor.
60
+ *
61
+ * Sin esto, una caché escrita por el binario ANTERIOR se deserializa sin error y
62
+ * se sirve entera durante los 5 minutos del TTL, en cada máquina que actualice.
63
+ * No es hipotético: el commit que añadió `evidence_scope` a este mismo select
64
+ * dejó exactamente esa ventana abierta, y el fallo no se ve —la caché vieja no
65
+ * está corrupta, solo le faltan campos, así que el render calla en vez de
66
+ * romperse. Un silencio se lee igual que "no hay nada que decir".
67
+ *
68
+ * Empieza en 1 y no en 2: toda caché SIN `v` es, por definición, anterior al
69
+ * versionado, así que se descarta pase lo que pase.
70
+ *
71
+ * ── v2 (2026-07-28): `changelog_id` en las filas ────────────────────────────
72
+ *
73
+ * Y aquí es donde esto deja de ser precaución. `coChangePairs` AGRUPA POR
74
+ * `changelog_id`: sin esa columna, todas las filas caen bajo la clave
75
+ * `undefined`, se leen como un único análisis gigantesco, y salen TODOS los
76
+ * módulos acoplados con TODOS. No es un fallo ruidoso — es una caché que
77
+ * responde, con datos que parecen razonables, durante los cinco minutos del
78
+ * TTL, en cada máquina que actualice. Con la versión, esas cachés se tiran.
79
+ */
80
+ export const IMPACT_CACHE_V = 2;
55
81
  /**
56
82
  * Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
57
83
  * ventanas distintas darían dependientes distintos según se pregunte o se
@@ -64,6 +90,19 @@ const ALERT_WINDOW_ROWS = 500;
64
90
  const SEEN_TTL_MS = 30 * 60_000;
65
91
  /** Cota del archivo de estado: es una nota, no un historial. */
66
92
  const SEEN_MAX = 200;
93
+ /**
94
+ * Versión de la forma del estado "ya avisado". Mismo mecanismo que
95
+ * IMPACT_CACHE_V, con UNA diferencia deliberada: aquí un `v` AUSENTE se acepta.
96
+ *
97
+ * Porque la forma de este fichero no ha cambiado, y decir lo contrario tirando
98
+ * el estado sería afirmar un desajuste que no existe. Un `v` presente y
99
+ * DISTINTO sí se descarta, que es la protección para el día que cambie.
100
+ *
101
+ * Y el reparto de riesgo no es el mismo: una ImpactCache vieja sirve datos
102
+ * equivocados; un SeenState viejo, como mucho, repite un aviso o se calla uno
103
+ * dentro de una sesión. No justifica tirar el estado de todo el mundo hoy.
104
+ */
105
+ const SEEN_STATE_V = 1;
67
106
  /** Cuántos módulos se describen si el archivo pertenece a varios. */
68
107
  const MAX_MODULES = 3;
69
108
  /**
@@ -162,6 +201,115 @@ export function repoRelative(toplevel, filePath) {
162
201
  // El atlas guarda rutas de git: siempre con "/", también en Windows.
163
202
  return rel.split(path.sep).join("/");
164
203
  }
204
+ /**
205
+ * Devuelve los avisos servidos con la identidad de TODAS las alertas que decian
206
+ * lo mismo, no solo la que sobrevivio al colapso por texto.
207
+ *
208
+ * Es la mitad de impacto del arreglo que el guardian ya lleva en
209
+ * `guardFindings`: la PANTALLA colapsa dos frases identicas —sobre dos frases
210
+ * iguales no se puede actuar distinto— pero el REGISTRO no puede, porque su
211
+ * trabajo es la atribucion. Con una sola identidad, el veredicto sobre esta
212
+ * entrega se le colgaria a la alerta equivocada.
213
+ *
214
+ * Se hace aparte y no dentro de `unoPorTexto` a proposito: esa funcion es
215
+ * generica y de presentacion, la comparten tres sitios y no sabe —ni debe— que
216
+ * es un id de alerta.
217
+ */
218
+ export function conIdentidad(servidas, todas) {
219
+ const idsPorTexto = new Map();
220
+ for (const a of todas) {
221
+ const texto = (a.plain ?? "").trim();
222
+ if (!texto || !a.id)
223
+ continue;
224
+ const lista = idsPorTexto.get(texto) ?? [];
225
+ if (!lista.includes(a.id))
226
+ lista.push(a.id);
227
+ idsPorTexto.set(texto, lista);
228
+ }
229
+ return servidas.map((s) => ({
230
+ ...s,
231
+ alertIds: idsPorTexto.get(s.plain.trim()) ?? s.alertIds,
232
+ }));
233
+ }
234
+ /**
235
+ * EL TECHO DEL CANAL EMPUJADO.
236
+ *
237
+ * `sync.ts` tiene `SYNC_BUDGET_CHARS` desde hace tiempo, con toda una historia
238
+ * detrás (2.000 → 3.000 → vuelta a 2.000, y el comentario que lo explica:
239
+ * «subir el techo alimenta al que se lo come»). Esto no tenía equivalente: hay
240
+ * `MAX_MODULES` y `MAX_NOTE_CHARS`, pero nada acotaba el TOTAL. Tres módulos con
241
+ * nota larga, sus dependientes, sus alertas y ahora sus co-cambios pasan de dos
242
+ * mil caracteres sin que nada los pare — y si la edición toca varios ficheros,
243
+ * se multiplica.
244
+ *
245
+ * MÁS BAJO QUE EL DEL BLOQUE, y no por simetría: el bloque de `CLAUDE.md` se
246
+ * lee UNA vez por sesión y esto dispara en CADA edición. Un canal que cuesta
247
+ * 2.000 caracteres cuarenta veces por sesión no es un canal, es un impuesto.
248
+ *
249
+ * Y el techo no recorta al tuntún: cae primero la prosa (nota), luego la
250
+ * correlación (co-cambio), luego la estructura (dependientes). La alerta abierta
251
+ * tiene suelo y no cae nunca — si eso se recortara, el canal dejaría de tener
252
+ * motivo para existir.
253
+ */
254
+ const IMPACT_BUDGET_CHARS = 1_500;
255
+ /**
256
+ * Recorta a `budget` quitando por prioridad descendente, no por orden de
257
+ * aparición.
258
+ *
259
+ * DOS PROPIEDADES QUE NO SE NEGOCIAN:
260
+ *
261
+ * Las alertas abiertas (prio 0) NO se recortan jamás, aunque solas pasen del
262
+ * techo. Es el suelo, y es el mismo argumento que el «suelo del mapa» de
263
+ * sync.ts: un presupuesto de suma cero sin suelo acaba comiéndose justo lo
264
+ * que justifica el canal.
265
+ *
266
+ * CONSECUENCIA, dicha aquí para que nadie la descubra depurando: el texto
267
+ * devuelto PUEDE pasar de `budget`. El techo acota lo discrecional, no el
268
+ * suelo. Si una edición toca módulos con veinte alertas abiertas, se sirven
269
+ * las veinte — un canal que se calla justo cuando hay veinte cosas rotas
270
+ * sería exactamente lo contrario de lo que promete.
271
+ *
272
+ * Una cabecera sin contenido no se queda. `- Module **X**` a secas no dice
273
+ * nada que el agente no tenga delante; servirla sería gastar caracteres en
274
+ * ruido y, peor, parecer que se dijo algo.
275
+ */
276
+ export function recortarAlPresupuesto(lineas, budget = IMPACT_BUDGET_CHARS) {
277
+ const coste = (xs) => xs.reduce((n, x) => n + x.text.length + 1, 0);
278
+ const dentro = new Set(lineas);
279
+ // De la prioridad más alta (más prescindible) a la más baja, y dentro de cada
280
+ // una por el final: si hay que perder co-cambios, se pierden los últimos, que
281
+ // `coChangePairs` ya ordenó de más fuerte a más débil.
282
+ const prios = [...new Set(lineas.map((l) => l.prio))].sort((a, b) => b - a);
283
+ for (const p of prios) {
284
+ if (p === 0)
285
+ break; // El suelo.
286
+ for (const l of [...lineas].reverse()) {
287
+ if (coste([...dentro]) <= budget)
288
+ break;
289
+ if (l.prio === p)
290
+ dentro.delete(l);
291
+ }
292
+ }
293
+ // Cabeceras huérfanas: un módulo cuyas líneas se fueron todas, y un bloque de
294
+ // fichero que se quedó sin módulos.
295
+ const quedan = lineas.filter((l) => dentro.has(l));
296
+ const util = [];
297
+ for (let i = 0; i < quedan.length; i++) {
298
+ const l = quedan[i];
299
+ if (l.ancla === "modulo" && quedan[i + 1]?.ancla !== undefined)
300
+ continue;
301
+ if (l.ancla === "modulo" && i === quedan.length - 1)
302
+ continue;
303
+ util.push(l);
304
+ }
305
+ const conContenido = util.filter((l, i) => l.ancla !== "bloque" || util[i + 1]?.ancla === "modulo");
306
+ // La línea en blanco entre bloques se pone AQUÍ y no al concatenar: si la
307
+ // pusiera quien llama, un bloque recortado entero dejaría su separador
308
+ // suelto — dos saltos de línea que se leen como "aquí falta algo".
309
+ return conContenido
310
+ .map((l, i) => (l.ancla === "bloque" && i > 0 ? `\n${l.text}` : l.text))
311
+ .join("\n");
312
+ }
165
313
  /**
166
314
  * ¿Merece este módulo un aviso?
167
315
  *
@@ -181,20 +329,67 @@ export function valeLaPena(m) {
181
329
  * para el mismo hecho serían dos hechos para él.
182
330
  */
183
331
  export function impactText(file, modules) {
184
- const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
332
+ return lineasDeImpacto(file, modules)
333
+ .map((x) => x.text)
334
+ .join("\n");
335
+ }
336
+ /**
337
+ * EL ORDEN DE CAÍDA, y por qué es ese.
338
+ *
339
+ * 0 · ALERTA ABIERTA — algo ya se rompió aquí. Es lo único con suelo: si esto se
340
+ * recorta, el canal deja de tener motivo para existir.
341
+ * 1 · DEPENDIENTES — quién se rompe si tocas esto. No está escrito en ninguna
342
+ * parte del código, así que el agente no puede deducirlo.
343
+ * 2 · CO-CAMBIO — más débil que lo anterior: una correlación, no una arista.
344
+ * 3 · NOTA — contexto, no una orden. Y es lo más caro: hasta MAX_NOTE_CHARS por
345
+ * módulo, o sea que tres módulos con nota se comen casi mil caracteres de
346
+ * prosa antes que ningún hecho.
347
+ */
348
+ export function lineasDeImpacto(file, modules) {
349
+ const lines = [
350
+ {
351
+ prio: 0,
352
+ ancla: "bloque",
353
+ text: `⚠ ChangeBook — impact radius of ${file}, before you edit it:`,
354
+ },
355
+ ];
185
356
  for (const m of modules.slice(0, MAX_MODULES)) {
186
357
  // El riesgo se nombra solo cuando es alto: ver valeLaPena.
187
358
  const alto = m.risk === "high" || m.risk === "hotspot";
188
- lines.push(`- Module **${m.module}**` +
189
- (alto ? ` · risk: ${m.risk}` : "") +
190
- (m.priorRegressions >= 2
191
- ? ` · ${m.priorRegressions} prior regressions`
192
- : ""));
359
+ lines.push({
360
+ prio: 0,
361
+ ancla: "modulo",
362
+ // La reincidencia viaja EN la cabecera y no en su propia línea, así que
363
+ // no se recorta aparte. El plan la ponía en prioridad 2; se queda porque
364
+ // son ~25 caracteres de señal de peligro pegados a una cabecera de 60 que
365
+ // igualmente se sirve — tirarla ahorraría poco y quitaría lo que más pesa
366
+ // de la línea. Si algún día hay que recortarla, se recorta la cabecera
367
+ // entera con su módulo.
368
+ text: `- Module **${m.module}**` +
369
+ (alto ? ` · risk: ${m.risk}` : "") +
370
+ (m.priorRegressions >= 2
371
+ ? ` · ⚠ ${m.priorRegressions} prior regressions`
372
+ : ""),
373
+ });
193
374
  if (m.dependents.length > 0) {
194
- lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
375
+ lines.push({
376
+ prio: 1,
377
+ text: ` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`,
378
+ });
379
+ }
380
+ // El co-cambio es HISTORIA, no estructura: «quién llama a quién» sale de un
381
+ // grafo y lo tiene medio mercado; «qué se toca junto en la práctica» solo
382
+ // sale del historial. Va DESPUÉS de los dependientes porque es más débil —
383
+ // una correlación, no una arista declarada— y el orden de las líneas es lo
384
+ // que dice cuál de las dos merece más confianza.
385
+ for (const cc of m.coChanges ?? []) {
386
+ lines.push({
387
+ prio: 2,
388
+ text: ` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`,
389
+ });
195
390
  }
196
391
  for (const a of m.alerts) {
197
- lines.push(` - ⚠ OPEN ALERT: ${a.plain}`);
392
+ lines.push({ prio: 0, text: ` - ⚠ OPEN ALERT: ${a.plain}` });
198
393
  // La condicion, resuelta. Va en su propia linea y con el simbolo delante
199
394
  // para que se lea como un hecho comprobado y no como parte de la prosa del
200
395
  // modelo, que es justo la que hedgea.
@@ -207,27 +402,48 @@ export function impactText(file, modules) {
207
402
  // agente el hecho y las dos salidas, que es mas informacion que la que
208
403
  // habia en cualquiera de las dos versiones anteriores.
209
404
  if (a.donde && a.donde.length > 0) {
210
- lines.push(` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
211
- ` alert expects it gone: either a reference was missed, or the alert is stale`);
405
+ lines.push({
406
+ // Prioridad 0 como su alerta: es la COMPROBACIÓN de lo que se afirma,
407
+ // y servir la afirmación sin ella deja al agente con el condicional
408
+ // que esta línea existe para contestar.
409
+ prio: 0,
410
+ text: ` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
411
+ ` — alert expects it gone: either a reference was missed, or the alert is stale`,
412
+ });
212
413
  }
213
414
  // El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
214
415
  // vez de dejar que el agente (o el refutador) la compruebe donde no era —
215
416
  // que es lo que tiraba las dos alertas del desfase de esquema.
216
417
  if (a.fueraDelRepo) {
217
- lines.push(` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`);
418
+ lines.push({
419
+ prio: 0,
420
+ text: ` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`,
421
+ });
422
+ }
423
+ // LA LINEA que lo provocó. Prioridad 0 como su aviso: es la evidencia, y
424
+ // un aviso servido sin ella obliga al agente a reconstruir de qué se
425
+ // habla — que es el trabajo que este canal existe para ahorrarle.
426
+ if (a.linea) {
427
+ lines.push({ prio: 0, text: ` → TRIGGERED BY: ${a.linea}` });
218
428
  }
219
429
  }
220
430
  // Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
221
431
  // observación fechada, no estado vigente. Va DESPUÉS de dependientes y
222
432
  // alertas porque es contexto, no una orden.
223
433
  if (m.note) {
224
- lines.push(` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`);
434
+ lines.push({
435
+ prio: 3,
436
+ text: ` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`,
437
+ });
225
438
  }
226
439
  }
227
440
  if (modules.length > MAX_MODULES) {
228
- lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
441
+ lines.push({
442
+ prio: 3,
443
+ text: `- (+${modules.length - MAX_MODULES} more module(s) affected)`,
444
+ });
229
445
  }
230
- return lines.join("\n");
446
+ return lines;
231
447
  }
232
448
  /**
233
449
  * Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
@@ -237,19 +453,27 @@ export function impactText(file, modules) {
237
453
  export function rutasYaAvisadas(state, sessionId, ahora) {
238
454
  if (!state || state.session_id !== sessionId)
239
455
  return new Set();
456
+ // Otra forma de fichero: no se interpreta, se olvida. Ausente cuenta como la
457
+ // actual (ver SEEN_STATE_V).
458
+ if ((state.v ?? SEEN_STATE_V) !== SEEN_STATE_V)
459
+ return new Set();
240
460
  const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
241
461
  return new Set(vivas.map(([ruta]) => ruta));
242
462
  }
243
463
  /** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
244
464
  export function estadoSiguiente(state, sessionId, nuevas, ahora) {
245
- const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
465
+ const base = state &&
466
+ state.session_id === sessionId &&
467
+ (state.v ?? SEEN_STATE_V) === SEEN_STATE_V
468
+ ? { ...(state.seen ?? {}) }
469
+ : {};
246
470
  for (const ruta of nuevas)
247
471
  base[ruta] = ahora;
248
472
  const podado = Object.entries(base)
249
473
  .filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
250
474
  .sort((a, b) => b[1] - a[1])
251
475
  .slice(0, SEEN_MAX);
252
- return { session_id: sessionId, seen: Object.fromEntries(podado) };
476
+ return { session_id: sessionId, seen: Object.fromEntries(podado), v: SEEN_STATE_V };
253
477
  }
254
478
  /**
255
479
  * Módulos a los que pertenece un archivo, del más reciente al más antiguo.
@@ -282,9 +506,185 @@ export function modulosDelArchivo(file, rows) {
282
506
  }
283
507
  return [...out.entries()].map(([module, v]) => ({ module, ...v }));
284
508
  }
509
+ /**
510
+ * ¿Se puede servir esta caché? Fresca Y de esta forma.
511
+ *
512
+ * Exportada para poder probar el descarte sin montar un repo: es una función
513
+ * pura y el fallo que evita —servir campos que no están— no se ve desde fuera.
514
+ */
515
+ export function cacheServible(cached, ahora) {
516
+ if (!cached)
517
+ return false;
518
+ if (cached.v !== IMPACT_CACHE_V)
519
+ return false;
520
+ return ahora - cached.fetched_at < CACHE_TTL_MS;
521
+ }
285
522
  async function cachePath(dir) {
286
523
  return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
287
524
  }
525
+ // ── El libro de entregas del canal empujado ──────────────────────────────────
526
+ //
527
+ // El hook PreToolUse es el canal MÁS frecuente del atlas —dispara en cada
528
+ // edición— y hasta hoy servía consejo sin dejar rastro: "¿acertó el aviso?" solo
529
+ // se podía responder del guardián pre-commit, que es el que menos corre.
530
+ //
531
+ // EL DISEÑO LO COMPLICA A PROPÓSITO, y hay que respetarlo: el camino crítico de
532
+ // `printImpact` no toca la red JAMÁS (ver `atlasSignals`) y tiene techo de
533
+ // 1.200 ms. Un insert síncrono rompería la regla que sostiene todo esto — el
534
+ // hook nunca puede costar tiempo perceptible.
535
+ //
536
+ // Por eso la entrega se ANOTA en disco (un append, sin leer ni parsear nada) y
537
+ // la sirve al servidor `impact --warm`, que ya corre desacoplado y ya toca la
538
+ // red. Escribir y enviar quedan separados por un proceso: si el editor mata al
539
+ // hook entre las dos cosas, la línea sigue en el fichero y se manda en la
540
+ // siguiente ronda.
541
+ /** Cota de la cola. Mismo patrón que el log del guardián: por tamaño, no por olvido. */
542
+ const QUEUE_MAX_BYTES = 65_536;
543
+ const QUEUE_KEEP_BYTES = 32_768;
544
+ async function queuePath(dir) {
545
+ return gitPath(dir, "changebook-impact-queue.jsonl").catch(() => null);
546
+ }
547
+ /**
548
+ * Anota una entrega. JSONL y no JSON: un append no tiene que leer lo que ya hay,
549
+ * y dos procesos solapados (dos ediciones seguidas) no pueden pisarse el fichero
550
+ * como sí harían leyendo-modificando-escribiendo un array.
551
+ *
552
+ * Best-effort de principio a fin: registrar la entrega no puede romper la
553
+ * edición que la provocó.
554
+ */
555
+ export function encolarEntrega(file, entrega) {
556
+ if (!file)
557
+ return;
558
+ try {
559
+ // Recortar ANTES de anexar, igual que el log del guardián. El recorte parte
560
+ // por la mitad de una línea a propósito: reconstruirla costaría leer y
561
+ // parsear el fichero entero en el camino crítico, y la línea rota se tira
562
+ // sola al drenar (ver `entregasDeLaCola`). Se pierde UNA entrega de hace
563
+ // rato antes que gastar milisegundos de cada edición.
564
+ try {
565
+ if (fs.statSync(file).size > QUEUE_MAX_BYTES) {
566
+ const keep = fs.readFileSync(file).subarray(-QUEUE_KEEP_BYTES);
567
+ fs.writeFileSync(file, keep);
568
+ }
569
+ }
570
+ catch {
571
+ // Aún no existe: nada que recortar.
572
+ }
573
+ fs.appendFileSync(file, `${JSON.stringify(entrega)}\n`);
574
+ }
575
+ catch {
576
+ // Sin sitio donde anotar, el hook sigue avisando igual. El registro es una
577
+ // segunda lectura del mismo hecho, nunca el hecho.
578
+ }
579
+ }
580
+ /**
581
+ * Las entregas legibles de la cola. Las líneas rotas se tiran en silencio: la
582
+ * primera puede venir cortada por el recorte, y una entrega ilegible no es un
583
+ * error del que haya que informar a nadie — es una que no se pudo registrar.
584
+ */
585
+ export function entregasDeLaCola(contenido) {
586
+ const fuera = [];
587
+ for (const linea of contenido.split("\n")) {
588
+ if (!linea.trim())
589
+ continue;
590
+ try {
591
+ const e = JSON.parse(linea);
592
+ // El CHECK de la migración exige sha256 en hex; una fila que no lo cumpla
593
+ // haría fallar el insert de TODAS las demás si fueran en lote.
594
+ if (typeof e?.advice_hash === "string" && /^[0-9a-f]{64}$/.test(e.advice_hash)) {
595
+ fuera.push(e);
596
+ }
597
+ }
598
+ catch {
599
+ // Línea partida por el recorte, o basura. Se ignora.
600
+ }
601
+ }
602
+ return fuera;
603
+ }
604
+ /**
605
+ * Las que TODAVÍA no están registradas, por (advice_hash, head_sha).
606
+ *
607
+ * Es la respuesta a "y si el proceso muere entre insertar y vaciar la cola":
608
+ * la cola se vacía DESPUÉS de insertar, así que morir en medio deja las líneas
609
+ * puestas y la siguiente ronda las volvería a mandar. Preguntar primero cuáles
610
+ * ya están hace que reenviar sea gratis, que es la misma propiedad que ya tiene
611
+ * `atlas_record_change`.
612
+ *
613
+ * Pura para poder probar el caso sin red, que es el que no se ve venir.
614
+ */
615
+ export function entregasSinRegistrar(cola, yaRegistradas) {
616
+ const clave = (h, s) => `${h ?? ""}\u0000${s ?? ""}`;
617
+ const vistas = new Set(yaRegistradas.map((r) => clave(r.advice_hash, r.head_sha)));
618
+ const fuera = [];
619
+ for (const e of cola) {
620
+ const k = clave(e.advice_hash, e.head_sha);
621
+ // Contra el servidor Y contra sí misma: la misma edición repetida dentro de
622
+ // la cola es una sola entrega, no dos.
623
+ if (vistas.has(k))
624
+ continue;
625
+ vistas.add(k);
626
+ fuera.push(e);
627
+ }
628
+ return fuera;
629
+ }
630
+ /**
631
+ * Vacía la cola contra `atlas_reads`. Corre en `impact --warm`, o sea en un
632
+ * proceso aparte del que edita y que ya paga la red de todas formas.
633
+ *
634
+ * NO se drena también en `changebook guard`: el warm corre en cuanto la caché
635
+ * caduca (5 minutos), así que durante una sesión de edición la cola se vacía
636
+ * sola una y otra vez. Lo que queda sin mandar al cerrar el editor sigue en el
637
+ * fichero y se manda en la siguiente sesión — se retrasa, no se pierde.
638
+ */
639
+ async function drenarCola(db, dir) {
640
+ const file = await queuePath(dir);
641
+ if (!file)
642
+ return;
643
+ let contenido;
644
+ try {
645
+ contenido = fs.readFileSync(file, "utf8");
646
+ }
647
+ catch {
648
+ return; // No hay cola: el caso normal.
649
+ }
650
+ const cola = entregasDeLaCola(contenido);
651
+ if (cola.length === 0) {
652
+ try {
653
+ fs.rmSync(file, { force: true });
654
+ }
655
+ catch {
656
+ /* da igual */
657
+ }
658
+ return;
659
+ }
660
+ const hashes = [...new Set(cola.map((e) => e.advice_hash))];
661
+ const yaRegistradas = await db
662
+ .rest(`atlas_reads?select=advice_hash,head_sha&advice_hash=in.(${hashes.join(",")})&limit=${hashes.length * 4}`)
663
+ .catch(() => []);
664
+ const pendientes = entregasSinRegistrar(cola, yaRegistradas);
665
+ for (const e of pendientes) {
666
+ const { project_id, chars_served, ...entrega } = e;
667
+ // Una a una y no en lote: un insert que falle no puede llevarse por delante
668
+ // las otras entregas, que ya no estarían en ningún sitio.
669
+ await db
670
+ .insertRow("atlas_reads", {
671
+ project_id,
672
+ tool: "impact_pretooluse",
673
+ source: "stdio",
674
+ chars_served,
675
+ ...entrega,
676
+ })
677
+ .catch(() => { });
678
+ }
679
+ // Se vacía DESPUÉS. Si el proceso muere aquí, la siguiente ronda pregunta y
680
+ // no duplica (ver `entregasSinRegistrar`).
681
+ try {
682
+ fs.rmSync(file, { force: true });
683
+ }
684
+ catch {
685
+ // La cola se recortará sola por tamaño; el dedup impide que se repitan.
686
+ }
687
+ }
288
688
  function leerJson(file) {
289
689
  if (!file)
290
690
  return null;
@@ -318,7 +718,14 @@ async function fetchAtlas(db, dir, env) {
318
718
  }
319
719
  const projectId = projects[0]?.id ?? null;
320
720
  if (!projectId) {
321
- return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
721
+ return {
722
+ v: IMPACT_CACHE_V,
723
+ fetched_at: Date.now(),
724
+ project_id: null,
725
+ rows: [],
726
+ deps: [],
727
+ alerts: [],
728
+ };
322
729
  }
323
730
  // TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
324
731
  // crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
@@ -336,16 +743,34 @@ async function fetchAtlas(db, dir, env) {
336
743
  // reincidencia (all-time) salen del mismo conjunto.
337
744
  const [rows, deps, alerts] = await Promise.all([
338
745
  db
339
- .rest(`change_module?select=module,files,risk,note,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
746
+ .rest(
747
+ // `changelog_id` para el co-cambio (ver ImpactCache["rows"]). Va en la
748
+ // consulta que YA se hacía, no en una cuarta: el co-cambio no puede
749
+ // costar una ronda de red más en el canal que corre en cada edición.
750
+ `change_module?select=module,files,risk,note,created_at,changelog_id&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
340
751
  .catch(() => []),
341
752
  db
342
753
  .rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
343
754
  .catch(() => []),
344
755
  db
345
- .rest(`regression_alerts?select=module,plain,files,resolved_at,evidence_symbol,evidence_expect&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
756
+ .rest(
757
+ // `evidence_scope` decide si el grep de refutación significa algo (ver
758
+ // el comentario del mismo select en guard.ts). `id` y `created_at` no
759
+ // los lee nadie TODAVÍA: son la identidad de la alerta, que es lo que
760
+ // hace falta para poder atribuir después "se te avisó de esto" a la fila
761
+ // que lo dijo. Vienen en la misma consulta porque son gratis aquí y una
762
+ // caché escrita sin ellos sobreviviría 5 minutos a quien los espere.
763
+ `regression_alerts?select=id,module,plain,created_at,files,resolved_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
346
764
  .catch(() => []),
347
765
  ]);
348
- return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
766
+ return {
767
+ v: IMPACT_CACHE_V,
768
+ fetched_at: Date.now(),
769
+ project_id: projectId,
770
+ rows,
771
+ deps,
772
+ alerts,
773
+ };
349
774
  }
350
775
  /**
351
776
  * Las señales, SIEMPRE de disco. Nunca de la red.
@@ -371,7 +796,10 @@ async function atlasSignals(db, dir) {
371
796
  if (!file)
372
797
  return null;
373
798
  const cached = leerJson(file);
374
- if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
799
+ // Una caché de otra versión NO se sirve y además dispara el recalentado, que
800
+ // es lo mismo que hace una caducada: en las dos, lo que hay en disco no
801
+ // responde a la pregunta que se está haciendo.
802
+ if (cacheServible(cached, Date.now()))
375
803
  return cached;
376
804
  if (db.hasCredentials())
377
805
  calentarDesacoplado(dir);
@@ -409,10 +837,16 @@ export async function warmImpactCache(db, dir, env = process.env) {
409
837
  const file = await cachePath(dir);
410
838
  if (!file)
411
839
  return;
840
+ // El libro de entregas ANTES de la puerta de frescura: si se pusiera
841
+ // después, una caché todavía fresca haría volver a este proceso por donde
842
+ // vino y la cola no se vaciaría nunca. El fallo sería invisible —el hook
843
+ // seguiría avisando igual— y "¿acertó el aviso?" seguiría sin respuesta por
844
+ // el canal más frecuente, que es justo lo que esto viene a arreglar.
845
+ await drenarCola(db, dir);
412
846
  // Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
413
847
  // fresca mientras este arrancaba, no gastar dos veces la misma consulta.
414
848
  const cached = leerJson(file);
415
- if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
849
+ if (cacheServible(cached, Date.now()))
416
850
  return;
417
851
  const fresco = await fetchAtlas(db, dir, env);
418
852
  // Un calentamiento que NO resolvió el proyecto no se cachea.
@@ -515,8 +949,23 @@ async function buildImpact(db, payload) {
515
949
  return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
516
950
  };
517
951
  const depsRows = signals.deps ?? [];
518
- const bloques = [];
952
+ // Co-cambio: UNA vez por corrida, no por módulo. `coChangePairs` recorre la
953
+ // ventana entera y ya trae sus propios umbrales (MIN_PAIR_COUNT=3,
954
+ // MIN_PAIR_RATE=0.6, MAX_COUPLINGS=5) y su exclusión de módulos de pruebas.
955
+ // Se importa de sync.ts en vez de copiarla: es la MISMA pregunta que contesta
956
+ // el bloque de CLAUDE.md, y dos respuestas distintas al mismo hecho es lo que
957
+ // enseña al agente a no fiarse de ninguna.
958
+ const parejas = coChangePairs(signals.rows.filter((r) => typeof r.changelog_id === "string" && r.changelog_id.length > 0));
959
+ /** Con quién cambia junto un módulo, mirando la pareja por los dos lados. */
960
+ const coCambiosDe = (modulo) => parejas
961
+ .filter((p) => p.a === modulo || p.b === modulo)
962
+ .map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
963
+ // Lineas con prioridad, no texto ya cocido: el techo se aplica al TOTAL, asi
964
+ // que hay que poder recortar despues de saber cuanto ocupa todo junto.
965
+ const lineas = [];
519
966
  const avisadas = [];
967
+ /** Las alertas que de verdad salieron en el texto. Ver el encolado del final. */
968
+ const idsServidos = [];
520
969
  for (const file of pendientes) {
521
970
  const modulos = modulosDelArchivo(file, signals.rows);
522
971
  if (modulos.length === 0)
@@ -529,10 +978,11 @@ async function buildImpact(db, payload) {
529
978
  note: m.note,
530
979
  noteDate: m.noteDate,
531
980
  dependents: dependientes.get(m.module) ?? [],
981
+ coChanges: coCambiosDe(m.module),
532
982
  // Una linea por texto: dos avisos con la MISMA frase no le dejan al
533
983
  // agente hacer nada distinto, por mucho que por dentro sean
534
984
  // afirmaciones opuestas. Gana el que trae linea de comprobacion.
535
- alerts: unoPorTexto(abiertas
985
+ alerts: conIdentidad(unoPorTexto(abiertas
536
986
  .filter((a) => (a.module ?? "").trim() === m.module && a.plain)
537
987
  .filter((a) => !refutada(a))
538
988
  .map((a) => {
@@ -540,12 +990,16 @@ async function buildImpact(db, payload) {
540
990
  const fuera = dondeComprobarlo(a);
541
991
  return {
542
992
  plain: a.plain,
993
+ alertIds: a.id ? [a.id] : [],
543
994
  ...(donde
544
995
  ? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
545
996
  : {}),
546
997
  ...(fuera ? { fueraDelRepo: fuera } : {}),
998
+ ...(a.evidence_line
999
+ ? { linea: String(a.evidence_line).trim() }
1000
+ : {}),
547
1001
  };
548
- }), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)),
1002
+ }), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)), abiertas.filter((a) => (a.module ?? "").trim() === m.module)),
549
1003
  priorRegressions: recidivism.get(m.module) ?? 0,
550
1004
  }))
551
1005
  .filter(valeLaPena)
@@ -564,15 +1018,44 @@ async function buildImpact(db, payload) {
564
1018
  a.module.localeCompare(b.module));
565
1019
  if (impactos.length === 0)
566
1020
  continue;
567
- bloques.push(impactText(file, impactos));
1021
+ lineas.push(...lineasDeImpacto(file, impactos));
568
1022
  avisadas.push(file);
1023
+ for (const m of impactos.slice(0, MAX_MODULES)) {
1024
+ idsServidos.push(...m.alerts.flatMap((a) => a.alertIds));
1025
+ }
569
1026
  }
570
1027
  // Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
571
1028
  // nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
572
1029
  if (avisadas.length > 0) {
573
1030
  escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
574
1031
  }
575
- return bloques.length > 0 ? bloques.join("\n\n") : null;
1032
+ if (lineas.length === 0)
1033
+ return null;
1034
+ // EL TECHO, sobre el total y no por fichero: una edición que toca cuatro
1035
+ // archivos servía cuatro bloques enteros, y el coste del canal se multiplicaba
1036
+ // sin que ningún tope lo viera. Ver IMPACT_BUDGET_CHARS.
1037
+ const texto = recortarAlPresupuesto(lineas);
1038
+ if (!texto)
1039
+ return null;
1040
+ // La entrega, anotada en disco. `MAX_MODULES` arriba no es cosmético: los
1041
+ // módulos que caen en el «+N more» NO se sirvieron, así que sus alertas no
1042
+ // pueden entrar en el registro de lo servido.
1043
+ //
1044
+ // headShaDe es un `git rev-parse` síncrono (~5 ms) y cabe de sobra en el
1045
+ // presupuesto; el hash de un texto de dos kilobytes no se mide. Lo que NO
1046
+ // entra aquí es la red: eso es del `--warm`.
1047
+ encolarEntrega(await queuePath(dir), {
1048
+ project_id: signals.project_id,
1049
+ chars_served: texto.length,
1050
+ ...entregaDe({
1051
+ alertIds: idsServidos,
1052
+ files: avisadas,
1053
+ message: texto,
1054
+ headSha: headShaDe(dir),
1055
+ hash: hashDelTexto,
1056
+ }),
1057
+ });
1058
+ return texto;
576
1059
  }
577
1060
  /**
578
1061
  * El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,