changebook 0.4.7 → 0.4.9

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,512 @@
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 } from "./git.js";
45
+ import { avisoRefutado, contarEnRepo, slugifyProject, } 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
+ /** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
70
+ const MAX_REFUTACIONES = 3;
71
+ export const IMPACT_HOOK = {
72
+ event: "PreToolUse",
73
+ // Mismo `2>/dev/null || true` que CONTEXT_HOOK y por lo mismo: un
74
+ // `changebook` que no está en el PATH de un compañero no puede convertirse en
75
+ // un hook que parece roto en cada edición.
76
+ command: "changebook impact 2>/dev/null || true",
77
+ marker: "changebook impact",
78
+ // Las cuatro tools que escriben en un archivo. El matcher es lo que evita
79
+ // arrancar un proceso por cada Read, Grep o Bash del agente.
80
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
81
+ };
82
+ /**
83
+ * La(s) ruta(s) que la tool va a escribir. `file_path` cubre Edit, Write y
84
+ * MultiEdit; `notebook_path` cubre NotebookEdit. Se devuelve lista porque el
85
+ * contrato puede crecer, no porque hoy haya dos.
86
+ */
87
+ export function pathsFromPayload(payload) {
88
+ const input = payload.tool_input ?? {};
89
+ return [input.file_path, input.notebook_path]
90
+ .filter((v) => typeof v === "string" && v.trim().length > 0)
91
+ .map((v) => v.trim());
92
+ }
93
+ /**
94
+ * Ruta con los enlaces simbólicos resueltos, incluso si la ruta no existe.
95
+ *
96
+ * No es defensa preventiva: sin esto el hook callaba SIEMPRE en macOS. `git
97
+ * rev-parse --show-toplevel` devuelve la ruta real (`/private/var/…`) y el
98
+ * payload del hook trae la que se usó para llegar (`/var/…`, que es un enlace al
99
+ * anterior). Son el mismo directorio, pero `path.relative` entre las dos daba
100
+ * `../../…`, la ruta se descartaba por «caer fuera del árbol», y no se avisaba
101
+ * de nada nunca. Fallo real, cazado por los tests el 2026-07-26.
102
+ *
103
+ * Sube hasta el primer ancestro que existe y recompone el resto, porque
104
+ * `realpathSync` falla con lo inexistente y con `Write` no tiene por qué existir
105
+ * ni el archivo ni sus carpetas.
106
+ */
107
+ function rutaRealProfunda(p) {
108
+ const abs = path.resolve(p);
109
+ const cola = [];
110
+ let actual = abs;
111
+ for (;;) {
112
+ try {
113
+ const real = fs.realpathSync(actual);
114
+ return cola.length > 0 ? path.join(real, ...cola.reverse()) : real;
115
+ }
116
+ catch {
117
+ const padre = path.dirname(actual);
118
+ if (padre === actual)
119
+ return abs; // se llegó a la raíz sin resolver nada
120
+ cola.push(path.basename(actual));
121
+ actual = padre;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Ruta absoluta (lo que manda el hook) → ruta relativa al repo (lo que guarda
127
+ * el atlas). `null` cuando queda fuera del árbol: un archivo de /tmp o del home
128
+ * no tiene módulo, y colarlo como ruta con `../` produciría cero coincidencias
129
+ * de forma silenciosa.
130
+ */
131
+ export function repoRelative(toplevel, filePath) {
132
+ const raiz = rutaRealProfunda(toplevel);
133
+ const abs = path.isAbsolute(filePath)
134
+ ? rutaRealProfunda(filePath)
135
+ : path.resolve(raiz, filePath);
136
+ const rel = path.relative(raiz, abs);
137
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
138
+ return null;
139
+ // El atlas guarda rutas de git: siempre con "/", también en Windows.
140
+ return rel.split(path.sep).join("/");
141
+ }
142
+ /**
143
+ * ¿Merece este módulo un aviso?
144
+ *
145
+ * Es LA decisión del archivo. Se dice algo solo cuando hay algo que el agente
146
+ * no puede deducir del archivo que tiene delante: quién depende de él (no está
147
+ * escrito en ninguna parte del código), una alerta abierta (historia), o
148
+ * reincidencia (historia). El riesgo por sí solo NO cuenta: "risk: medium" en
149
+ * cada edición es exactamente el ruido que hace que se deje de leer.
150
+ */
151
+ export function valeLaPena(m) {
152
+ return (m.dependents.length > 0 || m.alerts.length > 0 || m.priorRegressions >= 2);
153
+ }
154
+ /**
155
+ * El texto que se inyecta. Deliberadamente calcado a `atlas_file_context`
156
+ * ("↘ DEPENDS ON THIS", "⚠ OPEN ALERT", "prior regressions"): el agente ya sabe
157
+ * qué hacer con esas palabras porque las ve cuando pregunta él. Dos redacciones
158
+ * para el mismo hecho serían dos hechos para él.
159
+ */
160
+ export function impactText(file, modules) {
161
+ const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
162
+ for (const m of modules.slice(0, MAX_MODULES)) {
163
+ // El riesgo se nombra solo cuando es alto: ver valeLaPena.
164
+ const alto = m.risk === "high" || m.risk === "hotspot";
165
+ lines.push(`- Module **${m.module}**` +
166
+ (alto ? ` · risk: ${m.risk}` : "") +
167
+ (m.priorRegressions >= 2
168
+ ? ` · ⚠ ${m.priorRegressions} prior regressions`
169
+ : ""));
170
+ if (m.dependents.length > 0) {
171
+ lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
172
+ }
173
+ for (const plain of m.alerts)
174
+ lines.push(` - ⚠ OPEN ALERT: ${plain}`);
175
+ }
176
+ if (modules.length > MAX_MODULES) {
177
+ lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+ /**
182
+ * Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
183
+ * borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
184
+ * aviso vuelve a hacer falta.
185
+ */
186
+ export function rutasYaAvisadas(state, sessionId, ahora) {
187
+ if (!state || state.session_id !== sessionId)
188
+ return new Set();
189
+ const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
190
+ return new Set(vivas.map(([ruta]) => ruta));
191
+ }
192
+ /** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
193
+ export function estadoSiguiente(state, sessionId, nuevas, ahora) {
194
+ const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
195
+ for (const ruta of nuevas)
196
+ base[ruta] = ahora;
197
+ const podado = Object.entries(base)
198
+ .filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
199
+ .sort((a, b) => b[1] - a[1])
200
+ .slice(0, SEEN_MAX);
201
+ return { session_id: sessionId, seen: Object.fromEntries(podado) };
202
+ }
203
+ /**
204
+ * Módulos a los que pertenece un archivo, del más reciente al más antiguo.
205
+ *
206
+ * UNIÓN de filas y no la última foto, por la misma razón que `moduleFilesUnion`
207
+ * del guardián: los archivos que toca un módulo cambian en cada análisis, y
208
+ * quedarse con la fila más nueva silenciaba coincidencias reales (visto en vivo
209
+ * el 2026-07-18). El riesgo, en cambio, sí es el de la fila más nueva: es
210
+ * estado, no historia.
211
+ */
212
+ export function modulosDelArchivo(file, rows) {
213
+ const out = new Map();
214
+ for (const row of rows) {
215
+ const label = (row.module ?? "").trim();
216
+ if (!label || out.has(label))
217
+ continue;
218
+ const files = Array.isArray(row.files) ? row.files.map(String) : [];
219
+ if (files.includes(file))
220
+ out.set(label, row.risk);
221
+ }
222
+ return [...out.entries()].map(([module, risk]) => ({ module, risk }));
223
+ }
224
+ async function cachePath(dir) {
225
+ return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
226
+ }
227
+ function leerJson(file) {
228
+ if (!file)
229
+ return null;
230
+ try {
231
+ return JSON.parse(fs.readFileSync(file, "utf8"));
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ function escribirJson(file, data) {
238
+ if (!file)
239
+ return;
240
+ try {
241
+ fs.writeFileSync(file, JSON.stringify(data));
242
+ }
243
+ catch {
244
+ // Un .git de solo lectura no puede convertirse en una edición fallida.
245
+ }
246
+ }
247
+ async function fetchAtlas(db, dir, env) {
248
+ // Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
249
+ // el nombre del directorio, casado por slug del servidor primero.
250
+ const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
251
+ const slug = slugifyProject(candidate);
252
+ let projects = slug
253
+ ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
254
+ : [];
255
+ if (projects.length === 0) {
256
+ projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
257
+ }
258
+ const projectId = projects[0]?.id ?? null;
259
+ if (!projectId) {
260
+ return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
261
+ }
262
+ // TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
263
+ // crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
264
+ // que sean tres y no dos: `deps` y `files` viven en la misma tabla, pero no en
265
+ // la misma ventana.
266
+ //
267
+ // El grafo se pide con `deps=not.is.null`, exactamente como tools.ts. Traerlo
268
+ // de la ventana general costó un fallo real, cazado en el E2E del 2026-07-26:
269
+ // de 931 filas recientes solo 613 tenían grafo, así que el mismo módulo salía
270
+ // con 3 dependientes empujado y 4 preguntado. Dos respuestas para el mismo
271
+ // hecho, y el agente sin forma de saber cuál le mintió — el fallo contra el
272
+ // que yo mismo había escrito el comentario de GRAPH_WINDOW_ROWS.
273
+ //
274
+ // Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
275
+ // reincidencia (all-time) salen del mismo conjunto.
276
+ const [rows, deps, alerts] = await Promise.all([
277
+ db
278
+ .rest(`change_module?select=module,files,risk,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
279
+ .catch(() => []),
280
+ db
281
+ .rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
282
+ .catch(() => []),
283
+ db
284
+ .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}`)
285
+ .catch(() => []),
286
+ ]);
287
+ return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
288
+ }
289
+ /**
290
+ * Las señales, SIEMPRE de disco. Nunca de la red.
291
+ *
292
+ * Esto empezó consultando en línea y no funcionaba, y el fallo enseña más que el
293
+ * arreglo: medido contra producción el 2026-07-26, resolver el proyecto tarda
294
+ * ~590 ms y las dos consultas otros ~320 ms. Con el techo por edición no llegaba
295
+ * — y como no llegaba, nunca escribía la caché, así que TODAS las llamadas eran
296
+ * en frío para siempre. Una caché que solo se escribe al final de un camino que
297
+ * no termina no es una caché.
298
+ *
299
+ * Así que el camino crítico no toca la red jamás: lee el archivo, y si está frío
300
+ * o caducado lanza un refresco DESACOPLADO (`impact --warm`) y calla esta vez.
301
+ * Mismo patrón que el hook post-commit. El coste real es que la primera edición
302
+ * de cada ventana de 5 minutos no avisa; a cambio, las otras cuarenta cuestan
303
+ * una lectura de disco. En una sesión de vibe coding ese cambio es todo a favor.
304
+ */
305
+ async function atlasSignals(db, dir) {
306
+ const file = await cachePath(dir);
307
+ // Sin sitio donde guardar (no es un repo git, .git de solo lectura) no hay
308
+ // canal: calentar sería gastar un proceso y tres consultas para tirar el
309
+ // resultado a la basura.
310
+ if (!file)
311
+ return null;
312
+ const cached = leerJson(file);
313
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
314
+ return cached;
315
+ if (db.hasCredentials())
316
+ calentarDesacoplado(dir);
317
+ return null;
318
+ }
319
+ /**
320
+ * Lanza el refresco de la caché en un proceso aparte y se olvida de él.
321
+ *
322
+ * `stdio: "ignore"` no es higiene, es obligatorio: el stdout del hijo llegaría
323
+ * a la misma tubería que Claude Code lee como salida del hook, y un JSON a
324
+ * medias o dos objetos pegados sería basura inyectada en el contexto del agente.
325
+ * `detached` + `unref` para que el padre pueda morir ya y no retenga la edición.
326
+ */
327
+ function calentarDesacoplado(dir) {
328
+ try {
329
+ const hijo = spawn(process.execPath, [process.argv[1], "impact", "--warm", dir], {
330
+ detached: true,
331
+ stdio: "ignore",
332
+ });
333
+ hijo.unref();
334
+ }
335
+ catch {
336
+ // Sin poder lanzarlo, la caché se quedará fría y no habrá avisos. Mal, pero
337
+ // no tan mal como que falle una edición.
338
+ }
339
+ }
340
+ /**
341
+ * `changebook impact --warm [dir]` — el lado desacoplado. Consulta y escribe la
342
+ * caché. Sin stdin, sin stdout, sin código de salida que importe.
343
+ */
344
+ export async function warmImpactCache(db, dir, env = process.env) {
345
+ try {
346
+ if (!db.hasCredentials())
347
+ return;
348
+ const file = await cachePath(dir);
349
+ if (!file)
350
+ return;
351
+ // Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
352
+ // fresca mientras este arrancaba, no gastar dos veces la misma consulta.
353
+ const cached = leerJson(file);
354
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
355
+ return;
356
+ escribirJson(file, await fetchAtlas(db, dir, env));
357
+ }
358
+ catch {
359
+ // Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
360
+ // avisos, y eso ya lo cubre `hook-impact status`.
361
+ }
362
+ }
363
+ // ── El cuerpo ────────────────────────────────────────────────────────────────
364
+ /** stdin completo, acotado. El hook manda un objeto pequeño; nada más cabe. */
365
+ async function readStdin(limit = 256 * 1024) {
366
+ const chunks = [];
367
+ let total = 0;
368
+ for await (const chunk of process.stdin) {
369
+ const buf = Buffer.from(chunk);
370
+ total += buf.length;
371
+ if (total > limit)
372
+ break;
373
+ chunks.push(buf);
374
+ }
375
+ return Buffer.concat(chunks).toString("utf8");
376
+ }
377
+ async function buildImpact(db, payload) {
378
+ const rutas = pathsFromPayload(payload);
379
+ if (rutas.length === 0)
380
+ return null;
381
+ const dir = payload.cwd?.trim() || process.cwd();
382
+ // El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
383
+ // el atlas guarda rutas relativas a la raíz del repo.
384
+ const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
385
+ cwd: dir,
386
+ encoding: "utf8",
387
+ })
388
+ .then(({ stdout }) => stdout.trim())
389
+ .catch(() => dir);
390
+ const relativas = [
391
+ ...new Set(rutas
392
+ .map((r) => repoRelative(toplevel, r))
393
+ .filter((r) => Boolean(r))),
394
+ ];
395
+ if (relativas.length === 0)
396
+ return null;
397
+ const ahora = Date.now();
398
+ const sessionId = payload.session_id?.trim() || "sin-sesion";
399
+ const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
400
+ const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
401
+ const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
402
+ if (pendientes.length === 0)
403
+ return null;
404
+ const signals = await atlasSignals(db, dir);
405
+ if (!signals?.project_id)
406
+ return null;
407
+ const recidivism = computeRecidivism(signals.alerts);
408
+ const abiertas = signals.alerts.filter((a) => !a.resolved_at);
409
+ // Refutación al servir, igual que en atlas_file_context: el mismo grep que
410
+ // corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
411
+ // un grep. Aquí importa el doble: un aviso empujado que ya no es verdad
412
+ // enseña al agente a ignorar los empujados.
413
+ //
414
+ // Acotada a mano, y por una razón que no es cosmética: contarEnRepo usa
415
+ // execFileSync, o sea que BLOQUEA el bucle de eventos y el techo de arriba no
416
+ // puede desalojarlo — un Promise.race no gana a una llamada síncrona. Un
417
+ // archivo con muchas alertas vivas podría comerse el presupuesto entero a
418
+ // 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
419
+ // que es el lado seguro (la duda deja pasar el aviso).
420
+ let grepsRestantes = MAX_REFUTACIONES;
421
+ const refutada = (a) => {
422
+ if (grepsRestantes <= 0)
423
+ return false;
424
+ grepsRestantes -= 1;
425
+ return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
426
+ };
427
+ const depsRows = signals.deps ?? [];
428
+ const bloques = [];
429
+ const avisadas = [];
430
+ for (const file of pendientes) {
431
+ const modulos = modulosDelArchivo(file, signals.rows);
432
+ if (modulos.length === 0)
433
+ continue;
434
+ const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
435
+ const impactos = modulos
436
+ .map((m) => ({
437
+ module: m.module,
438
+ risk: m.risk,
439
+ dependents: dependientes.get(m.module) ?? [],
440
+ alerts: abiertas
441
+ .filter((a) => (a.module ?? "").trim() === m.module && a.plain)
442
+ .filter((a) => !refutada(a))
443
+ .map((a) => a.plain),
444
+ priorRegressions: recidivism.get(m.module) ?? 0,
445
+ }))
446
+ .filter(valeLaPena);
447
+ if (impactos.length === 0)
448
+ continue;
449
+ bloques.push(impactText(file, impactos));
450
+ avisadas.push(file);
451
+ }
452
+ // Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
453
+ // nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
454
+ if (avisadas.length > 0) {
455
+ escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
456
+ }
457
+ return bloques.length > 0 ? bloques.join("\n\n") : null;
458
+ }
459
+ /**
460
+ * El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
461
+ * con el JSON roto o pasado el presupuesto, se emite cero y se sale con 0.
462
+ *
463
+ * Se emite `additionalContext` a secas, sin `permissionDecision`: la decisión de
464
+ * permisos se queda en manos del usuario, exactamente como si este hook no
465
+ * existiera. Y por contrato de Claude Code, salir con 2 bloquearía la edición —
466
+ * de ahí que aquí no se lance nunca nada hacia fuera.
467
+ */
468
+ export async function printImpact(db) {
469
+ try {
470
+ // UN presupuesto para todo, no uno por etapa: dos carreras de 1,2 s en
471
+ // serie son 2,4 s de impuesto real por edición, que es justo lo que este
472
+ // techo existe para impedir. Se comparte entre leer stdin y leer la caché.
473
+ //
474
+ // Y se cancela en `finally`, no al final del camino feliz: los caminos que
475
+ // CALLAN son los más frecuentes (archivo limpio, ya avisado, caché fría) y
476
+ // cada `return` temprano dejaba el temporizador en pie. Medido el
477
+ // 2026-07-26: 1,39 s por edición silenciosa contra 0,23 s de la que habla —
478
+ // el proceso ya había terminado y se quedaba esperando a su propio reloj.
479
+ const { vencido, cancelar } = presupuestoDeTiempo(IMPACT_TIMEOUT_MS);
480
+ try {
481
+ const raw = await Promise.race([readStdin(), vencido.then(() => "")]);
482
+ if (!raw.trim())
483
+ return;
484
+ let payload;
485
+ try {
486
+ payload = JSON.parse(raw);
487
+ }
488
+ catch {
489
+ return;
490
+ }
491
+ const additionalContext = await Promise.race([
492
+ buildImpact(db, payload).catch(() => null),
493
+ vencido,
494
+ ]);
495
+ if (!additionalContext)
496
+ return;
497
+ process.stdout.write(JSON.stringify({
498
+ hookSpecificOutput: {
499
+ hookEventName: "PreToolUse",
500
+ additionalContext,
501
+ },
502
+ }));
503
+ }
504
+ finally {
505
+ cancelar();
506
+ }
507
+ }
508
+ catch {
509
+ // Editar un archivo no puede fallar por nosotros.
510
+ }
511
+ }
512
+ //# sourceMappingURL=impact.js.map
package/dist/index.js CHANGED
@@ -16,11 +16,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
16
  import { analyze } from "./analyze.js";
17
17
  import { atlasWebUrl, openInBrowser } from "./browser.js";
18
18
  import { clearCredentials, credentialsPath } from "./credentials.js";
19
+ import { recordFeed } from "./feed.js";
19
20
  import { runGuard } from "./guard.js";
20
21
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
21
22
  import { importHistory } from "./import.js";
22
23
  import { registerAgents } from "./init.js";
23
- import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
24
+ import { contextHookInstalled, installContextHook, installSettingsHook, printContext, settingsHookInstalled, uninstallContextHook, uninstallSettingsHook, } from "./context.js";
25
+ import { IMPACT_HOOK, printImpact, warmImpactCache } from "./impact.js";
24
26
  import { login } from "./login.js";
25
27
  import { AUTH_HELP, Supabase } from "./supabase.js";
26
28
  import { syncContextFiles } from "./sync.js";
@@ -51,6 +53,12 @@ Usage:
51
53
  changebook hook-context install|uninstall|status [dir]
52
54
  Push the atlas into EVERY Claude Code session at turn 0
53
55
  via a SessionStart hook in .claude/settings.json
56
+ changebook impact Print the blast radius of the file a PreToolUse hook
57
+ payload (on stdin) is about to edit
58
+ changebook hook-impact install|uninstall|status [dir]
59
+ Tell the agent who depends on a file BEFORE it edits it,
60
+ via a PreToolUse hook in .claude/settings.json. Never
61
+ blocks an edit; silent when there is nothing to say
54
62
  changebook init [dir] login + register MCP in every agent found + hook + sync
55
63
  changebook open Open the web atlas in the browser
56
64
  changebook serve Run the MCP server on stdio (default with no arguments)
@@ -136,9 +144,36 @@ async function main() {
136
144
  }
137
145
  case "analyze": {
138
146
  const db = new Supabase();
139
- requireCredentials(db);
140
147
  const options = parseAnalyzeArgs(process.argv.slice(3));
141
- await analyze(db, options);
148
+ const dir = options.dir ?? process.cwd();
149
+ // --commit IS the post-commit hook's path: the automatic feed, running
150
+ // detached where nobody reads its output. Record whether it landed so the
151
+ // next pre-commit can say it out loud (feed.ts). A manual `changebook
152
+ // analyze` is someone watching the terminal — no pulse needed, and
153
+ // recording one would let a hand-run failure warn about the hook.
154
+ const feeding = Boolean(options.commit);
155
+ if (!db.hasCredentials()) {
156
+ // Not requireCredentials(): it exits, and being logged out is exactly
157
+ // the failure the pulse exists to surface.
158
+ if (feeding)
159
+ await recordFeed(dir, { ok: false, reason: "No stored session." });
160
+ console.error(AUTH_HELP);
161
+ process.exit(1);
162
+ }
163
+ try {
164
+ await analyze(db, options);
165
+ }
166
+ catch (error) {
167
+ if (feeding) {
168
+ await recordFeed(dir, {
169
+ ok: false,
170
+ reason: error instanceof Error ? error.message : String(error),
171
+ });
172
+ }
173
+ throw error;
174
+ }
175
+ if (feeding)
176
+ await recordFeed(dir, { ok: true });
142
177
  // El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
143
178
  // así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
144
179
  // causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
@@ -147,9 +182,7 @@ async function main() {
147
182
  // archivos ni resucita un bloque borrado. Best-effort: un sync caído no
148
183
  // puede tumbar un análisis ya cobrado y registrado.
149
184
  try {
150
- await syncContextFiles(db, options.dir ?? process.cwd(), {
151
- refreshOnly: true,
152
- });
185
+ await syncContextFiles(db, dir, { refreshOnly: true });
153
186
  }
154
187
  catch (error) {
155
188
  console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
@@ -167,7 +200,14 @@ async function main() {
167
200
  // repo must commit exactly as before — runGuard resolves every failure
168
201
  // to exit 0 itself. The explicit exit also drops any fetch still racing
169
202
  // the timeout, so the commit never waits on a dangling socket.
170
- return process.exit(await runGuard(new Supabase(), arg ?? process.cwd()));
203
+ //
204
+ // allowRefresh: false — and it is that same exit that makes it necessary.
205
+ // A dropped fetch is harmless unless it happens to be the token refresh:
206
+ // Supabase rotates on receipt, so dying mid-flight burns the stored token
207
+ // without saving its replacement and logs the user out of everything.
208
+ // The guard is an optional warning with a 3.5s budget; it must never be
209
+ // able to cost a session. Expired access token → no warning this time.
210
+ return process.exit(await runGuard(new Supabase(process.env, { allowRefresh: false }), arg ?? process.cwd()));
171
211
  }
172
212
  case "hook": {
173
213
  const dir = process.argv[4] ?? process.cwd();
@@ -213,6 +253,43 @@ async function main() {
213
253
  }
214
254
  return;
215
255
  }
256
+ case "impact": {
257
+ // --warm es el lado DESACOPLADO, el que sí toca la red: lo lanza el propio
258
+ // hook en un proceso aparte cuando encuentra la caché fría. No lee stdin y
259
+ // no escribe en stdout (ver calentarDesacoplado: su salida iría a la
260
+ // tubería que Claude Code lee como respuesta del hook).
261
+ if (arg === "--warm") {
262
+ await warmImpactCache(new Supabase(), process.argv[4] ?? process.cwd());
263
+ return;
264
+ }
265
+ // Camino crítico de CADA edición: como `context`, jamás requireCredentials
266
+ // (saldría con 1) y jamás help. printImpact falla abierto — sin sesión,
267
+ // sin caché o lento, cero salida y exit 0. Y nunca sale con 2, que es el
268
+ // código con el que Claude Code BLOQUEA la edición.
269
+ await printImpact(new Supabase());
270
+ return;
271
+ }
272
+ case "hook-impact": {
273
+ const dir = process.argv[4] ?? process.cwd();
274
+ if (arg === "install") {
275
+ const r = installSettingsHook(dir, IMPACT_HOOK);
276
+ console.error(r === "installed"
277
+ ? `✓ PreToolUse hook installed (${dir}/.claude/settings.json). Before every edit, the agent now gets told who depends on the file it is about to touch.`
278
+ : "PreToolUse hook already installed.");
279
+ }
280
+ else if (arg === "uninstall") {
281
+ const r = uninstallSettingsHook(dir, IMPACT_HOOK);
282
+ console.error(r === "removed"
283
+ ? "✓ PreToolUse hook removed."
284
+ : "No ChangeBook PreToolUse hook found.");
285
+ }
286
+ else {
287
+ console.error(settingsHookInstalled(dir, IMPACT_HOOK)
288
+ ? `✓ ChangeBook PreToolUse hook installed (${dir}/.claude/settings.json).`
289
+ : "✗ No ChangeBook PreToolUse hook. Install with: changebook hook-impact install");
290
+ }
291
+ return;
292
+ }
216
293
  case "init": {
217
294
  let db = new Supabase();
218
295
  if (!db.hasCredentials()) {
@@ -239,6 +316,15 @@ async function main() {
239
316
  console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
240
317
  "(no tool call needed, never stale):\n changebook hook-context install");
241
318
  }
319
+ // El segundo canal se ofrece aparte porque responde a otra pregunta. El
320
+ // primero da el mapa al abrir; este avisa de a quién te llevas por delante
321
+ // justo antes de escribir, que es lo que hace falta cuando nadie pregunta
322
+ // nada. Se ofrece, NUNCA se instala en silencio: mismo motivo que el otro,
323
+ // .claude/settings.json se commitea y se comparte.
324
+ if (!settingsHookInstalled(dir, IMPACT_HOOK)) {
325
+ console.error("\nOptional: before every edit, tell the agent who depends on the file " +
326
+ "it is about to touch:\n changebook hook-impact install");
327
+ }
242
328
  console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
243
329
  return;
244
330
  }