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/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';
@@ -64,7 +64,21 @@ export class Supabase {
64
64
  // True when the tokens came from ~/.changebook/credentials.json: rotated
65
65
  // refresh tokens must be written back there or the stored one goes stale.
66
66
  persistRotation = false;
67
- constructor(env = process.env) {
67
+ /**
68
+ * Whether this process is allowed to SPEND the rotating refresh token.
69
+ *
70
+ * False for callers that run under a hard deadline and end in process.exit()
71
+ * — today, the pre-commit guard. Supabase rotates the refresh token the
72
+ * moment it RECEIVES the request, not when we read the reply, so a process
73
+ * that is killed mid-refresh burns the stored token without ever persisting
74
+ * the new one. The next process replays a spent token, Supabase's reuse
75
+ * detection fires, and the whole session dies. That is a silent logout
76
+ * caused by an optional warning — a terrible trade (diagnosed 2026-07-25:
77
+ * the session died ~30 min after every login, always on a commit).
78
+ */
79
+ allowRefresh = true;
80
+ constructor(env = process.env, opts = {}) {
81
+ this.allowRefresh = opts.allowRefresh ?? true;
68
82
  this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
69
83
  this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
70
84
  this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
@@ -288,6 +302,12 @@ export class Supabase {
288
302
  return this.refreshing;
289
303
  }
290
304
  doRefresh() {
305
+ // Refusing BEFORE the request is the whole point: once it leaves, the token
306
+ // is spent whether or not we survive to store the replacement. Callers that
307
+ // opt out get a plain 401 they can treat as "no atlas this time".
308
+ if (!this.allowRefresh) {
309
+ return Promise.reject(new SupabaseError("The stored session needs renewing and this process is not allowed to spend it (it runs under a deadline). Skipping.", 401));
310
+ }
291
311
  // Env-var sessions aren't shared through the credentials file, so there is
292
312
  // nothing to coordinate between processes: refresh in place.
293
313
  if (!this.persistRotation)
@@ -315,10 +335,30 @@ export class Supabase {
315
335
  return;
316
336
  }
317
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();
318
348
  const res = await this.refreshOnce(this.refreshToken);
319
349
  if (!res.ok) {
320
350
  const body = (await res.text()).slice(0, 300);
321
- 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);
322
362
  }
323
363
  const data = (await res.json());
324
364
  this.accessToken = data.access_token;
@@ -332,9 +372,33 @@ export class Supabase {
332
372
  refresh_token: this.refreshToken,
333
373
  access_token: this.accessToken,
334
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();
335
380
  }
336
- catch {
337
- // 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`);
338
402
  }
339
403
  }
340
404
  }
package/dist/sync.js CHANGED
@@ -50,6 +50,27 @@ const SYNC_BUDGET_CHARS = 2_000;
50
50
  // shared analyses and a ≥60% rate before we call it a dependency.
51
51
  const MIN_PAIR_COUNT = 3;
52
52
  const MIN_PAIR_RATE = 0.6;
53
+ /**
54
+ * Espejo de supabase/functions/mcp/scope.ts::summarizeHealth — el paquete npm
55
+ * es autocontenido. Paridad fijada en test/saludEnBrief.
56
+ */
57
+ export function summarizeHealth(rows) {
58
+ let passed = 0;
59
+ const at_risk = [];
60
+ for (const r of rows) {
61
+ if (r.status === 'passed') {
62
+ passed += 1;
63
+ }
64
+ else if (r.status === 'at_risk') {
65
+ at_risk.push({
66
+ check: r.check_id,
67
+ evidence: (r.evidence ?? '').trim(),
68
+ since: r.updated_at ? r.updated_at.slice(0, 10) : null,
69
+ });
70
+ }
71
+ }
72
+ return { passed, at_risk };
73
+ }
53
74
  // Same window/limit the web uses for the signals strip.
54
75
  const ALERT_WINDOW_DAYS = 14;
55
76
  const MAX_ALERTS = 3;
@@ -76,7 +97,7 @@ export async function fetchBriefSection(db, targetDir) {
76
97
  }
77
98
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
78
99
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
79
- const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
100
+ const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
80
101
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
81
102
  projectFilter),
82
103
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -93,8 +114,14 @@ export async function fetchBriefSection(db, targetDir) {
93
114
  .then((rows) => rows.filter((r) => r.status === 'pending'))
94
115
  .catch(() => [])
95
116
  : Promise.resolve([]),
117
+ // Salud (project_checks): tabla diminuta (≤8 filas/proyecto), en paralelo.
118
+ db
119
+ .rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
120
+ projectFilter)
121
+ .catch(() => []),
96
122
  ]);
97
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
123
+ const health = summarizeHealth(healthRows);
124
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk);
98
125
  return { section, projectId, projectResolved };
99
126
  }
100
127
  export async function syncContextFiles(db, targetDir, opts = {}) {
@@ -119,7 +146,7 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
119
146
  }
120
147
  }
121
148
  /** Exported for tests. */
122
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
149
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = []) {
123
150
  // Newest-first rows: the first occurrence of a module is its latest state.
124
151
  const seen = new Map();
125
152
  for (const row of rows) {
@@ -173,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
173
200
  // la tool solo cubre definiciones y el agente acababa pagando atlas Y
174
201
  // grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
175
202
  // hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
176
- '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).',
177
212
  '',
178
213
  'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
179
214
  '',
180
215
  // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
181
- `${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.`,
182
224
  '',
183
225
  ];
184
226
  if (modules.length === 0) {
@@ -225,6 +267,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
225
267
  return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
226
268
  });
227
269
  const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
270
+ // Salud en riesgo: los controles (auth, secretos, validación, límites…) que
271
+ // el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
272
+ // es seguridad. Solo los at_risk (lo accionable); el texto entero va a
273
+ // `atlas_project_brief`. Cap a 130 chars como las alertas.
274
+ const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
228
275
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
229
276
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
230
277
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
@@ -234,7 +281,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
234
281
  ].slice(0, 3);
235
282
  const taskLines = taskTitles.length > 0
236
283
  ? [
237
- `- 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\`.`,
238
285
  ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
239
286
  ]
240
287
  : [];
@@ -261,6 +308,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
261
308
  title: '### Regresiones detectadas (resolver o verificar YA)',
262
309
  lines: alertLines,
263
310
  },
311
+ {
312
+ key: 'health',
313
+ priority: 1,
314
+ title: '### Salud en riesgo (verifica antes de tocar)',
315
+ lines: healthLines,
316
+ },
264
317
  {
265
318
  key: 'hotspots',
266
319
  priority: 4,
package/dist/tools.js CHANGED
@@ -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) {
@@ -787,6 +838,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
787
838
  // (count(distinct plain), all-time), solo los con antecedentes (>= 2).
788
839
  // Fuente única compartida (computeRecidivism) — antes 3 copias.
789
840
  const recidivismByModule = computeRecidivism(recidivismRows);
841
+ const dependentsByModule = dependentsOf(moduleNames, depsRows);
790
842
  // Ancla temporal: el último commit analizado del proyecto vs el HEAD
791
843
  // de este árbol (misma puerta de proyecto que la refutación).
792
844
  // Best-effort: el ancla jamás rompe la lectura que ancla.
@@ -829,6 +881,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
829
881
  for (const plain of alertsByModule.get(m.module) ?? []) {
830
882
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
831
883
  }
884
+ const dependents = dependentsByModule.get(m.module);
885
+ if (dependents?.length) {
886
+ lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
887
+ }
832
888
  }
833
889
  for (const w of watchedByFile.get(f.file) ?? []) {
834
890
  lines.push(`- Current value: ${w.name} = ${w.value}` +
@@ -864,6 +920,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
864
920
  prior_regressions: recidivismByModule.get(m.module) ?? 0,
865
921
  }))
866
922
  .filter((x) => x.prior_regressions >= 2),
923
+ // Espejo del hospedado: el structuredContent no puede prometer
924
+ // menos que la prosa.
925
+ dependents: f.modules
926
+ .map((m) => ({
927
+ module: m.module,
928
+ dependents: dependentsByModule.get(m.module) ?? [],
929
+ }))
930
+ .filter((x) => x.dependents.length > 0),
867
931
  watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
868
932
  name: w.name,
869
933
  value: w.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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.7",
5
+ "version": "0.4.9",
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.7",
18
+ "version": "0.4.9",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }