changebook 0.4.1 → 0.4.2

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/guard.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
+ import { execFileSync } from "node:child_process";
17
18
  import { execFileAsync } from "./git.js";
18
19
  /** Exit code that asks the pre-commit hook to abort the commit. */
19
20
  export const EXIT_BLOCK = 3;
@@ -71,8 +72,61 @@ export function moduleFilesUnion(rows) {
71
72
  }
72
73
  return new Map([...map.entries()].map(([k, v]) => [k, [...v]]));
73
74
  }
74
- /** Open alerts × staged files → warnings, deduped by (module, message). */
75
- export function guardFindings(staged, alerts, filesByModule) {
75
+ /** Rutas propias del aviso, si las trae. */
76
+ function alertFiles(alert) {
77
+ return Array.isArray(alert.files)
78
+ ? alert.files.filter((f) => typeof f === "string")
79
+ : [];
80
+ }
81
+ /**
82
+ * ¿Se contradice el aviso con el codigo que hay delante?
83
+ *
84
+ * Devuelve `true` SOLO cuando la evidencia lo tumba de forma inequivoca. Sin
85
+ * evidencia, con el simbolo vacio o si la busqueda falla, devuelve `false`: la
86
+ * duda deja pasar el aviso. Un guardian que se calla por un error de disco es
87
+ * peor que uno ruidoso — misma leccion que el limitador que fallaba abierto y
88
+ * nadie noto en nueve dias.
89
+ *
90
+ * `buscar` devuelve cuantas veces aparece el simbolo, o null si no se pudo
91
+ * mirar.
92
+ */
93
+ export function avisoRefutado(alert, buscar) {
94
+ const simbolo = (alert.evidence_symbol ?? "").trim();
95
+ const espera = alert.evidence_expect;
96
+ if (!simbolo || (espera !== "present" && espera !== "absent"))
97
+ return false;
98
+ let apariciones;
99
+ try {
100
+ apariciones = buscar(simbolo);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ if (apariciones === null)
106
+ return false;
107
+ // "present": el aviso vive de que el simbolo siga ahi. Si ya no esta, el
108
+ // conflicto que describia no puede darse — es el caso de las 3 alertas del
109
+ // renombrado latestFilesByModule -> moduleFilesUnion: cero referencias.
110
+ if (espera === "present")
111
+ return apariciones === 0;
112
+ // "absent": el aviso vive de que algo FALTE. Si aparece, ya esta hecho.
113
+ return apariciones > 0;
114
+ }
115
+ /**
116
+ * Open alerts × staged files → warnings, deduped by (module, message).
117
+ *
118
+ * Dos filtros, los dos nacidos de medir los avisos reales del 2026-07-19.
119
+ *
120
+ * 1. A QUIEN se avisa. Antes se cruzaba contra `filesByModule`: TODOS los
121
+ * ficheros que alguna vez tocaron ese modulo. Por eso un aviso sobre
122
+ * `i18n.ts` saltaba al preparar `mcp/index.ts` — comparten modulo. Si el
123
+ * aviso trae sus propias rutas mandan esas; si no, se cae al modulo entero
124
+ * como antes, porque los avisos anteriores no las tienen.
125
+ *
126
+ * 2. SI SIGUE EN PIE. `refutado` lo decide quien llama, que es quien tiene el
127
+ * arbol de trabajo. De 7 avisos abiertos, 4 se caian con un grep.
128
+ */
129
+ export function guardFindings(staged, alerts, filesByModule, refutado) {
76
130
  const stagedSet = new Set(staged);
77
131
  const seen = new Set();
78
132
  const findings = [];
@@ -81,9 +135,17 @@ export function guardFindings(staged, alerts, filesByModule) {
81
135
  const plain = (alert.plain ?? "").trim();
82
136
  if (!module || !plain)
83
137
  continue;
84
- const touched = (filesByModule.get(module) ?? []).filter((f) => stagedSet.has(f));
138
+ // Las rutas del aviso mandan sobre las del modulo: dicen de QUE va, no solo
139
+ // a que cajon pertenece.
140
+ const propias = alertFiles(alert);
141
+ const ambito = propias.length > 0 ? propias : (filesByModule.get(module) ?? []);
142
+ const touched = ambito.filter((f) => stagedSet.has(f));
85
143
  if (touched.length === 0)
86
144
  continue;
145
+ // Se refuta DESPUES de acotar: si el aviso no te toca, no hay por que
146
+ // gastar una lectura de disco en tumbarlo.
147
+ if (refutado?.(alert))
148
+ continue;
87
149
  const key = module + "\u0000" + plain;
88
150
  if (seen.has(key))
89
151
  continue;
@@ -92,6 +154,48 @@ export function guardFindings(staged, alerts, filesByModule) {
92
154
  }
93
155
  return findings;
94
156
  }
157
+ /**
158
+ * Cuantas veces aparece un simbolo en el codigo versionado. `null` si no se
159
+ * pudo mirar — y ese `null` importa: hace que el aviso pase, no que se calle.
160
+ *
161
+ * `git grep` y no un recorrido propio: respeta .gitignore, no entra en
162
+ * node_modules y esta escrito en C. Sobre este repo tarda ~30 ms, asi que cabe
163
+ * de sobra en el presupuesto de 3,5 s del guardian.
164
+ *
165
+ * `--fixed-strings` es obligatorio: el simbolo viene de un modelo y un `$` o un
166
+ * `.` sueltos lo convertirian en otra expresion regular.
167
+ */
168
+ export function contarEnRepo(dir, simbolo) {
169
+ if (!/^[A-Za-z_$][\w$.]{1,118}$/.test(simbolo))
170
+ return null;
171
+ try {
172
+ const out = execFileSync("git", [
173
+ "grep",
174
+ "--fixed-strings",
175
+ "--count",
176
+ "--",
177
+ simbolo,
178
+ // Se busca en CODIGO, nunca en prosa. Sin esto el mecanismo nace
179
+ // inutil: el propio `sync` escribe el texto de las alertas en
180
+ // CLAUDE.md y AGENTS.md, y ese texto CONTIENE el simbolo. Cualquier
181
+ // aviso de tipo "esto sigue usandose" encontraria su propia cita y no
182
+ // podria refutarse jamas. Lo cazo el test, no el diseno.
183
+ ":!*.md",
184
+ ":!docs/",
185
+ ], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
186
+ // Una linea "fichero:N" por fichero con coincidencias.
187
+ return out
188
+ .split("\n")
189
+ .filter(Boolean)
190
+ .reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
191
+ }
192
+ catch (e) {
193
+ // git grep sale con 1 cuando NO hay coincidencias: eso es un cero real, no
194
+ // un fallo. Cualquier otro codigo si es "no he podido mirar".
195
+ const code = e.status;
196
+ return code === 1 ? 0 : null;
197
+ }
198
+ }
95
199
  async function gitPath(dir, name) {
96
200
  const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
97
201
  return path.resolve(dir, stdout.trim());
@@ -140,7 +244,7 @@ async function fetchSignals(db, dir, env) {
140
244
  let filesByModule = new Map();
141
245
  const projectId = projects[0]?.id ?? null;
142
246
  if (projectId) {
143
- alerts = await db.rest(`regression_alerts?select=module,plain,created_at&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
247
+ alerts = await db.rest(`regression_alerts?select=module,plain,created_at,evidence_symbol,evidence_expect,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
144
248
  const modules = [
145
249
  ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
146
250
  ];
@@ -236,7 +340,7 @@ export async function runGuard(db, dir, env = process.env) {
236
340
  await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
237
341
  return 0;
238
342
  }
239
- const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
343
+ const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)));
240
344
  const block = mode === "block";
241
345
  const message = findingsMessage(findings, block);
242
346
  // La consulta del guardián también es una consulta del atlas (QA
package/dist/supabase.js CHANGED
@@ -111,6 +111,17 @@ export class Supabase {
111
111
  rows = await this.rest(`projects?select=id&name=eq.${encodeURIComponent(wanted)}&limit=1`);
112
112
  }
113
113
  if (rows.length === 0) {
114
+ // Con UN solo proyecto no hay frontera que proteger: el nombre sirve
115
+ // para elegir y no hay entre qué elegir. Fallar aquí gastaba una llamada
116
+ // entera para decir "no lo encuentro" cuando la respuesta era obvia — y
117
+ // pasa de verdad, porque el nombre que se manda es el basename del
118
+ // directorio y basta clonar el repo en una carpeta distinta.
119
+ //
120
+ // Espejo de decideScope en supabase/functions/mcp/scope.ts. Es lo que
121
+ // permite exigir `project` en el esquema sin quitarle nada a nadie.
122
+ const todos = await this.rest(`projects?select=id&limit=2`);
123
+ if (todos.length === 1)
124
+ return `&project_id=eq.${todos[0].id}`;
114
125
  throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
115
126
  }
116
127
  return `&project_id=eq.${rows[0].id}`;
package/dist/sync.js CHANGED
@@ -30,13 +30,22 @@ function sanitizeCell(text) {
30
30
  const MAX_MODULES = 15;
31
31
  const MAX_CHANGES = 5;
32
32
  const MAX_COUPLINGS = 5;
33
- // El bloque entra en CADA sesión de agente del usuario, así que tiene un
34
- // presupuesto fijo (~750 tokens) y nunca crece sin control. Cuando no cabe
35
- // todo, se recorta por prioridad: regresiones > encargos > acoplamientos >
36
- // hotspots > módulos > últimos cambios. Subido de 2.000 a 3.000 el
37
- // 2026-07-18: la cabecera ganó las reglas de frontera/aviso/file-context y
38
- // con 2.000 expulsaba Módulos y Encargos enteros del mapa.
39
- const SYNC_BUDGET_CHARS = 3_000;
33
+ // El bloque entra en CADA sesión de agente del usuario, y ademas se reenvia al
34
+ // modelo en CADA peticion de esa sesion: es coste FIJO, se consulte el atlas o
35
+ // no. Por eso tiene presupuesto y nunca crece sin control.
36
+ //
37
+ // Historia de este numero, que es una leccion:
38
+ // 2.000 -> 3.000 el 2026-07-18, porque la cabecera habia engordado y
39
+ // expulsaba Módulos y Encargos. Se subio el techo en vez de
40
+ // adelgazar la prosa.
41
+ // 3.000 -> 2.000 el 2026-07-19, al medir que NO funciono: la cabecera crecio
42
+ // hasta 1.109 chars (46% del presupuesto) y la seccion "### Módulos"
43
+ // seguia expulsada. El bloque se llamaba "Mapa del producto" y salia
44
+ // sin mapa, en el commit mismo que subio el techo para evitarlo.
45
+ //
46
+ // Subir el techo alimenta al que se lo come. Adelgazada la cabecera a 271
47
+ // chars, con 2.000 cabe mas contenido REAL que antes con 3.000.
48
+ const SYNC_BUDGET_CHARS = 2_000;
40
49
  // Co-change pair thresholds — same spirit as the web's signals: at least 3
41
50
  // shared analyses and a ≥60% rate before we call it a dependency.
42
51
  const MIN_PAIR_COUNT = 3;
@@ -128,31 +137,31 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
128
137
  // útiles ya van por entrada (regresiones, últimos cambios).
129
138
  "## Mapa del producto (ChangeBook · auto-generado)",
130
139
  "",
131
- "Este proyecto tiene memoria en ChangeBook. Al EMPEZAR la sesión, oriéntate con UNA sola llamada a la tool MCP `atlas_project_brief` (mapa + alertas + encargos pendientes + últimos cambios); para profundizar: `atlas_module_detail` (detalle + diffs).",
140
+ // Esta cabecera se paga en CADA peticion de CADA sesion, se consulte el
141
+ // atlas o no, y ademas se reenvia al modelo cada vez. Medido el
142
+ // 2026-07-19: ocupaba 1.109 chars de instrucciones sobre un presupuesto de
143
+ // 3.000, o sea el 46%, y por eso la seccion "### Módulos" quedaba
144
+ // EXPULSADA: el bloque se llamaba "Mapa del producto" y salia sin mapa.
145
+ //
146
+ // Las seis reglas siguen estando —cada una se gano con un incidente— pero
147
+ // dichas una vez y en corto. Lo que se quita es la explicacion, no la
148
+ // instruccion: un agente no necesita que le argumenten por que.
149
+ // · orientarse por el brief en una llamada
150
+ // · file_context antes de tocar un archivo
151
+ // · registrar tras cada commit, con summary propio
152
+ // · frontera por proyecto (QA 2026-07-18: se mezclaban proyectos)
153
+ // · anunciar antes de atacar un encargo
154
+ // · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
155
+ // agente lo uso y siguio callado; desde fuera eso es no hacer nada)
156
+ "Memoria del proyecto en ChangeBook. Oriéntate con UNA llamada a `atlas_project_brief`. Antes de tocar un archivo: `atlas_file_context` con su ruta. Tras cada commit: `atlas_record_change` con el diff, `commit_hash`, `committed_at` y un `summary` tuyo (abarata el análisis; reenviar lo ya registrado es gratis).",
132
157
  "",
133
- "Si vas a trabajar un encargo pendiente del atlas, anuncia primero al usuario en 2-3 líneas qué vas a hacer y por qué, antes de tocar código.",
134
- "",
135
- // QA de Raúl 2026-07-19: el atlas avisó de un riesgo real, el agente lo
136
- // usó y siguió trabajando sin decir nada. Desde fuera, un guardián que
137
- // trabaja callado y un producto que no hace nada son la misma cosa. El
138
- // dueño paga esto: tiene que verlo ocurrir.
139
- "Cuando el atlas te avise de un riesgo (alerta abierta, `atlas_file_context` o el guardián), DÍSELO al usuario en 1-2 líneas antes de seguir, aunque lo resuelvas tú: él no ve esos avisos.",
140
- "",
141
- "Antes de tocar un archivo concreto, consulta `atlas_file_context` con sus rutas: te da su módulo, riesgo, alertas abiertas e historia por una llamada — más barato que releer el código y su git log.",
142
- "",
143
- // Frontera por proyecto (QA 2026-07-18): el atlas y la cola de encargos
144
- // son POR PROYECTO; sin este dato el agente recibiría los de otros.
145
158
  ...(projectName
146
159
  ? [
147
- `El proyecto de ESTE repo es \`${sanitizeCell(projectName)}\`. El atlas es por proyecto: pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\` en las tools del atlas (mapa, historia, detalle, encargos y registro). Los encargos o datos de otros proyectos no pertenecen a esta sesión.`,
160
+ `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
148
161
  "",
149
162
  ]
150
163
  : []),
151
- // La instrucción de escritura vive AQUÍ y no en la doc de la tool: los
152
- // agentes obedecen lo que el workspace les dice, no lo que una tool
153
- // disponible insinúa (lección 2026-07-17: un agente con la tool conectada
154
- // hizo 3 PRs y no registró ninguno hasta que se lo pidieron).
155
- "Tras cada commit, registra el cambio con la tool MCP `atlas_record_change`: pásale el diff (`git show HEAD`), `commit_hash`, `committed_at` y SIEMPRE `summary` (2-5 frases tuyas sobre qué cambió y por qué — abarata mucho el análisis). Reenviar un commit ya registrado es un no-op gratuito.",
164
+ "Habla: anuncia en 2-3 líneas qué vas a hacer antes de atacar un encargo, y cuéntale al usuario cualquier riesgo que el atlas te enseñe — él no ve esos avisos.",
156
165
  "",
157
166
  ];
158
167
  if (modules.length === 0) {
@@ -162,15 +171,19 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
162
171
  END,
163
172
  ].join("\n");
164
173
  }
174
+ // Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
175
+ // nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
176
+ // DOS. Un mapa de dos modulos sobre 46 no es un mapa, es una anecdota.
177
+ //
178
+ // Aqui el mapa solo tiene que decir QUE existe y QUE quema; el detalle
179
+ // (ficheros, notas, historia) esta a una llamada de `atlas_module_detail` y
180
+ // ahi se paga solo cuando hace falta, en vez de en cada peticion de todas
181
+ // las sesiones. El riesgo se marca solo cuando NO es bajo: "riesgo low"
182
+ // repetido cuarenta veces es ruido que se paga igual que la senal.
165
183
  const moduleLines = modules.map((m) => {
166
- const files = Array.isArray(m.files)
167
- ? m.files.slice(0, 3).map(String).join(", ")
168
- : "";
169
- const meta = [m.domain, m.risk && `riesgo ${m.risk}`]
170
- .filter(Boolean)
171
- .join(" · ");
172
- const note = sanitizeCell((m.note ?? "").slice(0, 110));
173
- return `- **${m.module}**${meta ? ` (${meta})` : ""}${files ? ` — ${files}` : ""}${note ? ` — ${note}` : ""}`;
184
+ const riesgo = m.risk && m.risk !== "low" ? ` ⚠ ${m.risk}` : "";
185
+ const area = m.domain ? ` · ${m.domain}` : "";
186
+ return `- **${m.module}**${area}${riesgo}`;
174
187
  });
175
188
  // Prevention beats detection: give the agent the co-change dependencies
176
189
  // and open warnings BEFORE it edits, not after something breaks.
@@ -181,7 +194,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
181
194
  .filter((a) => (a.plain ?? "").trim())
182
195
  .map((a) => {
183
196
  const mod = (a.module ?? "").trim();
184
- return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ""} ${sanitizeCell((a.plain ?? "").slice(0, 200))}`;
197
+ // 130 y no 200: en un bloque de coste FIJO el aviso es un titular, no
198
+ // un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
199
+ // y expulsaban el mapa. El texto entero sigue a una llamada de
200
+ // `atlas_project_brief`, donde se paga solo si alguien pregunta.
201
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ""} — ${sanitizeCell((a.plain ?? "").slice(0, 130))}`;
185
202
  });
186
203
  const hotspotLines = modules
187
204
  .filter((m) => m.risk === "hotspot")
@@ -208,7 +225,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
208
225
  // (utilidad para el agente) son independientes: si no cabe todo, caen
209
226
  // primero los últimos cambios y los módulos, nunca las regresiones.
210
227
  const sections = [
211
- { key: "modules", priority: 4, title: "### Módulos", lines: moduleLines },
228
+ { key: "modules", priority: 2, title: "### Módulos", lines: moduleLines },
212
229
  {
213
230
  key: "tasks",
214
231
  priority: 1,
@@ -217,7 +234,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
217
234
  },
218
235
  {
219
236
  key: "couplings",
220
- priority: 2,
237
+ priority: 3,
221
238
  title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
222
239
  lines: couplingLines,
223
240
  },
@@ -229,7 +246,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
229
246
  },
230
247
  {
231
248
  key: "hotspots",
232
- priority: 3,
249
+ priority: 4,
233
250
  title: "### Avisos abiertos (revisar antes de modificar)",
234
251
  lines: hotspotLines,
235
252
  },
package/dist/tools.js CHANGED
@@ -8,6 +8,20 @@ import { z } from "zod";
8
8
  import { SupabaseError } from "./supabase.js";
9
9
  const CHARACTER_LIMIT = 25_000;
10
10
  // ── Helpers ───────────────────────────────────────────────────────────────────
11
+ /**
12
+ * Lo que de VERDAD llega al modelo. Espejo de servedCharsOf en
13
+ * supabase/functions/mcp/index.ts.
14
+ *
15
+ * `chars_served` contaba el markdown, y el markdown casi nunca se usa: los
16
+ * tool_result llegan al modelo como el JSON del structuredContent. Solo cuando
17
+ * la respuesta revienta CHARACTER_LIMIT deja de haber JSON y queda el texto.
18
+ * Contar el markdown infravaloraba el gasto un 35%.
19
+ */
20
+ function servedCharsOf(result) {
21
+ return "structuredContent" in result && result.structuredContent
22
+ ? JSON.stringify(result.structuredContent).length
23
+ : (result.content?.[0]?.text?.length ?? 0);
24
+ }
11
25
  function errorResult(error) {
12
26
  const message = error instanceof SupabaseError || error instanceof Error
13
27
  ? error.message
@@ -103,7 +117,7 @@ Args:
103
117
  - search (optional): case-insensitive text filter over the business and technical summaries.
104
118
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
105
119
 
106
- Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, summary_tech, diff_chars, modules: [{ module, risk }] }] }
120
+ Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
107
121
 
108
122
  Don't use for per-module deep dives — use atlas_module_detail for that.`,
109
123
  inputSchema: {
@@ -113,8 +127,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
113
127
  .describe("Pagination offset"),
114
128
  search: z.string().min(2).max(120).optional()
115
129
  .describe("Case-insensitive filter over summaries"),
116
- project: z.string().min(1).max(120).optional()
130
+ project: z.string().min(1).max(120)
117
131
  .describe("Project to scope to (repo folder name or slug)"),
132
+ include_tech: z.boolean().default(false),
118
133
  },
119
134
  annotations: {
120
135
  readOnlyHint: true,
@@ -122,7 +137,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
122
137
  idempotentHint: true,
123
138
  openWorldHint: true,
124
139
  },
125
- }, async ({ limit, offset, search, project }) => {
140
+ }, async ({ limit, offset, search, project, include_tech }) => {
126
141
  try {
127
142
  const pf = await db.projectFilterFor(project);
128
143
  let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
@@ -152,7 +167,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
152
167
  id: r.id,
153
168
  date: day(r.created_at),
154
169
  business_impact: r.business_impact ?? "",
155
- summary_tech: r.summary_tech ?? null,
170
+ // Espejo de mcp/index.ts: `summary_tech` es el campo mas pesado y
171
+ // el que menos usa un agente que se esta orientando. Fuera por
172
+ // defecto; dentro si lo pide o si hay `search`, porque la busqueda
173
+ // mira ese campo en el servidor y sin verlo el agente no sabria por
174
+ // que casaron los resultados.
175
+ ...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
156
176
  diff_chars: r.diff_character_count ?? null,
157
177
  modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
158
178
  module: m.module,
@@ -171,7 +191,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
171
191
  .map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
172
192
  .join(", ");
173
193
  lines.push(`## ${c.date} — ${c.business_impact}`);
174
- if (c.summary_tech)
194
+ if ((include_tech || search) && c.summary_tech)
175
195
  lines.push(`- Tech: ${c.summary_tech}`);
176
196
  if (mods)
177
197
  lines.push(`- Modules: ${mods}`);
@@ -183,8 +203,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
183
203
  : "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
184
204
  }
185
205
  const changesText = lines.join("\n");
186
- recordRead(db, "atlas_recent_changes", pf, changesText.length);
187
- return toolResult(changesText, output);
206
+ const salida = toolResult(changesText, output);
207
+ recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
208
+ return salida;
188
209
  }
189
210
  catch (error) {
190
211
  return errorResult(error);
@@ -194,17 +215,17 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
194
215
  title: "ChangeBook module map",
195
216
  description: `List the modules of the product as known by ChangeBook, aggregated from the change history.
196
217
 
197
- For each module: domain, category, latest risk level, number of analyzed changes, last-change date and the files touched most recently. Use this to orient yourself in an unfamiliar codebase or to pick a module for atlas_module_detail.
218
+ For each module: domain, latest risk level, number of analyzed changes and last-change date. Files, notes and diffs live in atlas_module_detail this is the map, not the terrain.
198
219
 
199
220
  Args:
200
221
  - domain (optional): filter by domain (e.g. "billing").
201
222
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
202
223
 
203
- Returns (structured): { count, modules: [{ module, domain, category, risk, changes, last_changed, files }] }`,
224
+ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_changed }] }`,
204
225
  inputSchema: {
205
226
  domain: z.string().min(1).max(80).optional()
206
227
  .describe("Only modules in this domain"),
207
- project: z.string().min(1).max(120).optional()
228
+ project: z.string().min(1).max(120)
208
229
  .describe("Project to scope to (repo folder name or slug)"),
209
230
  },
210
231
  annotations: {
@@ -219,7 +240,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
219
240
  let query =
220
241
  // The aggregation below uses only these columns; note/tech/excerpt
221
242
  // (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
222
- `change_module?select=module,domain,category,risk,files,created_at` +
243
+ `change_module?select=module,domain,risk,created_at` +
223
244
  `&order=created_at.desc&limit=1000` +
224
245
  pf;
225
246
  if (domain)
@@ -238,11 +259,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
238
259
  return {
239
260
  module: name,
240
261
  domain: latest.domain,
241
- category: latest.category,
242
262
  risk: latest.risk,
243
263
  changes: list.length,
244
264
  last_changed: day(latest.created_at),
245
- files: fileList(latest.files),
246
265
  };
247
266
  });
248
267
  modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
@@ -251,8 +270,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
251
270
  for (const m of modules) {
252
271
  lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
253
272
  `${m.changes} change(s), last ${m.last_changed}` +
254
- (m.risk ? `, risk: ${m.risk}` : "") +
255
- (m.files.length ? ` — files: ${m.files.join(", ")}` : ""));
273
+ (m.risk ? `, risk: ${m.risk}` : ""));
256
274
  }
257
275
  if (modules.length === 0) {
258
276
  lines.push(domain
@@ -260,8 +278,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
260
278
  : "No modules yet. Analyze a diff from the ChangeBook extension first.");
261
279
  }
262
280
  const modulesText = lines.join("\n");
263
- recordRead(db, "atlas_modules", pf, modulesText.length);
264
- return toolResult(modulesText, output);
281
+ const salida = toolResult(modulesText, output);
282
+ recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
283
+ return salida;
265
284
  }
266
285
  catch (error) {
267
286
  return errorResult(error);
@@ -290,7 +309,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
290
309
  .describe("Include diff excerpts (previews unless full)"),
291
310
  full: z.boolean().default(false)
292
311
  .describe("Verbatim excerpts instead of short previews"),
293
- project: z.string().min(1).max(120).optional()
312
+ project: z.string().min(1).max(120)
294
313
  .describe("Project to scope to (repo folder name or slug)"),
295
314
  },
296
315
  annotations: {
@@ -375,8 +394,9 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
375
394
  lines.push("");
376
395
  }
377
396
  const detailText = lines.join("\n");
378
- recordRead(db, "atlas_module_detail", pf, detailText.length);
379
- return toolResult(detailText, output);
397
+ const salida = toolResult(detailText, output);
398
+ recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
399
+ return salida;
380
400
  }
381
401
  catch (error) {
382
402
  return errorResult(error);
@@ -396,7 +416,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
396
416
  inputSchema: {
397
417
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
398
418
  .describe("Repo-relative paths you are about to edit"),
399
- project: z.string().min(1).max(120).optional()
419
+ project: z.string().min(1).max(120)
400
420
  .describe("Project to scope to (repo folder name or slug)"),
401
421
  },
402
422
  annotations: {
@@ -464,8 +484,9 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
464
484
  lines.push("");
465
485
  }
466
486
  const contextText = lines.join("\n");
467
- recordRead(db, "atlas_file_context", pf, contextText.length);
468
- return toolResult(contextText, { files: perFile });
487
+ const salida = toolResult(contextText, { files: perFile });
488
+ recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
489
+ return salida;
469
490
  }
470
491
  catch (error) {
471
492
  return errorResult(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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.1",
5
+ "version": "0.4.2",
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.1",
18
+ "version": "0.4.2",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }