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/index.js CHANGED
@@ -21,7 +21,8 @@ import { runGuard } from "./guard.js";
21
21
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
22
22
  import { importHistory } from "./import.js";
23
23
  import { registerAgents } from "./init.js";
24
- 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";
25
26
  import { login } from "./login.js";
26
27
  import { AUTH_HELP, Supabase } from "./supabase.js";
27
28
  import { syncContextFiles } from "./sync.js";
@@ -52,6 +53,12 @@ Usage:
52
53
  changebook hook-context install|uninstall|status [dir]
53
54
  Push the atlas into EVERY Claude Code session at turn 0
54
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
55
62
  changebook init [dir] login + register MCP in every agent found + hook + sync
56
63
  changebook open Open the web atlas in the browser
57
64
  changebook serve Run the MCP server on stdio (default with no arguments)
@@ -246,6 +253,43 @@ async function main() {
246
253
  }
247
254
  return;
248
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
+ }
249
293
  case "init": {
250
294
  let db = new Supabase();
251
295
  if (!db.hasCredentials()) {
@@ -272,6 +316,15 @@ async function main() {
272
316
  console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
273
317
  "(no tool call needed, never stale):\n changebook hook-context install");
274
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
+ }
275
328
  console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
276
329
  return;
277
330
  }
package/dist/login.js CHANGED
@@ -15,6 +15,38 @@ import * as http from "node:http";
15
15
  import { atlasWebUrl, openInBrowser } from "./browser.js";
16
16
  import { credentialsPath, saveCredentials } from "./credentials.js";
17
17
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
18
+ const SUPABASE_URL = process.env.CHANGEBOOK_SUPABASE_URL ??
19
+ "https://oyosihxkecspjkiligga.supabase.co";
20
+ const ANON_KEY = process.env.CHANGEBOOK_SUPABASE_ANON_KEY ??
21
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
22
+ /**
23
+ * Canjea un codigo de un solo uso por una sesion PROPIA de este CLI.
24
+ *
25
+ * Espejo de `exchangeTokenHash` de la extension (src/auth/supabaseAuth.ts), que
26
+ * lleva usando este camino desde que se diagnostico el problema. El backend
27
+ * acuña el codigo con `generateLink({type:"magiclink"})` en la funcion
28
+ * editor-handoff — no se manda ningun correo — y aqui se cambia por tokens que
29
+ * no comparte nadie mas.
30
+ */
31
+ async function canjearTokenHash(tokenHash) {
32
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/verify`, {
33
+ method: "POST",
34
+ headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
35
+ body: JSON.stringify({ type: "magiclink", token_hash: tokenHash }),
36
+ signal: AbortSignal.timeout(30_000),
37
+ });
38
+ if (!res.ok) {
39
+ throw new Error(`No se pudo canjear el codigo de acceso (${res.status}). Vuelve a intentarlo.`);
40
+ }
41
+ const data = (await res.json());
42
+ if (!data.refresh_token) {
43
+ throw new Error("El canje no devolvio una sesion utilizable.");
44
+ }
45
+ return {
46
+ access_token: data.access_token ?? undefined,
47
+ refresh_token: data.refresh_token,
48
+ };
49
+ }
18
50
  const CALLBACK_PAGE = `<!DOCTYPE html>
19
51
  <html><head><meta charset="utf-8"><title>ChangeBook</title></head>
20
52
  <body style="font-family: sans-serif; max-width: 480px; margin: 80px auto; text-align: center;">
@@ -22,6 +54,7 @@ const CALLBACK_PAGE = `<!DOCTYPE html>
22
54
  <script>
23
55
  const params = new URLSearchParams(window.location.hash.slice(1));
24
56
  const payload = {
57
+ token_hash: params.get("token_hash"),
25
58
  access_token: params.get("access_token"),
26
59
  refresh_token: params.get("refresh_token"),
27
60
  state: params.get("state"),
@@ -62,6 +95,28 @@ export async function login() {
62
95
  res.end();
63
96
  return;
64
97
  }
98
+ // VIA PREFERIDA: un codigo de un solo uso que se canjea por una
99
+ // sesion PROPIA de este CLI. La via heredada —recibir los tokens
100
+ // vivos de la pestana web— entrega a dos clientes el MISMO refresh
101
+ // token rotatorio, y Supabase revoca la familia entera en cuanto uno
102
+ // de los dos lo rota. Como el navegador refresca solo en segundo
103
+ // plano, siempre gana el, y la sesion del CLI moria ~30 min despues
104
+ // de cada login, con reloj. La extension ya usaba este camino desde
105
+ // que se diagnostico; al CLI no se le habia traido.
106
+ if (data.token_hash) {
107
+ canjearTokenHash(data.token_hash).then((sesion) => {
108
+ res.writeHead(200, { "Content-Type": "application/json" });
109
+ res.end('{"ok":true}');
110
+ server.close();
111
+ resolve(sesion);
112
+ }, (err) => {
113
+ res.writeHead(400);
114
+ res.end();
115
+ server.close();
116
+ reject(err);
117
+ });
118
+ return;
119
+ }
65
120
  if (!data.refresh_token) {
66
121
  res.writeHead(400);
67
122
  res.end();
@@ -93,7 +148,12 @@ export async function login() {
93
148
  reject(new Error("Could not open a loopback port."));
94
149
  return;
95
150
  }
96
- const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}`;
151
+ // `th=1` anuncia que este CLI sabe canjear un token_hash. La web solo lo
152
+ // manda si lo ve: sin la marca, un CLI antiguo recibiria un codigo que no
153
+ // entiende y el login fallaria sin explicacion. Arreglar un fallo de
154
+ // sesion rompiendo el login de quien no ha actualizado no seria arreglar
155
+ // nada.
156
+ const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}&th=1`;
97
157
  console.error("Opening your browser to sign in to ChangeBook…");
98
158
  console.error(`If it doesn't open, visit:\n\n ${url}\n`);
99
159
  openInBrowser(url);
package/dist/supabase.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * every query to the signed-in user. Refreshes the access token with the
6
6
  * refresh token when needed (refresh does not require a captcha).
7
7
  */
8
- import { loadCredentials, saveCredentials, withCredentialsLock, } from './credentials.js';
8
+ import { clearRefreshInFlight, credentialsPath, loadCredentials, markRefreshInFlight, readRefreshInFlight, saveCredentials, withCredentialsLock, } from './credentials.js';
9
9
  import { slugifyProject } from './guard.js';
10
10
  // Public defaults — the anon key is the same public key the web app ships.
11
11
  const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
@@ -335,10 +335,30 @@ export class Supabase {
335
335
  return;
336
336
  }
337
337
  this.refreshToken = plan.refresh_token;
338
+ // Una renovacion anterior que quedo a medias: la dejo escrita ANTES de que
339
+ // esta salga, para no confundirla con la mia.
340
+ const aMedias = this.persistRotation ? readRefreshInFlight() : null;
341
+ // Supabase rota el token AL RECIBIR la peticion, asi que desde esta linea y
342
+ // hasta que se guarde el nuevo, el token del disco esta muerto sin que nadie
343
+ // lo sepa. Dejar constancia no evita que maten al proceso en esa ventana
344
+ // —el analyze post-commit corre desacoplado— pero convierte el sintoma de
345
+ // dentro de media hora en algo explicable.
346
+ if (this.persistRotation)
347
+ markRefreshInFlight();
338
348
  const res = await this.refreshOnce(this.refreshToken);
339
349
  if (!res.ok) {
340
350
  const body = (await res.text()).slice(0, 300);
341
- throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
351
+ // El token esta gastado o muerto de todos modos: la marca ya no sirve.
352
+ if (this.persistRotation)
353
+ clearRefreshInFlight();
354
+ const yaUsado = /already[ _]used|refresh[ _]token[ _]not[ _]valid/i.test(body);
355
+ const explicacion = yaUsado && aMedias
356
+ ? `\n\nQUE PASO: una renovacion anterior empezo el ${aMedias.at} (proceso ${aMedias.pid}) y no llego a terminar. ` +
357
+ `El servidor rotó el token al recibirla, pero este equipo no guardó el nuevo — normalmente porque mataron al proceso ` +
358
+ `(el analisis post-commit corre en segundo plano). Al reintentar con el viejo, Supabase lo toma por robado y cierra la sesion. ` +
359
+ `No has hecho nada mal y no hay nada que reparar: basta con volver a entrar.`
360
+ : '';
361
+ throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}${explicacion}\n\n${AUTH_HELP}`, res.status);
342
362
  }
343
363
  const data = (await res.json());
344
364
  this.accessToken = data.access_token;
@@ -352,9 +372,33 @@ export class Supabase {
352
372
  refresh_token: this.refreshToken,
353
373
  access_token: this.accessToken,
354
374
  });
375
+ // Guardado: la ventana peligrosa se ha cerrado. Se retira la marca
376
+ // DESPUES de escribir, no antes — al reves dejaria un hueco en el que
377
+ // ni hay marca ni hay token nuevo, que es justo el estado que la marca
378
+ // existe para poder contar.
379
+ clearRefreshInFlight();
355
380
  }
356
- catch {
357
- // Non-fatal: the in-memory session still works for this process.
381
+ catch (err) {
382
+ // NO es inofensivo, aunque lo parezca desde aquí.
383
+ //
384
+ // Este proceso sigue funcionando con su sesion en memoria, y por eso el
385
+ // codigo anterior se lo tragaba en silencio. Pero el token rotado se ha
386
+ // perdido: Supabase ya invalido el viejo, asi que el SIGUIENTE proceso
387
+ // leera del disco un token gastado, lo reenviara, y la deteccion de
388
+ // reutilizacion revocara la sesion entera. El sintoma aparece minutos u
389
+ // horas despues, sin ninguna relacion visible con este fallo de
390
+ // escritura — que es lo que lo hacia indiagnosticable.
391
+ //
392
+ // Firma en los logs de auth del 2026-07-25: un refresco con 200 a las
393
+ // 06:51:54 y, diez minutos mas tarde y sin nada en medio, un 400
394
+ // "Possible abuse attempt". Es exactamente lo que deja este camino.
395
+ //
396
+ // No se lanza: tumbar la orden en curso castigaria al usuario por algo
397
+ // que todavia funciona. Se avisa, alto y con el arreglo puesto.
398
+ const motivo = err instanceof Error ? err.message : String(err);
399
+ console.error(`ChangeBook: no se pudo guardar la sesion renovada en ${credentialsPath()} (${motivo}).\n` +
400
+ `Esta orden termina bien, pero la siguiente reutilizara un token ya gastado y la sesion se cerrara sola.\n` +
401
+ `Comprueba los permisos de ese fichero y vuelve a entrar con: changebook login`);
358
402
  }
359
403
  }
360
404
  }
package/dist/sync.js CHANGED
@@ -200,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
200
200
  // la tool solo cubre definiciones y el agente acababa pagando atlas Y
201
201
  // grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
202
202
  // hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
203
- 'Disparador tool: orientarte `atlas_project_brief` (`intent:"orient"`) · editar un archivo → `atlas_file_context` con su ruta · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
203
+ // Los VERBOS son literales a propósito. La ley medida el 2026-07-21 fue que
204
+ // la adopción es coincidencia LITERAL (los disparadores literales la movieron
205
+ // de 60/100/0 a 100/100/60), y el 2026-07-26 se comprobó el otro lado de la
206
+ // misma ley: T4 pidió «vas a cambiar el comportamiento de la función X» y el
207
+ // agente, con el atlas disponible, fue directo a grep — 32 turnos, 1,46M
208
+ // tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
209
+ // habla quien programa con un agente. Quien no pregunta nada no reconoce un
210
+ // disparador abstracto: hay que nombrar sus verbos.
211
+ 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
204
212
  '',
205
213
  'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
206
214
  '',
207
215
  // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
208
- `${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Anuncia antes de atacar un encargo, y DILE al usuario los riesgos que el atlas te enseñe — él no los ve.`,
216
+ //
217
+ // «Anuncia» pasó a «di qué harías y ESPERA su OK» el 2026-07-26, a petición
218
+ // de Raúl y con razón: anunciar y ponerse a trabajar en el mismo turno no le
219
+ // deja decidir nada. Y valía para «un encargo», o sea solo para la cola;
220
+ // ahora vale al abrir, que es cuando el agente elige por su cuenta qué mirar
221
+ // y qué tocar. Lo que se gasta aquí se recupera en la línea de la cola, que
222
+ // decía esto mismo por segunda vez.
223
+ `${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Al abrir: di en 2 líneas qué harías y por qué, y ESPERA su OK antes de tocar código. DILE los riesgos que el atlas te enseñe — él no los ve.`,
209
224
  '',
210
225
  ];
211
226
  if (modules.length === 0) {
@@ -266,27 +281,29 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
266
281
  ].slice(0, 3);
267
282
  const taskLines = taskTitles.length > 0
268
283
  ? [
269
- `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Al empezar, propón al usuario cuál atacarías y por qué, y espera su OK antes de tocar código. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
284
+ `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
270
285
  ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
271
286
  ]
272
287
  : [];
273
- // El orden de renderizado (legibilidad) y la prioridad de presupuesto
274
- // (utilidad para el agente) son independientes: si no cabe todo, caen
275
- // primero los últimos cambios y los módulos, nunca las regresiones.
288
+ // El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
289
+ // recorta primero) son independientes. Los dos importan, y hasta el 2026-07-26
290
+ // solo uno estaba bien.
291
+ //
292
+ // EL RIESGO VA PRIMERO AL LEER, y eso es el cambio. El presupuesto ya
293
+ // priorizaba las regresiones sobre el mapa, pero el ORDEN DE LECTURA ponía
294
+ // `### Módulos` en cabeza, así que lo que estaba frágil quedaba debajo de una
295
+ // lista de quince módulos. Medido ese día con 30 corridas: el bloque empujado
296
+ // al prompt del agente entrega 0,40 de los hechos de riesgo contra 0,60 de la
297
+ // tool, y las cinco corridas del brazo con empuje van de 0,4 a 0,6 mientras las
298
+ // de git puro van de 0 a 0,2. O sea: el canal llega, el agente lo tiene
299
+ // delante, y no lo usa. La hipótesis que queda es la posición.
300
+ //
301
+ // Y `hotspots` sube de prioridad 4 a 1. Con 4 era la penúltima en caer, así que
302
+ // en un proyecto con muchos módulos se recortaba y los críticos solo aparecían
303
+ // como un ⚠ inline dentro de la lista del mapa — el sitio exacto donde no se
304
+ // leen. Los módulos bajan a 3: el mapa se puede pedir con una tool, el riesgo
305
+ // de hoy no está en ningún sitio más.
276
306
  const sections = [
277
- { key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
278
- {
279
- key: 'tasks',
280
- priority: 1,
281
- title: '### Encargos pendientes del dueño (proponte atacarlos)',
282
- lines: taskLines,
283
- },
284
- {
285
- key: 'couplings',
286
- priority: 3,
287
- title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
288
- lines: couplingLines,
289
- },
290
307
  {
291
308
  key: 'alerts',
292
309
  priority: 0,
@@ -301,10 +318,23 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
301
318
  },
302
319
  {
303
320
  key: 'hotspots',
304
- priority: 4,
305
- title: '### Avisos abiertos (revisar antes de modificar)',
321
+ priority: 1,
322
+ title: '### Módulos críticos (frágiles por diseño: revisa antes de modificar)',
306
323
  lines: hotspotLines,
307
324
  },
325
+ {
326
+ key: 'couplings',
327
+ priority: 2,
328
+ title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
329
+ lines: couplingLines,
330
+ },
331
+ {
332
+ key: 'tasks',
333
+ priority: 1,
334
+ title: '### Encargos pendientes del dueño (proponte atacarlos)',
335
+ lines: taskLines,
336
+ },
337
+ { key: 'modules', priority: 3, title: '### Módulos', lines: moduleLines },
308
338
  {
309
339
  key: 'changes',
310
340
  priority: 5,
@@ -313,21 +343,62 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
313
343
  },
314
344
  ];
315
345
  let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
346
+ // ── El suelo del mapa ──────────────────────────────────────────────────────
347
+ //
348
+ // El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
349
+ // Medido el 2026-07-26 al hacerlo: con datos de producción reales (1 regresión,
350
+ // 1 control en riesgo, 2 módulos críticos, 2 encargos) el mapa cayó de 18
351
+ // módulos a DOS.
352
+ //
353
+ // Y eso no es un detalle estético: el estudio de 76 agentes del 2026-07-20
354
+ // concluyó que el mapa expulsado era «la causa nº 1 de que el agente ignore o
355
+ // desconfíe del atlas». Cambiar un fallo medido por otro fallo medido no es una
356
+ // mejora.
357
+ //
358
+ // Así que el reparto deja de ser el-que-llega-primero-se-lo-lleva-todo: se
359
+ // reserva lo que cuestan las primeras MIN_LINEAS_MAPA líneas del mapa, y solo
360
+ // el resto se disputa por prioridad. Si el mapa tiene menos líneas que el
361
+ // suelo, sobra menos reserva; si no hay mapa, no se reserva nada.
362
+ const MIN_LINEAS_MAPA = 6;
363
+ const mapa = sections.find((x) => x.key === 'modules');
364
+ let reservaMapa = 0;
365
+ if (mapa && mapa.lines.length > 0) {
366
+ reservaMapa = mapa.title.length + 2;
367
+ for (const line of mapa.lines.slice(0, MIN_LINEAS_MAPA)) {
368
+ reservaMapa += line.length + 1;
369
+ }
370
+ // Nunca más de un tercio: el suelo protege al mapa, no lo convierte en el
371
+ // dueño del bloque.
372
+ reservaMapa = Math.min(reservaMapa, Math.floor(budget / 3));
373
+ // Se APARTA del fondo común aquí. Sin esto, dárselo luego al mapa como
374
+ // `budget + reserva` lo contaba dos veces y el bloque se pasaba del tope
375
+ // (medido: 2.344 de 2.000).
376
+ budget -= reservaMapa;
377
+ }
316
378
  const includedCount = new Map();
317
379
  for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
318
380
  if (s.lines.length === 0)
319
381
  continue;
382
+ // El mapa gasta su reserva ADEMÁS de lo que haya quedado libre.
383
+ const disponible = s.key === 'modules' ? budget + reservaMapa : budget;
320
384
  let cost = s.title.length + 2; // título + línea en blanco separadora
321
385
  let count = 0;
322
386
  for (const line of s.lines) {
323
- if (cost + line.length > budget)
387
+ if (cost + line.length > disponible)
324
388
  break;
325
389
  cost += line.length + 1;
326
390
  count += 1;
327
391
  }
328
392
  if (count > 0) {
329
393
  includedCount.set(s.key, count);
330
- budget -= cost;
394
+ if (s.key === 'modules') {
395
+ // Lo que consumió por encima de su reserva sale del fondo común.
396
+ budget -= Math.max(0, cost - reservaMapa);
397
+ reservaMapa = 0;
398
+ }
399
+ else {
400
+ budget -= cost;
401
+ }
331
402
  }
332
403
  }
333
404
  const lines = [...head];
package/dist/tools.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import { z } from "zod";
9
9
  import { execFileAsync } from "./git.js";
10
- import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
10
+ import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
11
11
  import { SupabaseError } from "./supabase.js";
12
12
  const CHARACTER_LIMIT = 25_000;
13
13
  /**
@@ -96,6 +96,49 @@ export function quotedInList(values) {
96
96
  .join(",");
97
97
  }
98
98
  export const FILES_CAP = 8;
99
+ const MAX_DEPENDENTS = 6;
100
+ /** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
101
+ * El mapa de la web dibuja las flechas sobre esta misma ventana: dos
102
+ * ventanas distintas darían dependencias distintas según se mire el dibujo
103
+ * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
104
+ const MODULE_GRAPH_WINDOW_ROWS = 1000;
105
+ export function dependentsOf(targets, rows) {
106
+ const graph = new Map();
107
+ for (const r of rows) {
108
+ const label = (r.module ?? "").trim();
109
+ if (!label || graph.has(label.toLowerCase()))
110
+ continue;
111
+ const deps = Array.isArray(r.deps)
112
+ ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
113
+ : [];
114
+ graph.set(label.toLowerCase(), deps);
115
+ }
116
+ const reverse = new Map();
117
+ for (const r of rows) {
118
+ const from = (r.module ?? "").trim();
119
+ if (!from)
120
+ continue;
121
+ for (const to of graph.get(from.toLowerCase()) ?? []) {
122
+ const key = to.toLowerCase();
123
+ if (key === from.toLowerCase())
124
+ continue;
125
+ const set = reverse.get(key) ?? new Set();
126
+ set.add(from);
127
+ reverse.set(key, set);
128
+ }
129
+ }
130
+ const out = new Map();
131
+ for (const t of targets) {
132
+ const label = (t ?? "").trim();
133
+ if (!label)
134
+ continue;
135
+ const found = reverse.get(label.toLowerCase());
136
+ if (!found || found.size === 0)
137
+ continue;
138
+ out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
139
+ }
140
+ return out;
141
+ }
99
142
  /**
100
143
  * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
101
144
  * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
@@ -735,7 +778,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
735
778
  for (const f of perFile) {
736
779
  commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
737
780
  }
738
- const [alerts, watched, recidivismRows] = await Promise.all([
781
+ const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
739
782
  moduleNames.length
740
783
  ? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
741
784
  pf)
@@ -759,6 +802,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
759
802
  pf)
760
803
  .catch(() => [])
761
804
  : Promise.resolve([]),
805
+ // Radio de impacto: grafo COMPLETO del proyecto (no filtrado por
806
+ // moduleNames — quién depende de X vive en las filas de OTROS
807
+ // módulos). En el mismo Promise.all: cero rondas extra, cero turnos
808
+ // extra. Best-effort: sin columna, el contexto se sirve sin radio.
809
+ db
810
+ .rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
811
+ pf)
812
+ .catch(() => []),
762
813
  ]);
763
814
  const watchedByFile = new Map();
764
815
  for (const w of watched) {
@@ -771,22 +822,55 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
771
822
  // evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
772
823
  // cwd no es el proyecto consultado, no se refuta nada (ver
773
824
  // cwdEsElProyecto).
774
- const refutada = cwdEsElProyecto(project)
825
+ const enElProyecto = cwdEsElProyecto(project);
826
+ const refutada = enElProyecto
775
827
  ? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
776
828
  : () => false;
777
- const alertsByModule = new Map();
829
+ // La otra mitad, desde el 2026-07-26: un aviso 'absent' cuyo símbolo
830
+ // SIGUE apareciendo ya no se descarta (era ambiguo y tiraba las dos
831
+ // alertas que predijeron el desfase de esquema de producción). Se sirve
832
+ // diciendo dónde aparece, que es contestar el condicional del texto
833
+ // ("...si los hubiera") en vez de prohibir la redacción.
834
+ //
835
+ // Mismas palabras que el hook (impactText) a propósito: dos redacciones
836
+ // del mismo hecho son dos hechos para el agente.
837
+ const localizada = enElProyecto
838
+ ? (a) => dondeApareceElSimbolo(a, (s) => ficherosEnRepo(process.cwd(), s))
839
+ : () => null;
840
+ // Se construye la lista ANTES de renderizar para poder dejar una linea
841
+ // por texto: dos avisos con la misma frase se leen como una repeticion
842
+ // aunque por dentro sean afirmaciones opuestas, y sobre dos frases
843
+ // identicas no se puede actuar distinto. Gana el que trae comprobacion.
844
+ const porModulo = new Map();
778
845
  for (const a of alerts) {
779
846
  const m = (a.module ?? "").trim();
780
847
  if (!m || !a.plain)
781
848
  continue;
782
849
  if (refutada(a))
783
850
  continue;
784
- alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
851
+ const donde = localizada(a);
852
+ const fuera = dondeComprobarlo(a);
853
+ let extra = "";
854
+ if (donde) {
855
+ extra +=
856
+ `\n → CHECKED NOW: "${(a.evidence_symbol ?? "").trim()}" still appears in ${donde.join(", ")}` +
857
+ ` — alert expects it gone: either a reference was missed, or the alert is stale`;
858
+ }
859
+ // El repo no puede contestar esta afirmacion: se dice, en vez de
860
+ // dejar que se compruebe donde no era.
861
+ if (fuera)
862
+ extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
863
+ porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
864
+ }
865
+ const alertsByModule = new Map();
866
+ for (const [m, entradas] of porModulo) {
867
+ alertsByModule.set(m, unoPorTexto(entradas, (e) => e.plain, (e) => e.extra.length > 0).map((e) => e.plain + e.extra));
785
868
  }
786
869
  // Reincidencia: nº de problemas de regresión DISTINTOS por módulo
787
870
  // (count(distinct plain), all-time), solo los con antecedentes (>= 2).
788
871
  // Fuente única compartida (computeRecidivism) — antes 3 copias.
789
872
  const recidivismByModule = computeRecidivism(recidivismRows);
873
+ const dependentsByModule = dependentsOf(moduleNames, depsRows);
790
874
  // Ancla temporal: el último commit analizado del proyecto vs el HEAD
791
875
  // de este árbol (misma puerta de proyecto que la refutación).
792
876
  // Best-effort: el ancla jamás rompe la lectura que ancla.
@@ -829,6 +913,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
829
913
  for (const plain of alertsByModule.get(m.module) ?? []) {
830
914
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
831
915
  }
916
+ const dependents = dependentsByModule.get(m.module);
917
+ if (dependents?.length) {
918
+ lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
919
+ }
832
920
  }
833
921
  for (const w of watchedByFile.get(f.file) ?? []) {
834
922
  lines.push(`- Current value: ${w.name} = ${w.value}` +
@@ -864,6 +952,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
864
952
  prior_regressions: recidivismByModule.get(m.module) ?? 0,
865
953
  }))
866
954
  .filter((x) => x.prior_regressions >= 2),
955
+ // Espejo del hospedado: el structuredContent no puede prometer
956
+ // menos que la prosa.
957
+ dependents: f.modules
958
+ .map((m) => ({
959
+ module: m.module,
960
+ dependents: dependentsByModule.get(m.module) ?? [],
961
+ }))
962
+ .filter((x) => x.dependents.length > 0),
867
963
  watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
868
964
  name: w.name,
869
965
  value: w.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "mcpName": "io.github.raulbr90/changebook",
5
5
  "description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
4
  "description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
5
- "version": "0.4.8",
5
+ "version": "0.4.10",
6
6
  "websiteUrl": "https://changebook.dev",
7
7
  "remotes": [
8
8
  {
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "changebook",
18
- "version": "0.4.8",
18
+ "version": "0.4.10",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }