changebook 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -4,12 +4,12 @@
4
4
  * All tools are read-only queries over the user's own ChangeBook data
5
5
  * (Row Level Security scopes every request to the signed-in user).
6
6
  */
7
- import path from "node:path";
8
7
  import { z } from "zod";
9
- import { execFileAsync } from "./git.js";
8
+ import { execFileAsync, projectNameFor } from "./git.js";
10
9
  import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
11
10
  import { SupabaseError } from "./supabase.js";
12
11
  import { coChangePairs } from "./sync.js";
12
+ import { aliasesFor, canonicalizeModuleRows, canonicalModule, etiquetaPorSlug, expandModuleNames, projectIdOf, slugModule, } from "./aliasDeModulo.js";
13
13
  const CHARACTER_LIMIT = 25_000;
14
14
  /**
15
15
  * El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
@@ -104,24 +104,40 @@ const MAX_DEPENDENTS = 6;
104
104
  * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
105
105
  const MODULE_GRAPH_WINDOW_ROWS = 1000;
106
106
  export function dependentsOf(targets, rows) {
107
+ // POR SLUG, NO POR toLowerCase(): media normalizacion se lee igual que una
108
+ // entera (invariante 16). Espejo de supabase/functions/mcp/scope.ts.
109
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
110
+ // Memo de slugModule: se llama una vez por fila y por dep. Espejo de
111
+ // supabase/functions/mcp/scope.ts (medido: 0,510 ms -> 2,698 ms sin esto).
112
+ const memo = new Map();
113
+ const slug = (s) => {
114
+ let v = memo.get(s);
115
+ if (v === undefined) {
116
+ v = slugModule(s);
117
+ memo.set(s, v);
118
+ }
119
+ return v;
120
+ };
107
121
  const graph = new Map();
108
122
  for (const r of rows) {
109
123
  const label = (r.module ?? "").trim();
110
- if (!label || graph.has(label.toLowerCase()))
124
+ if (!label || graph.has(slug(label)))
111
125
  continue;
112
126
  const deps = Array.isArray(r.deps)
113
127
  ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
114
128
  : [];
115
- graph.set(label.toLowerCase(), deps);
129
+ graph.set(slug(label), deps);
116
130
  }
117
131
  const reverse = new Map();
118
132
  for (const r of rows) {
119
- const from = (r.module ?? "").trim();
120
- if (!from)
133
+ const crudo = (r.module ?? "").trim();
134
+ if (!crudo)
121
135
  continue;
122
- for (const to of graph.get(from.toLowerCase()) ?? []) {
123
- const key = to.toLowerCase();
124
- if (key === from.toLowerCase())
136
+ const slugFrom = slug(crudo);
137
+ const from = etiqueta.get(slugFrom) ?? crudo;
138
+ for (const to of graph.get(slugFrom) ?? []) {
139
+ const key = slug(to);
140
+ if (key === slugFrom)
125
141
  continue;
126
142
  const set = reverse.get(key) ?? new Set();
127
143
  set.add(from);
@@ -133,7 +149,7 @@ export function dependentsOf(targets, rows) {
133
149
  const label = (t ?? "").trim();
134
150
  if (!label)
135
151
  continue;
136
- const found = reverse.get(label.toLowerCase());
152
+ const found = reverse.get(slug(label));
137
153
  if (!found || found.size === 0)
138
154
  continue;
139
155
  out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
@@ -147,18 +163,58 @@ export function dependentsOf(targets, rows) {
147
163
  * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
148
164
  * infle. No se refuta: cuenta historia, no vigencia.
149
165
  */
166
+ /**
167
+ * ESPEJO de `RecidivismMap` en supabase/functions/mcp/scope.ts. Un Map de
168
+ * verdad —se itera y se muestra con la etiqueta buena— cuyo `get`/`has`
169
+ * normalizan la clave por slug antes de buscar, para que ningún consumidor
170
+ * tenga que acordarse de canonicalizar. Ver allí el razonamiento entero.
171
+ */
172
+ export class RecidivismMap extends Map {
173
+ #porSlug = new Map();
174
+ set(clave, valor) {
175
+ this.#porSlug.set(slugModule(clave), valor);
176
+ return super.set(clave, valor);
177
+ }
178
+ get(clave) {
179
+ return super.get(clave) ?? this.#porSlug.get(slugModule(clave));
180
+ }
181
+ has(clave) {
182
+ return super.has(clave) || this.#porSlug.has(slugModule(clave));
183
+ }
184
+ }
150
185
  export function computeRecidivism(rows) {
186
+ // Agrupado por slug y presentado con la etiqueta representante: si las
187
+ // alertas de un módulo partido están escritas con las dos grafías, sus
188
+ // regresiones distintas son las de UN módulo, no las de dos.
189
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
151
190
  const plainsByModule = new Map();
152
191
  for (const r of rows) {
153
- const m = (r.module ?? "").trim();
192
+ // SOLO LAS CONFIRMADAS. Hasta el 2026-08-02 esto contaba TODA fila de
193
+ // `regression_alerts`, o sea avisos que el propio atlas se generó: sumaba
194
+ // los confirmados, los que el dueño refutó, los que se cerraron solos al
195
+ // re-tocar el módulo y los 79 que son anteriores al campo `resolution`.
196
+ // Medido en producción: decía 110 y las confirmadas eran 20 — «Análisis de
197
+ // cambios» salía con 13 y tenía CERO.
198
+ //
199
+ // La migración que añadió `resolution` (20260725100000) dice en su propio
200
+ // comentario que existe para impedir «el número bonito y falso». Este
201
+ // contador nunca lo adoptó. Ahora sí.
202
+ //
203
+ // `fixed` lo escribe el agente que actuó sobre el aviso, no un oráculo
204
+ // independiente, así que sigue siendo autocertificado — pero es la mejor
205
+ // etiqueta que hay, y desde luego mejor que no mirar.
206
+ if ((r.resolution ?? "").trim().toLowerCase() !== "fixed")
207
+ continue;
208
+ const crudo = (r.module ?? "").trim();
154
209
  const p = (r.plain ?? "").trim();
155
- if (!m || !p)
210
+ if (!crudo || !p)
156
211
  continue;
212
+ const m = etiqueta.get(slugModule(crudo)) ?? crudo;
157
213
  const set = plainsByModule.get(m) ?? new Set();
158
214
  set.add(p);
159
215
  plainsByModule.set(m, set);
160
216
  }
161
- const out = new Map();
217
+ const out = new RecidivismMap();
162
218
  for (const [m, set] of plainsByModule) {
163
219
  if (set.size >= 2)
164
220
  out.set(m, set.size);
@@ -245,14 +301,20 @@ function ilikePattern(search) {
245
301
  * `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
246
302
  * En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
247
303
  * avisoRefutado con expect='present' lo tomaría como refutación — silenciar
248
- * una alerta verdadera por mirar donde no era es peor que el ruido. Misma
249
- * identidad que usa el guardián: CHANGEBOOK_PROJECT o el basename del
250
- * directorio, pasados por la slugify del servidor.
304
+ * una alerta verdadera por mirar donde no era es peor que el ruido.
305
+ *
306
+ * La identidad sale de `projectNameFor`, la MISMA que usan el guardián, el hook
307
+ * y sync: `CHANGEBOOK_PROJECT`, si no el nombre del árbol PRINCIPAL, y solo como
308
+ * último recurso el basename del cwd. Usar `path.basename(cwd)` a secas era el
309
+ * fallo: en un worktree enlazado el basename es el del worktree (`seo-wt`,
310
+ * `wt-3`…), NO el del repo, así que el grep de refutación y el ancla temporal se
311
+ * apagaban en todo worktree aunque fuera el mismo proyecto. `projectNameFor` ya
312
+ * resolvió esto para el resto del CLI el 2026-07-25; este gate se había quedado
313
+ * atrás.
251
314
  */
252
315
  function cwdEsElProyecto(project) {
253
- const candidato = process.env.CHANGEBOOK_PROJECT?.trim() ||
254
- path.basename(path.resolve(process.cwd()));
255
- return slugifyProject(candidato) === slugifyProject(project.trim());
316
+ return (slugifyProject(projectNameFor(process.cwd())) ===
317
+ slugifyProject(project.trim()));
256
318
  }
257
319
  /**
258
320
  * "Atlas current up to commit X — your HEAD is N ahead": la señal MECÁNICA de
@@ -281,76 +343,6 @@ export async function derivaContraHead(dir, hash) {
281
343
  }
282
344
  // ── Registration ──────────────────────────────────────────────────────────────
283
345
  export function registerTools(server, db) {
284
- // Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
285
- // exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
286
- server.registerTool("atlas_symbol_lookup", {
287
- title: "Where is this symbol defined?",
288
- description: `Files where an exported symbol (function/class/const/interface/type/enum) is DEFINED, with its kind and the commit that last touched it. Mechanical index built from every ingested diff.
289
-
290
- Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
291
-
292
- Args:
293
- - symbol (required): the identifier to look up.
294
- - project (recommended): the repo you are working in (folder name or slug).
295
-
296
- Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
297
- inputSchema: {
298
- symbol: z
299
- .string()
300
- .min(2)
301
- .max(120)
302
- .describe("Identifier to look up (exported definition)"),
303
- project: z
304
- .string()
305
- .min(1)
306
- .max(120)
307
- .describe("Project to scope to (repo folder name or slug)"),
308
- },
309
- annotations: {
310
- readOnlyHint: true,
311
- destructiveHint: false,
312
- idempotentHint: true,
313
- openWorldHint: true,
314
- },
315
- }, async ({ symbol, project }) => {
316
- try {
317
- const t0 = Date.now();
318
- const pf = await db.projectFilterFor(project);
319
- const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
320
- pf;
321
- let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
322
- let exact = true;
323
- if (matches.length === 0) {
324
- exact = false;
325
- matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
326
- }
327
- const lines = [`# Symbol lookup: ${symbol}`, ""];
328
- if (matches.length === 0) {
329
- lines.push("Not in the index. It may be unexported, renamed, or defined before the index existed — fall back to grep and SAY you did, instead of guessing.");
330
- }
331
- for (const m of matches) {
332
- lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
333
- }
334
- if (matches.length > 0) {
335
- lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
336
- }
337
- const salida = toolResult(lines.join("\n"), {
338
- symbol,
339
- exact_match: exact,
340
- matches: matches.map((m) => ({
341
- symbol: m.symbol,
342
- file: m.file,
343
- kind: m.kind,
344
- commit: m.commit_hash?.slice(0, 7) ?? null,
345
- })),
346
- });
347
- recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
348
- return salida;
349
- }
350
- catch (error) {
351
- return errorResult(error);
352
- }
353
- });
354
346
  server.registerTool("atlas_recent_changes", {
355
347
  title: "Recent ChangeBook changes",
356
348
  description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
@@ -500,23 +492,42 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
500
492
  // The aggregation below uses only these columns; note/tech/excerpt
501
493
  // (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
502
494
  `change_module?select=module,domain,risk,created_at` +
503
- `&order=created_at.desc&limit=1000` +
495
+ // LA CONSTANTE, no un 1000 a mano. Este era el tercer deletreo de la
496
+ // misma ventana en el CLI (la constante, este literal, y el
497
+ // MODULE_COUNT_WINDOW_ROWS del servidor), y el contrato que prohibe
498
+ // escribirla a mano solo leia los ficheros del servidor.
499
+ `&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
504
500
  pf;
505
501
  if (domain)
506
502
  query += `&domain=eq.${encodeURIComponent(domain)}`;
507
- const rows = await db.rest(query);
503
+ // Canonicalizar ANTES de agrupar, o el catálogo listaría dos entradas
504
+ // para un módulo ya fusionado: la que más duele, porque el catálogo es
505
+ // justo la pantalla donde el dueño va a mirar si la fusión ha surtido
506
+ // efecto. Los alias van en el mismo Promise.all, sin ronda extra.
507
+ const [rowsCrudas, { aliases }] = await Promise.all([
508
+ db.rest(query),
509
+ aliasesFor(db, projectIdOf(pf)),
510
+ ]);
511
+ const rows = canonicalizeModuleRows(rowsCrudas, aliases);
512
+ // POR SLUG, no por el nombre crudo — gemelo del arreglo del hospedado
513
+ // (03/08). Este catalogo servia un modulo escrito de dos maneras como
514
+ // dos modulos, y es la pantalla donde el dueño mira si su fusion ha
515
+ // surtido efecto. `canonicalizeModuleRows` resuelve ALIAS, que es otra
516
+ // cosa: no colapsa tildes ni mayusculas (invariante 16).
517
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
508
518
  const byModule = new Map();
509
519
  for (const row of rows) {
510
- const list = byModule.get(row.module);
520
+ const clave = slugModule((row.module ?? '').trim());
521
+ const list = byModule.get(clave);
511
522
  if (list)
512
523
  list.push(row);
513
524
  else
514
- byModule.set(row.module, [row]);
525
+ byModule.set(clave, [row]);
515
526
  }
516
- const modules = [...byModule.entries()].map(([name, list]) => {
527
+ const modules = [...byModule.entries()].map(([clave, list]) => {
517
528
  const latest = list[0]; // rows arrive newest-first
518
529
  return {
519
- module: name,
530
+ module: etiqueta.get(clave) ?? latest.module,
520
531
  domain: latest.domain,
521
532
  risk: latest.risk,
522
533
  changes: list.length,
@@ -525,7 +536,17 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
525
536
  });
526
537
  modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
527
538
  const output = { count: modules.length, modules };
528
- const lines = [`# ChangeBook module map (${modules.length} modules)`, ""];
539
+ // LA VENTANA VA DICHA. Esto contaba "(N modules)" a secas y el brief
540
+ // mandaba aqui a por "el mapa completo": las dos cosas prometian de
541
+ // mas. Son los modulos vistos en los ultimos MODULE_GRAPH_WINDOW_ROWS
542
+ // cambios, y esa ventana estaba LLENA AL RAS el 03/08 (la suma de
543
+ // `changes` daba 1.000 clavados), o sea que el numero baja segun se
544
+ // trabaja. Un contador que se encoge cuando trabajas y se llama
545
+ // "total" dice lo contrario de lo que pasa.
546
+ const lines = [
547
+ `# ChangeBook module map (${modules.length} modules seen in the last ${MODULE_GRAPH_WINDOW_ROWS} changes)`,
548
+ "",
549
+ ];
529
550
  for (const m of modules) {
530
551
  lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
531
552
  `${m.changes} change(s), last ${m.last_changed}` +
@@ -547,9 +568,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
547
568
  });
548
569
  server.registerTool("atlas_module_detail", {
549
570
  title: "ChangeBook module detail",
550
- description: `Get the change history of one module: what changed, why it matters, the files touched and (when available) a verbatim excerpt of each diff.
551
-
552
- This is the token-saving alternative to re-reading a module's source: it gives an agent the recent evolution and risk picture in one call.
571
+ description: `Call this WHEN you need a module's recent history instead of re-reading its source: what changed, why it matters, the files touched and (when available) a verbatim excerpt of each diff. The token-saving way to catch up on a module — its recent evolution and risk picture — in one call before you touch it.
553
572
 
554
573
  Args:
555
574
  - module (required): exact module name as returned by atlas_modules.
@@ -581,9 +600,18 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
581
600
  try {
582
601
  const t0 = Date.now();
583
602
  const pf = await db.projectFilterFor(project);
584
- const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
585
- `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
603
+ // Los alias ANTES de la consulta, y aquí sí cuesta una ronda: el filtro
604
+ // por nombre va al servidor, así que no hay forma de expandirlo sin
605
+ // saber las hermanas. Se paga porque esta tool la invoca un agente de
606
+ // vez en cuando, no el guardián en cada edición — y porque la
607
+ // alternativa es que el historial de un módulo fusionado salga a medias
608
+ // sin decirlo, que es la peor forma de estar mal.
609
+ const { aliases, hermanas } = await aliasesFor(db, projectIdOf(pf));
610
+ const nombresParaConsultar = expandModuleNames([module], hermanas);
611
+ const rowsCrudas = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
612
+ `&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=${limit}` +
586
613
  pf);
614
+ const rows = canonicalizeModuleRows(rowsCrudas, aliases);
587
615
  if (rows.length === 0) {
588
616
  return {
589
617
  content: [
@@ -646,15 +674,20 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
646
674
  }
647
675
  }
648
676
  const latest = rows[0];
677
+ // Se responde con el nombre CANÓNICO, no con el que preguntó el agente:
678
+ // preguntar por la etiqueta vieja de un módulo fusionado tiene que
679
+ // devolver su historia completa BAJO EL NOMBRE BUENO, o la respuesta
680
+ // enseñaría un nombre que ya no está en el mapa.
681
+ const nombreCanonico = canonicalModule(module, aliases);
649
682
  const output = {
650
- module,
683
+ module: nombreCanonico,
651
684
  domain: latest.domain,
652
685
  category: latest.category,
653
686
  count: changes.length,
654
687
  changes,
655
688
  };
656
689
  const lines = [
657
- `# Module: ${module}` +
690
+ `# Module: ${nombreCanonico}` +
658
691
  (latest.domain ? ` (${latest.domain}` +
659
692
  (latest.category ? ` / ${latest.category}` : "") + ")" : ""),
660
693
  ];
@@ -692,9 +725,9 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
692
725
  });
693
726
  server.registerTool("atlas_file_context", {
694
727
  title: "Context of the files you are about to edit",
695
- description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, how often they changed recently, the recent commits that touched each file (cite these instead of running git log), and the CURRENT value of watched config constants (with the commit it comes from no need to re-read the file for those).
728
+ description: `Call this BEFORE you edit, change, fix or refactor any file or function: it tells you how many times each file's module already broke here (recidivism) and its open regression alerts, so you touch it knowing what bit people last time. One cheap call instead of re-reading the code and its git history.
696
729
 
697
- Call this BEFORE editing a file one cheap call instead of re-reading the code and its git history.
730
+ It also returns, per file: the module it belongs to and its risk, how often it changed recently, the recent commits that touched it (cite these instead of running git log), and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
698
731
 
699
732
  Args:
700
733
  - files (required): 1-8 repo-relative paths.
@@ -717,10 +750,22 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
717
750
  try {
718
751
  const t0 = Date.now();
719
752
  const pf = await db.projectFilterFor(project);
753
+ // La lectura de alias ARRANCA aquí y se espera dentro de cada fichero:
754
+ // corre en paralelo con las consultas por archivo, así que no cuesta
755
+ // ronda. Es la tool que el agente llama antes de cada edición. (Mismo
756
+ // patrón que el MCP hospedado, `aliasFileP`.)
757
+ const aliasP = aliasesFor(db, projectIdOf(pf));
720
758
  const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
721
759
  const perFile = await Promise.all(paths.map(async (file) => {
722
- const rows = await db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
723
- pf);
760
+ const [rowsCrudas, { aliases }] = await Promise.all([
761
+ db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
762
+ pf),
763
+ aliasP,
764
+ ]);
765
+ // ANTES de agrupar: el bucle de abajo cuenta cambios POR NOMBRE, y
766
+ // canonicalizar después dejaría el mismo módulo partido en dos
767
+ // entradas con la mitad de los cambios cada una.
768
+ const rows = canonicalizeModuleRows(rowsCrudas, aliases);
724
769
  const byModule = new Map();
725
770
  // Commits que tocaron ESTE archivo, del más nuevo al más viejo,
726
771
  // deduplicados (un mismo análisis puede traer varias filas de
@@ -749,9 +794,15 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
749
794
  }
750
795
  return { file, modules: [...byModule.values()], changelogIds };
751
796
  }));
797
+ // Los nombres YA canonicalizados: es lo que se enseña y con lo que se
798
+ // agrupa. Pero las consultas de abajo filtran EN EL SERVIDOR por
799
+ // nombre, y en la base siguen las etiquetas viejas, así que van con las
800
+ // hermanas — el alias se resuelve al leer, no reescribe nada.
801
+ const { aliases: aliasesFile, hermanas: hermanasFile } = await aliasP;
752
802
  const moduleNames = [
753
803
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
754
804
  ];
805
+ const nombresParaConsultar = expandModuleNames(moduleNames, hermanasFile);
755
806
  // Commits recientes por archivo (T2: el agente que va a editar dejaba
756
807
  // de necesitar `git log -- <archivo>`). UNA consulta batcheada para
757
808
  // TODOS los archivos, no una por archivo: es la tool que corre antes
@@ -779,14 +830,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
779
830
  for (const f of perFile) {
780
831
  commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
781
832
  }
782
- const [alerts, watched, recidivismRows, depsRows, coChangeRows] = await Promise.all([
833
+ const [alertsCrudas, watched, recidivismCrudas, depsCrudas, coChangeCrudas,] = await Promise.all([
783
834
  moduleNames.length
784
835
  ? db.rest(
785
836
  // Con `evidence_scope`: sin él ni se refuta (alcanceDelRepo dice
786
837
  // "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
787
838
  // HERE, que es la única forma de que un aviso sobre producción
788
839
  // no se compruebe con un grep del repo. Ver guard.ts.
789
- `regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
840
+ `regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=10` +
790
841
  pf)
791
842
  : Promise.resolve([]),
792
843
  // Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
@@ -798,13 +849,22 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
798
849
  pf)
799
850
  .catch(() => []),
800
851
  // Reincidencia (edit-time, espejo del hospedado): problemas de
801
- // regresión de TODO el tiempo (sin filtro resolved) de los módulos
802
- // tocados. Depende de moduleNames como las alertas mismo Promise.all,
803
- // sin ronda extra. Se cuenta distinct plain abajo (mismo criterio que
804
- // el RPC del brief).
852
+ // regresión CONFIRMADOS de todo el tiempo, de los módulos tocados.
853
+ // Se trae `resolution` porque el recuento solo cuenta las `fixed`:
854
+ // hasta el 2026-08-02 sumaba toda fila y decía 110 donde había 20.
855
+ // Depende de moduleNames como las alertas → mismo Promise.all, sin
856
+ // ronda extra. Se cuenta distinct plain abajo.
805
857
  moduleNames.length
806
858
  ? db
807
- .rest(`regression_alerts?select=module,plain&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&limit=500` +
859
+ .rest(
860
+ // `order` añadido el 03/08 (AUD-C9), a la vez que en el
861
+ // hospedado: son las dos mitades de la paridad que vigila
862
+ // reincidenciaFileContext. Sin orden, el tope de 500 no daba
863
+ // «las 500 más recientes» sino 500 cualesquiera, distintas en
864
+ // cada llamada, así que la reincidencia que sale de aquí
865
+ // podía cambiar entre dos lecturas seguidas sin que la base
866
+ // hubiera cambiado.
867
+ `regression_alerts?select=module,plain,resolution&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=500` +
808
868
  pf)
809
869
  .catch(() => [])
810
870
  : Promise.resolve([]),
@@ -834,6 +894,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
834
894
  pf)
835
895
  .catch(() => []),
836
896
  ]);
897
+ // Los CUATRO conjuntos, sobre filas ya canonicalizadas. Si uno solo se
898
+ // quedara crudo, la respuesta hablaría de un módulo con la reincidencia
899
+ // o las dependencias de otro — y esta tool corre antes de cada edición,
900
+ // así que el error viajaría dentro de una decisión de código.
901
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliasesFile);
902
+ const recidivismRows = canonicalizeModuleRows(recidivismCrudas, aliasesFile);
903
+ const depsRows = canonicalizeModuleRows(depsCrudas, aliasesFile);
904
+ const coChangeRows = canonicalizeModuleRows(coChangeCrudas, aliasesFile);
837
905
  const watchedByFile = new Map();
838
906
  for (const w of watched) {
839
907
  watchedByFile.set(w.file, [...(watchedByFile.get(w.file) ?? []), w]);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.raulbr90/changebook",
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.",
5
+ "description": "Your agent already broke this three times. ChangeBook tells it before the fourth. MCP server + CLI: the history of what broke in your repo, served to Claude Code, Cursor or Codex before they edit.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "bin": {
package/server.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
- "description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
5
- "version": "0.5.0",
4
+ "description": "Your agent already broke this three times. ChangeBook tells it before the fourth.",
5
+ "version": "0.7.0",
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.5.0",
18
+ "version": "0.7.0",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }