changebook 0.4.3 → 0.4.5

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
@@ -181,7 +181,11 @@ export function contarEnRepo(dir, simbolo) {
181
181
  // aviso de tipo "esto sigue usandose" encontraria su propia cita y no
182
182
  // podria refutarse jamas. Lo cazo el test, no el diseno.
183
183
  ":!*.md",
184
- ":!docs/",
184
+ // Con comodín a propósito: un pathspec sin comodín ("docs/") hace
185
+ // abortar a git grep si la carpeta no existe — en cualquier repo de
186
+ // usuario sin docs/ el buscador devolvía null y la refutación moría
187
+ // en silencio. Lo cazó el fixture hermético del test (2026-07-21).
188
+ ":!docs/**",
185
189
  ], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
186
190
  // Una linea "fichero:N" por fichero con coincidencias.
187
191
  return out
package/dist/index.js CHANGED
@@ -131,7 +131,23 @@ async function main() {
131
131
  case "analyze": {
132
132
  const db = new Supabase();
133
133
  requireCredentials(db);
134
- await analyze(db, parseAnalyzeArgs(process.argv.slice(3)));
134
+ const options = parseAnalyzeArgs(process.argv.slice(3));
135
+ await analyze(db, options);
136
+ // El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
137
+ // así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
138
+ // causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
139
+ // del hook post-commit, o sea CADA commit: refrescar aquí hace el mapa
140
+ // fresco por construcción, como watched_values. refreshOnly: jamás crea
141
+ // archivos ni resucita un bloque borrado. Best-effort: un sync caído no
142
+ // puede tumbar un análisis ya cobrado y registrado.
143
+ try {
144
+ await syncContextFiles(db, options.dir ?? process.cwd(), {
145
+ refreshOnly: true,
146
+ });
147
+ }
148
+ catch (error) {
149
+ console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
150
+ }
135
151
  return;
136
152
  }
137
153
  case "import": {
package/dist/sync.js CHANGED
@@ -53,7 +53,7 @@ const MIN_PAIR_RATE = 0.6;
53
53
  // Same window/limit the web uses for the signals strip.
54
54
  const ALERT_WINDOW_DAYS = 14;
55
55
  const MAX_ALERTS = 3;
56
- export async function syncContextFiles(db, targetDir) {
56
+ export async function syncContextFiles(db, targetDir, opts = {}) {
57
57
  const projectName = projectNameFor(targetDir);
58
58
  // Frontera por proyecto también aquí (QA 2026-07-18): sin filtro, una
59
59
  // cuenta con varios proyectos construiría el mapa de ESTE repo mezclando
@@ -103,7 +103,7 @@ export async function syncContextFiles(db, targetDir) {
103
103
  const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
104
104
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
105
105
  const file = path.join(targetDir, name);
106
- const updated = await upsertSection(file, section);
106
+ const updated = await upsertSection(file, section, opts);
107
107
  console.error(`${updated} ${name}`);
108
108
  }
109
109
  // El sync ES una consulta del atlas — la más apalancada: el mapa que
@@ -156,15 +156,31 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
156
156
  // · anunciar antes de atacar un encargo
157
157
  // · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
158
158
  // agente lo uso y siguio callado; desde fuera eso es no hacer nada)
159
- '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).',
159
+ // · cargar las tools diferidas de UNA vez (benchmark 2026-07-20: 3 de 7
160
+ // llamadas del agente CON atlas eran ToolSearch cargando esquemas de
161
+ // uno en uno — cada una relee ~45k tokens de contexto)
162
+ //
163
+ // La FORMA de las instrucciones es disparador → tool, no prosa. Medido en
164
+ // la tanda 8808ebe (adopcion por celda, 2026-07-21): la adopcion sigue al
165
+ // matching LITERAL entre la tarea y el disparador escrito — "vas a editar
166
+ // el archivo" casaba con el disparador de file_context y adopto 5/5; el
167
+ // generico de orientacion 3/5; "investiga la funcion X", sin disparador,
168
+ // 0/5. Un agente no infiere que una tool aplica: reconoce su tarea en el
169
+ // texto o no la usa. Por eso cada tool lleva delante la formulacion de
170
+ // tarea que debe capturar, y el ToolSearch va con el select completo
171
+ // listo para copiar (cargar esquemas de uno en uno costaba 3 turnos).
172
+ // El disparador de symbol_lookup se RETIRÓ el 2026-07-21 con medición en
173
+ // la mano (tanda 957372c): llevó la adopción de t3 de 0/5 a 3/5, pero los
174
+ // adoptantes gastaron 607k tok/9 turnos vs 254k/4 del brazo sin atlas —
175
+ // la tool solo cubre definiciones y el agente acababa pagando atlas Y
176
+ // grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
177
+ // hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
178
+ '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).',
179
+ '',
180
+ 'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
160
181
  '',
161
- ...(projectName
162
- ? [
163
- `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
164
- '',
165
- ]
166
- : []),
167
- '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.',
182
+ // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
183
+ `${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.`,
168
184
  '',
169
185
  ];
170
186
  if (modules.length === 0) {
@@ -342,7 +358,7 @@ export function coChangePairs(rows) {
342
358
  return pairs.sort((x, y) => y.rate - x.rate).slice(0, MAX_COUPLINGS);
343
359
  }
344
360
  /** Exported for tests. */
345
- export async function upsertSection(file, section) {
361
+ export async function upsertSection(file, section, opts = {}) {
346
362
  let content = null;
347
363
  try {
348
364
  content = await readFile(file, 'utf8');
@@ -351,6 +367,8 @@ export async function upsertSection(file, section) {
351
367
  content = null;
352
368
  }
353
369
  if (content === null) {
370
+ if (opts.refreshOnly)
371
+ return 'skipped';
354
372
  await writeFile(file, section + '\n');
355
373
  return 'created';
356
374
  }
@@ -359,6 +377,14 @@ export async function upsertSection(file, section) {
359
377
  // nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
360
378
  const LEGACY_START = '<!-- appatlas:start -->';
361
379
  const LEGACY_END = '<!-- appatlas:end -->';
380
+ // Un archivo sin NINGÚN bloque (ni actual ni legado) en modo refreshOnly es
381
+ // una decisión del dueño, no un hueco que rellenar: borró el mapa y el
382
+ // refresco automático no puede volver a pegárselo en cada commit.
383
+ if (opts.refreshOnly &&
384
+ !(content.includes(START) && content.includes(END)) &&
385
+ !(content.includes(LEGACY_START) && content.includes(LEGACY_END))) {
386
+ return 'skipped';
387
+ }
362
388
  const legacyStart = content.indexOf(LEGACY_START);
363
389
  const legacyEnd = content.indexOf(LEGACY_END);
364
390
  if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
package/dist/tools.js CHANGED
@@ -95,11 +95,54 @@ export function quotedInList(values) {
95
95
  .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
96
96
  .join(",");
97
97
  }
98
+ export const FILES_CAP = 8;
99
+ export function filesUnionByChange(rows, cap = FILES_CAP) {
100
+ const acc = new Map();
101
+ for (const r of rows) {
102
+ if (!Array.isArray(r.files))
103
+ continue;
104
+ let list = acc.get(r.changelog_id);
105
+ if (!list) {
106
+ list = [];
107
+ acc.set(r.changelog_id, list);
108
+ }
109
+ for (const f of r.files) {
110
+ if (typeof f === "string" && f.length > 0 && !list.includes(f)) {
111
+ list.push(f);
112
+ }
113
+ }
114
+ }
115
+ const out = new Map();
116
+ for (const [id, list] of acc) {
117
+ out.set(id, {
118
+ files: list.slice(0, cap),
119
+ more: Math.max(0, list.length - cap),
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+ // Espejo de supabase/functions/mcp/scope.ts (paridad en
125
+ // test/espejoDeCommits.test.ts): aliases post-squash del mismo contenido,
126
+ // para que el hash citado exista en el main del consultante.
127
+ export function commitAliasesShort(raw) {
128
+ if (!Array.isArray(raw))
129
+ return [];
130
+ return raw
131
+ .filter((h) => typeof h === "string" && /^[0-9a-f]{7,64}$/i.test(h))
132
+ .map((h) => h.slice(0, 7));
133
+ }
134
+ export function commitLabel(hash, aliasesRaw) {
135
+ const corto = hash?.slice(0, 7) ?? null;
136
+ if (!corto)
137
+ return null;
138
+ const aliases = commitAliasesShort(aliasesRaw);
139
+ return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
140
+ }
98
141
  // Consultation metering (never billing): each successful read leaves a row in
99
142
  // atlas_reads so the web can show "your agent consulted the atlas N times".
100
143
  // Best-effort and non-blocking — metering must never break or slow a read.
101
144
  // user_id is filled server-side (column default auth.uid()).
102
- function recordRead(db, tool, projectFilter, charsServed) {
145
+ function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
103
146
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
104
147
  void db
105
148
  .insertRow("atlas_reads", {
@@ -107,6 +150,11 @@ function recordRead(db, tool, projectFilter, charsServed) {
107
150
  tool,
108
151
  source: "stdio",
109
152
  chars_served: charsServed,
153
+ // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
154
+ // metering, jamás contrato, y un servidor viejo simplemente no la manda.
155
+ ...(latencyMs !== undefined
156
+ ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
157
+ : {}),
110
158
  })
111
159
  .catch(() => { });
112
160
  }
@@ -157,6 +205,76 @@ export async function derivaContraHead(dir, hash) {
157
205
  }
158
206
  // ── Registration ──────────────────────────────────────────────────────────────
159
207
  export function registerTools(server, db) {
208
+ // Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
209
+ // exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
210
+ server.registerTool("atlas_symbol_lookup", {
211
+ title: "Where is this symbol defined?",
212
+ 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.
213
+
214
+ Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
215
+
216
+ Args:
217
+ - symbol (required): the identifier to look up.
218
+ - project (recommended): the repo you are working in (folder name or slug).
219
+
220
+ Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
221
+ inputSchema: {
222
+ symbol: z
223
+ .string()
224
+ .min(2)
225
+ .max(120)
226
+ .describe("Identifier to look up (exported definition)"),
227
+ project: z
228
+ .string()
229
+ .min(1)
230
+ .max(120)
231
+ .describe("Project to scope to (repo folder name or slug)"),
232
+ },
233
+ annotations: {
234
+ readOnlyHint: true,
235
+ destructiveHint: false,
236
+ idempotentHint: true,
237
+ openWorldHint: true,
238
+ },
239
+ }, async ({ symbol, project }) => {
240
+ try {
241
+ const t0 = Date.now();
242
+ const pf = await db.projectFilterFor(project);
243
+ const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
244
+ pf;
245
+ let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
246
+ let exact = true;
247
+ if (matches.length === 0) {
248
+ exact = false;
249
+ matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
250
+ }
251
+ const lines = [`# Symbol lookup: ${symbol}`, ""];
252
+ if (matches.length === 0) {
253
+ 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.");
254
+ }
255
+ for (const m of matches) {
256
+ lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
257
+ }
258
+ if (matches.length > 0) {
259
+ lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
260
+ }
261
+ const salida = toolResult(lines.join("\n"), {
262
+ symbol,
263
+ exact_match: exact,
264
+ matches: matches.map((m) => ({
265
+ symbol: m.symbol,
266
+ file: m.file,
267
+ kind: m.kind,
268
+ commit: m.commit_hash?.slice(0, 7) ?? null,
269
+ })),
270
+ });
271
+ recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
272
+ return salida;
273
+ }
274
+ catch (error) {
275
+ return errorResult(error);
276
+ }
277
+ });
160
278
  server.registerTool("atlas_recent_changes", {
161
279
  title: "Recent ChangeBook changes",
162
280
  description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
@@ -169,7 +287,7 @@ Args:
169
287
  - search (optional): case-insensitive text filter over the business and technical summaries.
170
288
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
171
289
 
172
- Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
290
+ Returns (structured): { count, offset, has_more, changes: [{ id, date, commit, business_impact, diff_chars, files, files_more, modules: [{ module, risk }] }] }. files is a capped union (files_more = paths cut). Pass include_tech for summary_tech.
173
291
 
174
292
  Don't use for per-module deep dives — use atlas_module_detail for that.`,
175
293
  inputSchema: {
@@ -191,8 +309,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
191
309
  },
192
310
  }, async ({ limit, offset, search, project, include_tech }) => {
193
311
  try {
312
+ const t0 = Date.now();
194
313
  const pf = await db.projectFilterFor(project);
195
- let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
314
+ let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
196
315
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
197
316
  pf;
198
317
  if (search) {
@@ -201,12 +320,14 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
201
320
  }
202
321
  const rows = await db.rest(query);
203
322
  const modulesByChange = new Map();
323
+ let filesByChange = new Map();
204
324
  if (rows.length > 0) {
205
325
  const ids = rows.map((r) => r.id).join(",");
206
326
  const mods = await db.rest(
207
- // Only the module name and risk are used below; don't pull note/tech/
208
- // excerpt/files (up to ~1.5k each) for every row of every change.
209
- `change_module?select=changelog_id,module,risk&changelog_id=in.(${ids})`);
327
+ // note/tech/excerpt stay out (~1.5k each per row); `files` comes in
328
+ // deliberately (commit mirror, benchmark 2026-07-20) and is served
329
+ // as a capped union, never raw.
330
+ `change_module?select=changelog_id,module,risk,files&changelog_id=in.(${ids})`);
210
331
  for (const m of mods) {
211
332
  const list = modulesByChange.get(m.changelog_id);
212
333
  if (list)
@@ -214,6 +335,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
214
335
  else
215
336
  modulesByChange.set(m.changelog_id, [m]);
216
337
  }
338
+ filesByChange = filesUnionByChange(mods);
217
339
  }
218
340
  const changes = rows.map((r) => ({
219
341
  id: r.id,
@@ -226,6 +348,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
226
348
  // que casaron los resultados.
227
349
  ...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
228
350
  diff_chars: r.diff_character_count ?? null,
351
+ commit: r.commit_hash?.slice(0, 7) ?? null,
352
+ // Post-squash: el mismo cambio bajo otro hash (rama vs main). Si
353
+ // `commit` no existe en tu repo, uno de estos sí.
354
+ commit_aliases: commitAliasesShort(r.hash_aliases),
355
+ files: filesByChange.get(r.id)?.files ?? [],
356
+ files_more: filesByChange.get(r.id)?.more ?? 0,
229
357
  modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
230
358
  module: m.module,
231
359
  risk: m.risk,
@@ -242,11 +370,13 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
242
370
  const mods = c.modules
243
371
  .map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
244
372
  .join(", ");
245
- lines.push(`## ${c.date} — ${c.business_impact}`);
373
+ lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""} — ${c.business_impact}`);
246
374
  if ((include_tech || search) && c.summary_tech)
247
375
  lines.push(`- Tech: ${c.summary_tech}`);
248
376
  if (mods)
249
377
  lines.push(`- Modules: ${mods}`);
378
+ if (c.files.length > 0)
379
+ lines.push(`- Files: ${c.files.join(", ")}${c.files_more > 0 ? ` (+${c.files_more} more)` : ""}`);
250
380
  lines.push("");
251
381
  }
252
382
  if (changes.length === 0) {
@@ -256,7 +386,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
256
386
  }
257
387
  const changesText = lines.join("\n");
258
388
  const salida = toolResult(changesText, output);
259
- recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
389
+ recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida), Date.now() - t0);
260
390
  return salida;
261
391
  }
262
392
  catch (error) {
@@ -288,6 +418,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
288
418
  },
289
419
  }, async ({ domain, project }) => {
290
420
  try {
421
+ const t0 = Date.now();
291
422
  const pf = await db.projectFilterFor(project);
292
423
  let query =
293
424
  // The aggregation below uses only these columns; note/tech/excerpt
@@ -331,7 +462,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
331
462
  }
332
463
  const modulesText = lines.join("\n");
333
464
  const salida = toolResult(modulesText, output);
334
- recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
465
+ recordRead(db, "atlas_modules", pf, servedCharsOf(salida), Date.now() - t0);
335
466
  return salida;
336
467
  }
337
468
  catch (error) {
@@ -372,6 +503,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
372
503
  },
373
504
  }, async ({ module, limit, include_excerpts, full, project }) => {
374
505
  try {
506
+ const t0 = Date.now();
375
507
  const pf = await db.projectFilterFor(project);
376
508
  const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
377
509
  `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
@@ -387,7 +519,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
387
519
  };
388
520
  }
389
521
  const ids = [...new Set(rows.map((r) => r.changelog_id))].join(",");
390
- const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash&id=in.(${ids})`);
522
+ const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases&id=in.(${ids})`);
391
523
  const logById = new Map(logs.map((l) => [l.id, l]));
392
524
  const changes = rows.map((r) => {
393
525
  let excerpt = null;
@@ -407,6 +539,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
407
539
  // El commit del que salió cada entrada: la nota deja de flotar en
408
540
  // el tiempo y se puede cuadrar contra git.
409
541
  commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
542
+ commit_aliases: commitAliasesShort(logById.get(r.changelog_id)?.hash_aliases),
410
543
  risk: r.risk,
411
544
  note: r.note,
412
545
  tech: r.tech,
@@ -418,12 +551,24 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
418
551
  });
419
552
  // Ancla temporal de la respuesta entera: el commit más nuevo servido,
420
553
  // comparado con el HEAD del árbol — solo si este árbol ES el proyecto
421
- // (misma puerta que la refutación de alertas).
422
- const ancla = cwdEsElProyecto(project)
423
- ? await derivaContraHead(process.cwd(), rows
424
- .map((r) => logById.get(r.changelog_id)?.commit_hash)
425
- .find(Boolean) ?? null)
426
- : null;
554
+ // (misma puerta que la refutación de alertas). Post-squash, el hash
555
+ // primario puede no existir en este árbol: se prueban sus aliases
556
+ // antes de rendirse.
557
+ let ancla = null;
558
+ if (cwdEsElProyecto(project)) {
559
+ const log = rows
560
+ .map((r) => logById.get(r.changelog_id))
561
+ .find((l) => l?.commit_hash);
562
+ const candidatos = [
563
+ log?.commit_hash ?? null,
564
+ ...commitAliasesShort(log?.hash_aliases),
565
+ ];
566
+ for (const c of candidatos) {
567
+ ancla = await derivaContraHead(process.cwd(), c);
568
+ if (ancla)
569
+ break;
570
+ }
571
+ }
427
572
  const latest = rows[0];
428
573
  const output = {
429
574
  module,
@@ -441,7 +586,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
441
586
  lines.push(ancla);
442
587
  lines.push("");
443
588
  for (const c of changes) {
444
- lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
589
+ lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
445
590
  if (c.business_impact)
446
591
  lines.push(`- Impact: ${c.business_impact}`);
447
592
  if (c.note)
@@ -462,7 +607,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
462
607
  lines.push(TEMPORAL_CONTRACT, "");
463
608
  const detailText = lines.join("\n");
464
609
  const salida = toolResult(detailText, output);
465
- recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
610
+ recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida), Date.now() - t0);
466
611
  return salida;
467
612
  }
468
613
  catch (error) {
@@ -471,7 +616,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
471
616
  });
472
617
  server.registerTool("atlas_file_context", {
473
618
  title: "Context of the files you are about to edit",
474
- description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, and how often they changed recently.
619
+ 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, and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
475
620
 
476
621
  Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
477
622
 
@@ -479,7 +624,7 @@ Args:
479
624
  - files (required): 1-8 repo-relative paths.
480
625
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
481
626
 
482
- Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts }] }`,
627
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, watched_values: [{ name, value, commit }] }] }`,
483
628
  inputSchema: {
484
629
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
485
630
  .describe("Repo-relative paths you are about to edit"),
@@ -494,6 +639,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
494
639
  },
495
640
  }, async ({ files, project }) => {
496
641
  try {
642
+ const t0 = Date.now();
497
643
  const pf = await db.projectFilterFor(project);
498
644
  const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
499
645
  const perFile = await Promise.all(paths.map(async (file) => {
@@ -522,10 +668,24 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
522
668
  const moduleNames = [
523
669
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
524
670
  ];
525
- const alerts = moduleNames.length
526
- ? await 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` +
671
+ const [alerts, watched] = await Promise.all([
672
+ moduleNames.length
673
+ ? 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` +
674
+ pf)
675
+ : Promise.resolve([]),
676
+ // Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
677
+ // su commit, extraído mecánicamente en cada ingesta — la cura del
678
+ // fallo de frescura del benchmark 2026-07-20. Best-effort: sin la
679
+ // tabla, el contexto sigue sirviéndose.
680
+ db
681
+ .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
527
682
  pf)
528
- : [];
683
+ .catch(() => []),
684
+ ]);
685
+ const watchedByFile = new Map();
686
+ for (const w of watched) {
687
+ watchedByFile.set(w.file, [...(watchedByFile.get(w.file) ?? []), w]);
688
+ }
529
689
  // Refutación al servir (benchmark 2026-07-20): el mismo grep que el
530
690
  // guardián corre en el pre-commit, pero aquí, en la consulta que el
531
691
  // agente hace ANTES de editar — 4 de 7 alertas abiertas se caían con
@@ -551,10 +711,20 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
551
711
  let ancla = null;
552
712
  if (cwdEsElProyecto(project)) {
553
713
  const ultimo = await db
554
- .rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
714
+ .rest(`changelog?select=commit_hash,hash_aliases&commit_hash=not.is.null&order=created_at.desc&limit=1` +
555
715
  pf)
556
716
  .catch(() => []);
557
- ancla = await derivaContraHead(process.cwd(), ultimo[0]?.commit_hash ?? null);
717
+ // Post-squash: si el hash primario no existe en este árbol (era el
718
+ // de la rama), sus aliases sí pueden — probar antes de rendirse.
719
+ const candidatos = [
720
+ ultimo[0]?.commit_hash ?? null,
721
+ ...commitAliasesShort(ultimo[0]?.hash_aliases),
722
+ ];
723
+ for (const c of candidatos) {
724
+ ancla = await derivaContraHead(process.cwd(), c);
725
+ if (ancla)
726
+ break;
727
+ }
558
728
  }
559
729
  const lines = [`# File context (${paths.length} file(s))`];
560
730
  if (ancla)
@@ -576,13 +746,35 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
576
746
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
577
747
  }
578
748
  }
749
+ for (const w of watchedByFile.get(f.file) ?? []) {
750
+ lines.push(`- Current value: ${w.name} = ${w.value}` +
751
+ (w.commit_hash
752
+ ? ` (as of commit ${w.commit_hash.slice(0, 7)})`
753
+ : ""));
754
+ }
579
755
  lines.push("");
580
756
  }
581
757
  if (!ancla)
582
758
  lines.push(TEMPORAL_CONTRACT, "");
583
759
  const contextText = lines.join("\n");
584
- const salida = toolResult(contextText, { files: perFile });
585
- recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
760
+ // open_alerts entra en el structured desde el 2026-07-20: la
761
+ // descripción lo prometía y solo viajaba en el texto (bug cazado por
762
+ // el verificador adversarial del benchmark).
763
+ const salida = toolResult(contextText, {
764
+ files: perFile.map((f) => ({
765
+ ...f,
766
+ open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
767
+ module: m.module,
768
+ plain,
769
+ }))),
770
+ watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
771
+ name: w.name,
772
+ value: w.value,
773
+ commit: w.commit_hash?.slice(0, 7) ?? null,
774
+ })),
775
+ })),
776
+ });
777
+ recordRead(db, "atlas_file_context", pf, servedCharsOf(salida), Date.now() - t0);
586
778
  return salida;
587
779
  }
588
780
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "start": "node dist/index.js",
32
- "build": "tsc",
32
+ "build": "tsc && chmod +x dist/index.js",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "clean": "rm -rf dist",
35
35
  "prepublishOnly": "npm run build"
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.3",
5
+ "version": "0.4.5",
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.3",
18
+ "version": "0.4.5",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }