changebook 0.4.8 → 0.4.10

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 ADDED
@@ -0,0 +1,630 @@
1
+ /**
2
+ * `changebook impact` — el radio de impacto, EMPUJADO. Un hook `PreToolUse` de
3
+ * Claude Code que, justo antes de que el agente escriba en un archivo, le mete
4
+ * en contexto quién depende de lo que está a punto de tocar.
5
+ *
6
+ * POR QUÉ EXISTE, medido y no supuesto:
7
+ *
8
+ * El 2026-07-26, la tarea T4 del benchmark («vas a cambiar el comportamiento de
9
+ * computeRecidivism, ¿qué más hay que revisar?») se corrió con el atlas
10
+ * instalado y disponible. El agente hizo 32 turnos de grep, gastó 1.464.591
11
+ * tokens y llamó al atlas CERO veces: `atlas_adoption: 0`. El radio de impacto
12
+ * —desplegado, testado y en producción— fue invisible.
13
+ *
14
+ * La lección no es que faltara una tool. Es que un canal que depende de que el
15
+ * agente se acuerde de usarlo es un canal que se puede ignorar, y quien programa
16
+ * a base de «hazme esto» no pregunta nada: no hay turno en el que decida
17
+ * orientarse. Los disparadores literales de `sync.ts` mueven la aguja (60→100%
18
+ * medido el 2026-07-21) pero no la clavan: siguen necesitando que el agente
19
+ * reconozca una frase.
20
+ *
21
+ * Este canal no lo necesita. Salta porque va a editar, y punto.
22
+ *
23
+ * TRES REGLAS, y las tres son de supervivencia del producto:
24
+ *
25
+ * 1. NUNCA bloquea. Se emite `additionalContext` y nada más — sin
26
+ * `permissionDecision`, así que el sistema de permisos del usuario decide
27
+ * como si no estuviéramos. Un hook que puede tumbar una edición se
28
+ * desinstala el día que se equivoca.
29
+ * 2. NUNCA cuesta tiempo perceptible. Corre antes de CADA edición, así que la
30
+ * red se paga una vez cada CACHE_TTL_MS y el resto de las ediciones son una
31
+ * lectura de disco. Con presupuesto duro por encima: pasado eso, silencio.
32
+ * 3. NUNCA repite. Un aviso idéntico en las 20 ediciones seguidas del mismo
33
+ * archivo es ruido que se paga en tokens y que entrena al agente a
34
+ * ignorarlo. Se avisa una vez por (sesión, archivo).
35
+ *
36
+ * Y una cuarta que es la que hace que se lea: SILENCIO cuando no hay nada que
37
+ * decir. Sin dependientes, sin alerta abierta y sin reincidencia, no se emite
38
+ * una sola letra.
39
+ */
40
+ import { spawn } from "node:child_process";
41
+ import * as fs from "node:fs";
42
+ import * as path from "node:path";
43
+ import { presupuestoDeTiempo } from "./context.js";
44
+ import { execFileAsync, gitPath, projectNameFor } from "./git.js";
45
+ import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
46
+ import { computeRecidivism, dependentsOf } from "./tools.js";
47
+ /**
48
+ * Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
49
+ * este corre por edición y no por sesión: 20 ediciones × 2 s serían 40 s de
50
+ * impuesto sobre el bucle que este producto dice acelerar.
51
+ */
52
+ const IMPACT_TIMEOUT_MS = 1_200;
53
+ /** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
54
+ const CACHE_TTL_MS = 5 * 60_000;
55
+ /**
56
+ * Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
57
+ * ventanas distintas darían dependientes distintos según se pregunte o se
58
+ * empuje, y el agente no tiene forma de saber cuál de las dos le mintió.
59
+ */
60
+ const GRAPH_WINDOW_ROWS = 1_000;
61
+ /** Tope de filas de alertas; alimenta reincidencia (all-time) y abiertas. */
62
+ const ALERT_WINDOW_ROWS = 500;
63
+ /** Un archivo vuelve a avisar si se vuelve a él mucho después. */
64
+ const SEEN_TTL_MS = 30 * 60_000;
65
+ /** Cota del archivo de estado: es una nota, no un historial. */
66
+ const SEEN_MAX = 200;
67
+ /** Cuántos módulos se describen si el archivo pertenece a varios. */
68
+ const MAX_MODULES = 3;
69
+ /**
70
+ * Cuánto de la nota del análisis se sirve.
71
+ *
72
+ * SE SIRVE, y ese es el cambio del 2026-07-26 por la tarde. El hook nacía
73
+ * escueto a propósito —dependientes, alertas, reincidencia— y las notas se
74
+ * quedaron fuera por miedo al ruido. Midiendo se vio el coste de esa decisión:
75
+ * la nota de `Carga y arranque de la aplicación` sobre sync.ts dice «el
76
+ * SYNC_BUDGET_CHARS se rebaja de 3.000 a 2.000 porque la prosa gorda expulsaba
77
+ * el mapa», que es EXACTAMENTE el fallo en el que caí dos veces ese mismo día. Y
78
+ * es lo único que ningún grep puede encontrar: no está en el código, está en la
79
+ * historia del análisis. El canal estaba construido y servía lo que git ya sabe.
80
+ *
81
+ * Acotada, no libre: 320 chars por nota y solo en los módulos que YA se
82
+ * reportan (los que tienen dependientes, alerta abierta o reincidencia). Sin el
83
+ * tope, un archivo de diez módulos volcaría diez párrafos y el hook volvería a
84
+ * ser ruido que se aprende a ignorar.
85
+ *
86
+ * 320 y no 220: con 220, la nota de sync.ts se cortaba en «…porque la prosa
87
+ * gord…», justo antes de la conclusión. Estas notas describen primero y concluyen
88
+ * al final, así que un tope corto se come exactamente la carga útil y deja la
89
+ * paja. Tres notas de 320 son ~250 tokens una vez por archivo y sesión.
90
+ */
91
+ const MAX_NOTE_CHARS = 320;
92
+ /** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
93
+ const MAX_REFUTACIONES = 3;
94
+ export const IMPACT_HOOK = {
95
+ event: "PreToolUse",
96
+ // Mismo `2>/dev/null || true` que CONTEXT_HOOK y por lo mismo: un
97
+ // `changebook` que no está en el PATH de un compañero no puede convertirse en
98
+ // un hook que parece roto en cada edición.
99
+ command: "changebook impact 2>/dev/null || true",
100
+ marker: "changebook impact",
101
+ // Las cuatro tools que escriben en un archivo. El matcher es lo que evita
102
+ // arrancar un proceso por cada Read, Grep o Bash del agente.
103
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
104
+ };
105
+ /**
106
+ * La(s) ruta(s) que la tool va a escribir. `file_path` cubre Edit, Write y
107
+ * MultiEdit; `notebook_path` cubre NotebookEdit. Se devuelve lista porque el
108
+ * contrato puede crecer, no porque hoy haya dos.
109
+ */
110
+ export function pathsFromPayload(payload) {
111
+ const input = payload.tool_input ?? {};
112
+ return [input.file_path, input.notebook_path]
113
+ .filter((v) => typeof v === "string" && v.trim().length > 0)
114
+ .map((v) => v.trim());
115
+ }
116
+ /**
117
+ * Ruta con los enlaces simbólicos resueltos, incluso si la ruta no existe.
118
+ *
119
+ * No es defensa preventiva: sin esto el hook callaba SIEMPRE en macOS. `git
120
+ * rev-parse --show-toplevel` devuelve la ruta real (`/private/var/…`) y el
121
+ * payload del hook trae la que se usó para llegar (`/var/…`, que es un enlace al
122
+ * anterior). Son el mismo directorio, pero `path.relative` entre las dos daba
123
+ * `../../…`, la ruta se descartaba por «caer fuera del árbol», y no se avisaba
124
+ * de nada nunca. Fallo real, cazado por los tests el 2026-07-26.
125
+ *
126
+ * Sube hasta el primer ancestro que existe y recompone el resto, porque
127
+ * `realpathSync` falla con lo inexistente y con `Write` no tiene por qué existir
128
+ * ni el archivo ni sus carpetas.
129
+ */
130
+ function rutaRealProfunda(p) {
131
+ const abs = path.resolve(p);
132
+ const cola = [];
133
+ let actual = abs;
134
+ for (;;) {
135
+ try {
136
+ const real = fs.realpathSync(actual);
137
+ return cola.length > 0 ? path.join(real, ...cola.reverse()) : real;
138
+ }
139
+ catch {
140
+ const padre = path.dirname(actual);
141
+ if (padre === actual)
142
+ return abs; // se llegó a la raíz sin resolver nada
143
+ cola.push(path.basename(actual));
144
+ actual = padre;
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Ruta absoluta (lo que manda el hook) → ruta relativa al repo (lo que guarda
150
+ * el atlas). `null` cuando queda fuera del árbol: un archivo de /tmp o del home
151
+ * no tiene módulo, y colarlo como ruta con `../` produciría cero coincidencias
152
+ * de forma silenciosa.
153
+ */
154
+ export function repoRelative(toplevel, filePath) {
155
+ const raiz = rutaRealProfunda(toplevel);
156
+ const abs = path.isAbsolute(filePath)
157
+ ? rutaRealProfunda(filePath)
158
+ : path.resolve(raiz, filePath);
159
+ const rel = path.relative(raiz, abs);
160
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
161
+ return null;
162
+ // El atlas guarda rutas de git: siempre con "/", también en Windows.
163
+ return rel.split(path.sep).join("/");
164
+ }
165
+ /**
166
+ * ¿Merece este módulo un aviso?
167
+ *
168
+ * Es LA decisión del archivo. Se dice algo solo cuando hay algo que el agente
169
+ * no puede deducir del archivo que tiene delante: quién depende de él (no está
170
+ * escrito en ninguna parte del código), una alerta abierta (historia), o
171
+ * reincidencia (historia). El riesgo por sí solo NO cuenta: "risk: medium" en
172
+ * cada edición es exactamente el ruido que hace que se deje de leer.
173
+ */
174
+ export function valeLaPena(m) {
175
+ return (m.dependents.length > 0 || m.alerts.length > 0 || m.priorRegressions >= 2);
176
+ }
177
+ /**
178
+ * El texto que se inyecta. Deliberadamente calcado a `atlas_file_context`
179
+ * ("↘ DEPENDS ON THIS", "⚠ OPEN ALERT", "prior regressions"): el agente ya sabe
180
+ * qué hacer con esas palabras porque las ve cuando pregunta él. Dos redacciones
181
+ * para el mismo hecho serían dos hechos para él.
182
+ */
183
+ export function impactText(file, modules) {
184
+ const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
185
+ for (const m of modules.slice(0, MAX_MODULES)) {
186
+ // El riesgo se nombra solo cuando es alto: ver valeLaPena.
187
+ 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
+ : ""));
193
+ if (m.dependents.length > 0) {
194
+ lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
195
+ }
196
+ for (const a of m.alerts) {
197
+ lines.push(` - ⚠ OPEN ALERT: ${a.plain}`);
198
+ // La condicion, resuelta. Va en su propia linea y con el simbolo delante
199
+ // para que se lea como un hecho comprobado y no como parte de la prosa del
200
+ // modelo, que es justo la que hedgea.
201
+ //
202
+ // Y DICE LAS DOS LECTURAS a proposito, porque el hecho no distingue: un
203
+ // simbolo que sigue apareciendo cuando se esperaba ausente puede ser una
204
+ // referencia que se quedo sin actualizar (el caso `noscriptFor`, que rompio
205
+ // el build) o un aviso que ya no vale (el caso `registerTools`). Antes se
206
+ // elegia la segunda en silencio y se tiraba el aviso; ahora se le dan al
207
+ // agente el hecho y las dos salidas, que es mas informacion que la que
208
+ // habia en cualquiera de las dos versiones anteriores.
209
+ 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`);
212
+ }
213
+ // El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
214
+ // vez de dejar que el agente (o el refutador) la compruebe donde no era —
215
+ // que es lo que tiraba las dos alertas del desfase de esquema.
216
+ if (a.fueraDelRepo) {
217
+ lines.push(` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`);
218
+ }
219
+ }
220
+ // Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
221
+ // observación fechada, no estado vigente. Va DESPUÉS de dependientes y
222
+ // alertas porque es contexto, no una orden.
223
+ if (m.note) {
224
+ lines.push(` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`);
225
+ }
226
+ }
227
+ if (modules.length > MAX_MODULES) {
228
+ lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
229
+ }
230
+ return lines.join("\n");
231
+ }
232
+ /**
233
+ * Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
234
+ * borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
235
+ * aviso vuelve a hacer falta.
236
+ */
237
+ export function rutasYaAvisadas(state, sessionId, ahora) {
238
+ if (!state || state.session_id !== sessionId)
239
+ return new Set();
240
+ const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
241
+ return new Set(vivas.map(([ruta]) => ruta));
242
+ }
243
+ /** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
244
+ export function estadoSiguiente(state, sessionId, nuevas, ahora) {
245
+ const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
246
+ for (const ruta of nuevas)
247
+ base[ruta] = ahora;
248
+ const podado = Object.entries(base)
249
+ .filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
250
+ .sort((a, b) => b[1] - a[1])
251
+ .slice(0, SEEN_MAX);
252
+ return { session_id: sessionId, seen: Object.fromEntries(podado) };
253
+ }
254
+ /**
255
+ * Módulos a los que pertenece un archivo, del más reciente al más antiguo.
256
+ *
257
+ * UNIÓN de filas y no la última foto, por la misma razón que `moduleFilesUnion`
258
+ * del guardián: los archivos que toca un módulo cambian en cada análisis, y
259
+ * quedarse con la fila más nueva silenciaba coincidencias reales (visto en vivo
260
+ * el 2026-07-18). El riesgo, en cambio, sí es el de la fila más nueva: es
261
+ * estado, no historia.
262
+ */
263
+ export function modulosDelArchivo(file, rows) {
264
+ const out = new Map();
265
+ for (const row of rows) {
266
+ const label = (row.module ?? "").trim();
267
+ if (!label || out.has(label))
268
+ continue;
269
+ const files = Array.isArray(row.files) ? row.files.map(String) : [];
270
+ if (!files.includes(file))
271
+ continue;
272
+ const nota = (row.note ?? "").trim();
273
+ out.set(label, {
274
+ risk: row.risk,
275
+ note: nota
276
+ ? nota.length > MAX_NOTE_CHARS
277
+ ? `${nota.slice(0, MAX_NOTE_CHARS - 1).trimEnd()}…`
278
+ : nota
279
+ : null,
280
+ noteDate: (row.created_at ?? "").slice(0, 10) || null,
281
+ });
282
+ }
283
+ return [...out.entries()].map(([module, v]) => ({ module, ...v }));
284
+ }
285
+ async function cachePath(dir) {
286
+ return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
287
+ }
288
+ function leerJson(file) {
289
+ if (!file)
290
+ return null;
291
+ try {
292
+ return JSON.parse(fs.readFileSync(file, "utf8"));
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ function escribirJson(file, data) {
299
+ if (!file)
300
+ return;
301
+ try {
302
+ fs.writeFileSync(file, JSON.stringify(data));
303
+ }
304
+ catch {
305
+ // Un .git de solo lectura no puede convertirse en una edición fallida.
306
+ }
307
+ }
308
+ async function fetchAtlas(db, dir, env) {
309
+ // Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
310
+ // el nombre del directorio, casado por slug del servidor primero.
311
+ const candidate = projectNameFor(dir, env);
312
+ const slug = slugifyProject(candidate);
313
+ let projects = slug
314
+ ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
315
+ : [];
316
+ if (projects.length === 0) {
317
+ projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
318
+ }
319
+ const projectId = projects[0]?.id ?? null;
320
+ if (!projectId) {
321
+ return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
322
+ }
323
+ // TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
324
+ // crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
325
+ // que sean tres y no dos: `deps` y `files` viven en la misma tabla, pero no en
326
+ // la misma ventana.
327
+ //
328
+ // El grafo se pide con `deps=not.is.null`, exactamente como tools.ts. Traerlo
329
+ // de la ventana general costó un fallo real, cazado en el E2E del 2026-07-26:
330
+ // de 931 filas recientes solo 613 tenían grafo, así que el mismo módulo salía
331
+ // con 3 dependientes empujado y 4 preguntado. Dos respuestas para el mismo
332
+ // hecho, y el agente sin forma de saber cuál le mintió — el fallo contra el
333
+ // que yo mismo había escrito el comentario de GRAPH_WINDOW_ROWS.
334
+ //
335
+ // Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
336
+ // reincidencia (all-time) salen del mismo conjunto.
337
+ const [rows, deps, alerts] = await Promise.all([
338
+ db
339
+ .rest(`change_module?select=module,files,risk,note,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
340
+ .catch(() => []),
341
+ db
342
+ .rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
343
+ .catch(() => []),
344
+ 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}`)
346
+ .catch(() => []),
347
+ ]);
348
+ return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
349
+ }
350
+ /**
351
+ * Las señales, SIEMPRE de disco. Nunca de la red.
352
+ *
353
+ * Esto empezó consultando en línea y no funcionaba, y el fallo enseña más que el
354
+ * arreglo: medido contra producción el 2026-07-26, resolver el proyecto tarda
355
+ * ~590 ms y las dos consultas otros ~320 ms. Con el techo por edición no llegaba
356
+ * — y como no llegaba, nunca escribía la caché, así que TODAS las llamadas eran
357
+ * en frío para siempre. Una caché que solo se escribe al final de un camino que
358
+ * no termina no es una caché.
359
+ *
360
+ * Así que el camino crítico no toca la red jamás: lee el archivo, y si está frío
361
+ * o caducado lanza un refresco DESACOPLADO (`impact --warm`) y calla esta vez.
362
+ * Mismo patrón que el hook post-commit. El coste real es que la primera edición
363
+ * de cada ventana de 5 minutos no avisa; a cambio, las otras cuarenta cuestan
364
+ * una lectura de disco. En una sesión de vibe coding ese cambio es todo a favor.
365
+ */
366
+ async function atlasSignals(db, dir) {
367
+ const file = await cachePath(dir);
368
+ // Sin sitio donde guardar (no es un repo git, .git de solo lectura) no hay
369
+ // canal: calentar sería gastar un proceso y tres consultas para tirar el
370
+ // resultado a la basura.
371
+ if (!file)
372
+ return null;
373
+ const cached = leerJson(file);
374
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
375
+ return cached;
376
+ if (db.hasCredentials())
377
+ calentarDesacoplado(dir);
378
+ return null;
379
+ }
380
+ /**
381
+ * Lanza el refresco de la caché en un proceso aparte y se olvida de él.
382
+ *
383
+ * `stdio: "ignore"` no es higiene, es obligatorio: el stdout del hijo llegaría
384
+ * a la misma tubería que Claude Code lee como salida del hook, y un JSON a
385
+ * medias o dos objetos pegados sería basura inyectada en el contexto del agente.
386
+ * `detached` + `unref` para que el padre pueda morir ya y no retenga la edición.
387
+ */
388
+ function calentarDesacoplado(dir) {
389
+ try {
390
+ const hijo = spawn(process.execPath, [process.argv[1], "impact", "--warm", dir], {
391
+ detached: true,
392
+ stdio: "ignore",
393
+ });
394
+ hijo.unref();
395
+ }
396
+ catch {
397
+ // Sin poder lanzarlo, la caché se quedará fría y no habrá avisos. Mal, pero
398
+ // no tan mal como que falle una edición.
399
+ }
400
+ }
401
+ /**
402
+ * `changebook impact --warm [dir]` — el lado desacoplado. Consulta y escribe la
403
+ * caché. Sin stdin, sin stdout, sin código de salida que importe.
404
+ */
405
+ export async function warmImpactCache(db, dir, env = process.env) {
406
+ try {
407
+ if (!db.hasCredentials())
408
+ return;
409
+ const file = await cachePath(dir);
410
+ if (!file)
411
+ return;
412
+ // Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
413
+ // fresca mientras este arrancaba, no gastar dos veces la misma consulta.
414
+ const cached = leerJson(file);
415
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
416
+ return;
417
+ const fresco = await fetchAtlas(db, dir, env);
418
+ // Un calentamiento que NO resolvió el proyecto no se cachea.
419
+ //
420
+ // Cazado el 2026-07-26 montando el banco de pruebas: `impact --warm` sin
421
+ // CHANGEBOOK_PROJECT en un directorio cuyo nombre no es el del proyecto
422
+ // guardaba `{project_id: null, rows: []}`, y `atlasSignals` lo tomaba por
423
+ // caché válida durante CACHE_TTL_MS. Resultado: cinco minutos de silencio
424
+ // que se leen EXACTAMENTE igual que "no hay nada que decir" — la clase de
425
+ // fallo que este producto existe para cazar, dentro del producto.
426
+ //
427
+ // Sin cachearlo, el siguiente intento vuelve a preguntar. Cuesta un
428
+ // calentamiento desacoplado más; callar cinco minutos cuesta el canal.
429
+ if (!fresco.project_id)
430
+ return;
431
+ escribirJson(file, fresco);
432
+ }
433
+ catch {
434
+ // Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
435
+ // avisos, y eso ya lo cubre `hook-impact status`.
436
+ }
437
+ }
438
+ // ── El cuerpo ────────────────────────────────────────────────────────────────
439
+ /** stdin completo, acotado. El hook manda un objeto pequeño; nada más cabe. */
440
+ async function readStdin(limit = 256 * 1024) {
441
+ const chunks = [];
442
+ let total = 0;
443
+ for await (const chunk of process.stdin) {
444
+ const buf = Buffer.from(chunk);
445
+ total += buf.length;
446
+ if (total > limit)
447
+ break;
448
+ chunks.push(buf);
449
+ }
450
+ return Buffer.concat(chunks).toString("utf8");
451
+ }
452
+ async function buildImpact(db, payload) {
453
+ const rutas = pathsFromPayload(payload);
454
+ if (rutas.length === 0)
455
+ return null;
456
+ const dir = payload.cwd?.trim() || process.cwd();
457
+ // El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
458
+ // el atlas guarda rutas relativas a la raíz del repo.
459
+ const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
460
+ cwd: dir,
461
+ encoding: "utf8",
462
+ })
463
+ .then(({ stdout }) => stdout.trim())
464
+ .catch(() => dir);
465
+ const relativas = [
466
+ ...new Set(rutas
467
+ .map((r) => repoRelative(toplevel, r))
468
+ .filter((r) => Boolean(r))),
469
+ ];
470
+ if (relativas.length === 0)
471
+ return null;
472
+ const ahora = Date.now();
473
+ const sessionId = payload.session_id?.trim() || "sin-sesion";
474
+ const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
475
+ const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
476
+ const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
477
+ if (pendientes.length === 0)
478
+ return null;
479
+ const signals = await atlasSignals(db, dir);
480
+ if (!signals?.project_id)
481
+ return null;
482
+ const recidivism = computeRecidivism(signals.alerts);
483
+ const abiertas = signals.alerts.filter((a) => !a.resolved_at);
484
+ // Refutación al servir, igual que en atlas_file_context: el mismo grep que
485
+ // corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
486
+ // un grep. Aquí importa el doble: un aviso empujado que ya no es verdad
487
+ // enseña al agente a ignorar los empujados.
488
+ //
489
+ // Acotada a mano, y por una razón que no es cosmética: contarEnRepo usa
490
+ // execFileSync, o sea que BLOQUEA el bucle de eventos y el techo de arriba no
491
+ // puede desalojarlo — un Promise.race no gana a una llamada síncrona. Un
492
+ // archivo con muchas alertas vivas podría comerse el presupuesto entero a
493
+ // 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
494
+ // que es el lado seguro (la duda deja pasar el aviso).
495
+ //
496
+ // El presupuesto lo comparten refutar y localizar, porque son el mismo grep
497
+ // sobre el mismo simbolo y solo uno de los dos aplica a cada aviso: 'present'
498
+ // se refuta, 'absent' se localiza (ver avisoRefutado). Nunca se gastan dos.
499
+ let grepsRestantes = MAX_REFUTACIONES;
500
+ const refutada = (a) => {
501
+ if (a.evidence_expect !== "present")
502
+ return false;
503
+ if (grepsRestantes <= 0)
504
+ return false;
505
+ grepsRestantes -= 1;
506
+ return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
507
+ };
508
+ /** Donde sigue apareciendo el simbolo de un aviso 'absent'. Contesta el condicional. */
509
+ const localizar = (a) => {
510
+ if (a.evidence_expect !== "absent")
511
+ return null;
512
+ if (grepsRestantes <= 0)
513
+ return null;
514
+ grepsRestantes -= 1;
515
+ return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
516
+ };
517
+ const depsRows = signals.deps ?? [];
518
+ const bloques = [];
519
+ const avisadas = [];
520
+ for (const file of pendientes) {
521
+ const modulos = modulosDelArchivo(file, signals.rows);
522
+ if (modulos.length === 0)
523
+ continue;
524
+ const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
525
+ const impactos = modulos
526
+ .map((m) => ({
527
+ module: m.module,
528
+ risk: m.risk,
529
+ note: m.note,
530
+ noteDate: m.noteDate,
531
+ dependents: dependientes.get(m.module) ?? [],
532
+ // Una linea por texto: dos avisos con la MISMA frase no le dejan al
533
+ // agente hacer nada distinto, por mucho que por dentro sean
534
+ // afirmaciones opuestas. Gana el que trae linea de comprobacion.
535
+ alerts: unoPorTexto(abiertas
536
+ .filter((a) => (a.module ?? "").trim() === m.module && a.plain)
537
+ .filter((a) => !refutada(a))
538
+ .map((a) => {
539
+ const donde = localizar(a);
540
+ const fuera = dondeComprobarlo(a);
541
+ return {
542
+ plain: a.plain,
543
+ ...(donde
544
+ ? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
545
+ : {}),
546
+ ...(fuera ? { fueraDelRepo: fuera } : {}),
547
+ };
548
+ }), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)),
549
+ priorRegressions: recidivism.get(m.module) ?? 0,
550
+ }))
551
+ .filter(valeLaPena)
552
+ // Por PELIGRO, no por lo más reciente. Solo caben MAX_MODULES y el orden
553
+ // decide qué se ve: con el orden por recencia, el módulo cuya nota decía
554
+ // «la prosa gorda expulsaba el mapa» —6 regresiones previas, y el fallo
555
+ // exacto en el que caí dos veces el 2026-07-26— quedaba en el «+N more».
556
+ // Servir la nota no vale nada si la nota que importa no entra.
557
+ //
558
+ // Total y determinista: el desempate por nombre mantiene el string estable
559
+ // entre sesiones, que es lo que evita pagar una escritura de caché a 1,25×
560
+ // en vez de una lectura a 0,1×.
561
+ .sort((a, b) => b.alerts.length - a.alerts.length ||
562
+ b.priorRegressions - a.priorRegressions ||
563
+ b.dependents.length - a.dependents.length ||
564
+ a.module.localeCompare(b.module));
565
+ if (impactos.length === 0)
566
+ continue;
567
+ bloques.push(impactText(file, impactos));
568
+ avisadas.push(file);
569
+ }
570
+ // Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
571
+ // nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
572
+ if (avisadas.length > 0) {
573
+ escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
574
+ }
575
+ return bloques.length > 0 ? bloques.join("\n\n") : null;
576
+ }
577
+ /**
578
+ * El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
579
+ * con el JSON roto o pasado el presupuesto, se emite cero y se sale con 0.
580
+ *
581
+ * Se emite `additionalContext` a secas, sin `permissionDecision`: la decisión de
582
+ * permisos se queda en manos del usuario, exactamente como si este hook no
583
+ * existiera. Y por contrato de Claude Code, salir con 2 bloquearía la edición —
584
+ * de ahí que aquí no se lance nunca nada hacia fuera.
585
+ */
586
+ export async function printImpact(db) {
587
+ try {
588
+ // UN presupuesto para todo, no uno por etapa: dos carreras de 1,2 s en
589
+ // serie son 2,4 s de impuesto real por edición, que es justo lo que este
590
+ // techo existe para impedir. Se comparte entre leer stdin y leer la caché.
591
+ //
592
+ // Y se cancela en `finally`, no al final del camino feliz: los caminos que
593
+ // CALLAN son los más frecuentes (archivo limpio, ya avisado, caché fría) y
594
+ // cada `return` temprano dejaba el temporizador en pie. Medido el
595
+ // 2026-07-26: 1,39 s por edición silenciosa contra 0,23 s de la que habla —
596
+ // el proceso ya había terminado y se quedaba esperando a su propio reloj.
597
+ const { vencido, cancelar } = presupuestoDeTiempo(IMPACT_TIMEOUT_MS);
598
+ try {
599
+ const raw = await Promise.race([readStdin(), vencido.then(() => "")]);
600
+ if (!raw.trim())
601
+ return;
602
+ let payload;
603
+ try {
604
+ payload = JSON.parse(raw);
605
+ }
606
+ catch {
607
+ return;
608
+ }
609
+ const additionalContext = await Promise.race([
610
+ buildImpact(db, payload).catch(() => null),
611
+ vencido,
612
+ ]);
613
+ if (!additionalContext)
614
+ return;
615
+ process.stdout.write(JSON.stringify({
616
+ hookSpecificOutput: {
617
+ hookEventName: "PreToolUse",
618
+ additionalContext,
619
+ },
620
+ }));
621
+ }
622
+ finally {
623
+ cancelar();
624
+ }
625
+ }
626
+ catch {
627
+ // Editar un archivo no puede fallar por nosotros.
628
+ }
629
+ }
630
+ //# sourceMappingURL=impact.js.map
package/dist/import.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import * as path from "node:path";
12
12
  import { atlasWebUrl } from "./browser.js";
13
- import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
13
+ import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
14
14
  import { canonicalDiffHash } from "./canonical.js";
15
15
  import { optimizeTokensForAI } from "./optimize.js";
16
16
  // Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
@@ -29,7 +29,7 @@ const SKIP_FILE_PATTERNS = [
29
29
  ];
30
30
  export async function importHistory(db, options = {}) {
31
31
  const cwd = path.resolve(options.dir ?? process.cwd());
32
- const projectName = path.basename(cwd);
32
+ const projectName = projectNameFor(cwd);
33
33
  const count = Math.max(1, Math.min(options.commits ?? 25, 100));
34
34
  // A previous run may have been interrupted after submitting: the Anthropic
35
35
  // batch keeps running server-side, so finish those before spending more.