changebook 0.4.2 → 0.4.4

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/supabase.js CHANGED
@@ -5,11 +5,11 @@
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 } from "./credentials.js";
9
- import { slugifyProject } from "./guard.js";
8
+ import { loadCredentials, saveCredentials } from './credentials.js';
9
+ import { slugifyProject } from './guard.js';
10
10
  // Public defaults — the anon key is the same public key the web app ships.
11
- const DEFAULT_URL = "https://oyosihxkecspjkiligga.supabase.co";
12
- const DEFAULT_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
11
+ const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
12
+ const DEFAULT_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE';
13
13
  export const AUTH_HELP = `No ChangeBook session configured. Run:
14
14
 
15
15
  changebook login
@@ -42,7 +42,7 @@ export class Supabase {
42
42
  // refresh tokens must be written back there or the stored one goes stale.
43
43
  persistRotation = false;
44
44
  constructor(env = process.env) {
45
- this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, "");
45
+ this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
46
46
  this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
47
47
  this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
48
48
  this.refreshToken = env.CHANGEBOOK_REFRESH_TOKEN?.trim() || undefined;
@@ -75,11 +75,11 @@ export class Supabase {
75
75
  // mezcla — la frontera por proyecto que el MCP hospedado ya aplica
76
76
  // (auditoría M3: el stdio devolvía "" = todos los proyectos y mezclaba
77
77
  // historia/módulos/contexto). Con 0 o 1 proyecto no hay ambigüedad.
78
- const some = await this.rest("projects?select=id&limit=2");
78
+ const some = await this.rest('projects?select=id&limit=2');
79
79
  if (some.length > 1) {
80
- throw new SupabaseError("Esta cuenta tiene varios proyectos. Define CHANGEBOOK_PROJECT (nombre o slug del repo) o pasa `project` en la tool para no mezclar sus datos.", 400);
80
+ throw new SupabaseError('Esta cuenta tiene varios proyectos. Define CHANGEBOOK_PROJECT (nombre o slug del repo) o pasa `project` en la tool para no mezclar sus datos.', 400);
81
81
  }
82
- this.projectFilterCache = "";
82
+ this.projectFilterCache = '';
83
83
  return this.projectFilterCache;
84
84
  }
85
85
  const v = encodeURIComponent(this.projectEnv);
@@ -99,7 +99,7 @@ export class Supabase {
99
99
  * then exact name; falls back to the env-based filter when absent. The
100
100
  * atlas is per-project, so tools pass what the agent gave them here.
101
101
  */
102
- async projectFilterFor(project) {
102
+ async projectFilterFor(project, opts) {
103
103
  const wanted = project?.trim();
104
104
  if (!wanted)
105
105
  return this.projectFilter();
@@ -119,10 +119,26 @@ export class Supabase {
119
119
  //
120
120
  // Espejo de decideScope en supabase/functions/mcp/scope.ts. Es lo que
121
121
  // permite exigir `project` en el esquema sin quitarle nada a nadie.
122
- const todos = await this.rest(`projects?select=id&limit=2`);
122
+ //
123
+ // `strict` desactiva ese respaldo. La diferencia es QUIÉN puede
124
+ // corregir la adivinanza: una tool responde a un agente que ve el
125
+ // nombre del proyecto en la respuesta y rectifica; sync escribe
126
+ // CLAUDE.md en silencio, y si adivina mal, un repo sin atlas hereda el
127
+ // mapa de OTRO proyecto — el invariante que la frontera por proyecto
128
+ // prohíbe ("el mapa debe salir VACÍO, jamás el de otro").
129
+ if (opts?.strict) {
130
+ throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
131
+ }
132
+ const todos = await this.rest(`projects?select=id,name,slug&order=created_at.asc&limit=20`);
123
133
  if (todos.length === 1)
124
134
  return `&project_id=eq.${todos[0].id}`;
125
- throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
135
+ // Espejo del scopeNotice hospedado (mcp/scope.ts): el error LISTA los
136
+ // proyectos válidos. Sin la lista, el agente que recibe este 404 tiene
137
+ // que gastar otra petición entera (o preguntar al usuario) solo para
138
+ // saber qué nombres existen — la ronda perdida que 0.4.2 eliminaba.
139
+ const names = todos.map((p) => p.name ?? p.slug ?? p.id).join(', ');
140
+ throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name). ` +
141
+ `Projects: ${names}. Pass project with the name or slug of the repo you are working in.`, 404);
126
142
  }
127
143
  return `&project_id=eq.${rows[0].id}`;
128
144
  }
@@ -162,11 +178,11 @@ export class Supabase {
162
178
  }
163
179
  rpcOnce(fn, args) {
164
180
  return fetch(`${this.url}/rest/v1/rpc/${fn}`, {
165
- method: "POST",
181
+ method: 'POST',
166
182
  headers: {
167
183
  apikey: this.anonKey,
168
184
  Authorization: `Bearer ${this.accessToken}`,
169
- "Content-Type": "application/json",
185
+ 'Content-Type': 'application/json',
170
186
  },
171
187
  body: JSON.stringify(args),
172
188
  signal: AbortSignal.timeout(15_000),
@@ -174,12 +190,12 @@ export class Supabase {
174
190
  }
175
191
  insertOnce(table, row) {
176
192
  return fetch(`${this.url}/rest/v1/${table}`, {
177
- method: "POST",
193
+ method: 'POST',
178
194
  headers: {
179
195
  apikey: this.anonKey,
180
196
  Authorization: `Bearer ${this.accessToken}`,
181
- "Content-Type": "application/json",
182
- Prefer: "return=minimal",
197
+ 'Content-Type': 'application/json',
198
+ Prefer: 'return=minimal',
183
199
  },
184
200
  body: JSON.stringify(row),
185
201
  signal: AbortSignal.timeout(10_000),
@@ -201,7 +217,7 @@ export class Supabase {
201
217
  }
202
218
  }
203
219
  await fetch(`${this.url}/auth/v1/logout?scope=global`, {
204
- method: "POST",
220
+ method: 'POST',
205
221
  headers: {
206
222
  apikey: this.anonKey,
207
223
  Authorization: `Bearer ${this.accessToken}`,
@@ -234,7 +250,7 @@ export class Supabase {
234
250
  headers: {
235
251
  apikey: this.anonKey,
236
252
  Authorization: `Bearer ${this.accessToken}`,
237
- Accept: "application/json",
253
+ Accept: 'application/json',
238
254
  },
239
255
  signal: AbortSignal.timeout(30_000),
240
256
  });
@@ -296,8 +312,8 @@ export class Supabase {
296
312
  }
297
313
  refreshOnce(refreshToken) {
298
314
  return fetch(`${this.url}/auth/v1/token?grant_type=refresh_token`, {
299
- method: "POST",
300
- headers: { apikey: this.anonKey, "Content-Type": "application/json" },
315
+ method: 'POST',
316
+ headers: { apikey: this.anonKey, 'Content-Type': 'application/json' },
301
317
  body: JSON.stringify({ refresh_token: refreshToken }),
302
318
  signal: AbortSignal.timeout(30_000),
303
319
  });
@@ -329,11 +345,11 @@ export class Supabase {
329
345
  }
330
346
  invokeOnce(name, payload) {
331
347
  return fetch(`${this.url}/functions/v1/${name}`, {
332
- method: "POST",
348
+ method: 'POST',
333
349
  headers: {
334
350
  apikey: this.anonKey,
335
351
  Authorization: `Bearer ${this.accessToken}`,
336
- "Content-Type": "application/json",
352
+ 'Content-Type': 'application/json',
337
353
  },
338
354
  body: JSON.stringify(payload),
339
355
  signal: AbortSignal.timeout(120_000),
package/dist/sync.js CHANGED
@@ -10,22 +10,22 @@
10
10
  * The section lives between markers and is replaced in place; everything
11
11
  * outside the markers is never touched.
12
12
  */
13
- import { readFile, writeFile } from "node:fs/promises";
14
- import path from "node:path";
13
+ import { readFile, writeFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
15
  /** The project identity of the synced repo (same rule as analyze/guard). */
16
16
  function projectNameFor(targetDir) {
17
17
  return (process.env.CHANGEBOOK_PROJECT?.trim() ||
18
18
  path.basename(path.resolve(targetDir)));
19
19
  }
20
- const START = "<!-- changebook:start -->";
21
- const END = "<!-- changebook:end -->";
20
+ const START = '<!-- changebook:start -->';
21
+ const END = '<!-- changebook:end -->';
22
22
  // DB text (notes, alert bodies, business impact) is AI-written and gets embedded
23
23
  // between the markers that upsertSection splices on. If any of it contained a
24
24
  // literal marker, the next sync would splice at the wrong offset and corrupt the
25
25
  // file injected into every agent session. Break the HTML-comment opener so no
26
26
  // exact marker can be forged; both markers begin with "<!--".
27
27
  function sanitizeCell(text) {
28
- return text.replace(/<!--/g, "<!- -");
28
+ return text.replace(/<!--/g, '<!- -');
29
29
  }
30
30
  const MAX_MODULES = 15;
31
31
  const MAX_CHANGES = 5;
@@ -62,16 +62,19 @@ export async function syncContextFiles(db, targetDir) {
62
62
  let projectFilter;
63
63
  let projectResolved = true;
64
64
  try {
65
- projectFilter = await db.projectFilterFor(projectName);
65
+ // strict: el respaldo de proyecto único (0.4.2) es para tools que
66
+ // conversan con un agente; sync escribe este archivo en silencio y no
67
+ // puede adivinar — si el nombre no casa, mapa vacío y punto.
68
+ projectFilter = await db.projectFilterFor(projectName, { strict: true });
66
69
  }
67
70
  catch {
68
- projectFilter = "&project_id=eq.00000000-0000-0000-0000-000000000000";
71
+ projectFilter = '&project_id=eq.00000000-0000-0000-0000-000000000000';
69
72
  projectResolved = false;
70
73
  }
71
74
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
72
75
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
73
76
  const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
74
- db.rest("change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500" +
77
+ db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
75
78
  projectFilter),
76
79
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
77
80
  projectFilter),
@@ -90,15 +93,15 @@ export async function syncContextFiles(db, targetDir) {
90
93
  // Best-effort como las alertas.
91
94
  projectResolved && projectId
92
95
  ? db
93
- .callRpc("list_agent_tasks", {
96
+ .callRpc('list_agent_tasks', {
94
97
  p_project_id: projectId,
95
98
  })
96
- .then((rows) => rows.filter((r) => r.status === "pending"))
99
+ .then((rows) => rows.filter((r) => r.status === 'pending'))
97
100
  .catch(() => [])
98
101
  : Promise.resolve([]),
99
102
  ]);
100
103
  const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
101
- for (const name of ["CLAUDE.md", "AGENTS.md"]) {
104
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
102
105
  const file = path.join(targetDir, name);
103
106
  const updated = await upsertSection(file, section);
104
107
  console.error(`${updated} ${name}`);
@@ -108,10 +111,10 @@ export async function syncContextFiles(db, targetDir) {
108
111
  // pagar tool calls. Cuenta como lectura (best-effort).
109
112
  if (projectResolved && projectId) {
110
113
  await db
111
- .insertRow("atlas_reads", {
114
+ .insertRow('atlas_reads', {
112
115
  project_id: projectId,
113
- tool: "sync_context_files",
114
- source: "sync",
116
+ tool: 'sync_context_files',
117
+ source: 'sync',
115
118
  chars_served: section.length,
116
119
  })
117
120
  .catch(() => { });
@@ -135,8 +138,8 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
135
138
  // del usuario y una fecha que cambia a diario invalidaría el prefijo de
136
139
  // prompt-cache de TODO el archivo en cada sesión del agente. Las fechas
137
140
  // útiles ya van por entrada (regresiones, últimos cambios).
138
- "## Mapa del producto (ChangeBook · auto-generado)",
139
- "",
141
+ '## Mapa del producto (ChangeBook · auto-generado)',
142
+ '',
140
143
  // Esta cabecera se paga en CADA peticion de CADA sesion, se consulte el
141
144
  // atlas o no, y ademas se reenvia al modelo cada vez. Medido el
142
145
  // 2026-07-19: ocupaba 1.109 chars de instrucciones sobre un presupuesto de
@@ -153,23 +156,26 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
153
156
  // · anunciar antes de atacar un encargo
154
157
  // · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
155
158
  // 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).",
157
- "",
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
+ '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). Tools diferidas: una sola llamada a ToolSearch con todas, nunca de una en una.',
163
+ '',
158
164
  ...(projectName
159
165
  ? [
160
166
  `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
161
- "",
167
+ '',
162
168
  ]
163
169
  : []),
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.",
165
- "",
170
+ '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.',
171
+ '',
166
172
  ];
167
173
  if (modules.length === 0) {
168
174
  return [
169
175
  ...head,
170
- "_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._",
176
+ '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
171
177
  END,
172
- ].join("\n");
178
+ ].join('\n');
173
179
  }
174
180
  // Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
175
181
  // nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
@@ -181,8 +187,8 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
181
187
  // las sesiones. El riesgo se marca solo cuando NO es bajo: "riesgo low"
182
188
  // repetido cuarenta veces es ruido que se paga igual que la senal.
183
189
  const moduleLines = modules.map((m) => {
184
- const riesgo = m.risk && m.risk !== "low" ? ` ⚠ ${m.risk}` : "";
185
- const area = m.domain ? ` · ${m.domain}` : "";
190
+ const riesgo = m.risk && m.risk !== 'low' ? ` ⚠ ${m.risk}` : '';
191
+ const area = m.domain ? ` · ${m.domain}` : '';
186
192
  return `- **${m.module}**${area}${riesgo}`;
187
193
  });
188
194
  // Prevention beats detection: give the agent the co-change dependencies
@@ -191,29 +197,29 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
191
197
  // AI-detected regressions from the latest analyses: the most urgent thing
192
198
  // an agent can know before editing — a change already broke a coupling.
193
199
  const alertLines = alerts
194
- .filter((a) => (a.plain ?? "").trim())
200
+ .filter((a) => (a.plain ?? '').trim())
195
201
  .map((a) => {
196
- const mod = (a.module ?? "").trim();
202
+ const mod = (a.module ?? '').trim();
197
203
  // 130 y no 200: en un bloque de coste FIJO el aviso es un titular, no
198
204
  // un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
199
205
  // y expulsaban el mapa. El texto entero sigue a una llamada de
200
206
  // `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))}`;
207
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? '').slice(0, 130))}`;
202
208
  });
203
209
  const hotspotLines = modules
204
- .filter((m) => m.risk === "hotspot")
210
+ .filter((m) => m.risk === 'hotspot')
205
211
  .slice(0, 5)
206
212
  .map((m) => {
207
- const note = sanitizeCell((m.note ?? "").slice(0, 110));
208
- return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ""}`;
213
+ const note = sanitizeCell((m.note ?? '').slice(0, 110));
214
+ return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
209
215
  });
210
- const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? "").slice(0, 140))}`);
216
+ const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
211
217
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
212
218
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
213
219
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
214
220
  // llevar la misma señal encolada varias veces).
215
221
  const taskTitles = [
216
- ...new Set(pendingTasks.map((t) => (t.title ?? "").trim()).filter(Boolean)),
222
+ ...new Set(pendingTasks.map((t) => (t.title ?? '').trim()).filter(Boolean)),
217
223
  ].slice(0, 3);
218
224
  const taskLines = taskTitles.length > 0
219
225
  ? [
@@ -225,39 +231,39 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
225
231
  // (utilidad para el agente) son independientes: si no cabe todo, caen
226
232
  // primero los últimos cambios y los módulos, nunca las regresiones.
227
233
  const sections = [
228
- { key: "modules", priority: 2, title: "### Módulos", lines: moduleLines },
234
+ { key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
229
235
  {
230
- key: "tasks",
236
+ key: 'tasks',
231
237
  priority: 1,
232
- title: "### Encargos pendientes del dueño (proponte atacarlos)",
238
+ title: '### Encargos pendientes del dueño (proponte atacarlos)',
233
239
  lines: taskLines,
234
240
  },
235
241
  {
236
- key: "couplings",
242
+ key: 'couplings',
237
243
  priority: 3,
238
- title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
244
+ title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
239
245
  lines: couplingLines,
240
246
  },
241
247
  {
242
- key: "alerts",
248
+ key: 'alerts',
243
249
  priority: 0,
244
- title: "### Regresiones detectadas (resolver o verificar YA)",
250
+ title: '### Regresiones detectadas (resolver o verificar YA)',
245
251
  lines: alertLines,
246
252
  },
247
253
  {
248
- key: "hotspots",
254
+ key: 'hotspots',
249
255
  priority: 4,
250
- title: "### Avisos abiertos (revisar antes de modificar)",
256
+ title: '### Avisos abiertos (revisar antes de modificar)',
251
257
  lines: hotspotLines,
252
258
  },
253
259
  {
254
- key: "changes",
260
+ key: 'changes',
255
261
  priority: 5,
256
- title: "### Últimos cambios",
262
+ title: '### Últimos cambios',
257
263
  lines: changeLines,
258
264
  },
259
265
  ];
260
- let budget = SYNC_BUDGET_CHARS - head.join("\n").length - END.length;
266
+ let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
261
267
  const includedCount = new Map();
262
268
  for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
263
269
  if (s.lines.length === 0)
@@ -282,12 +288,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
282
288
  if (count === 0)
283
289
  continue;
284
290
  if (!first)
285
- lines.push("");
291
+ lines.push('');
286
292
  lines.push(s.title, ...s.lines.slice(0, count));
287
293
  first = false;
288
294
  }
289
295
  lines.push(END);
290
- return lines.join("\n");
296
+ return lines.join('\n');
291
297
  }
292
298
  /**
293
299
  * Symmetric co-change pairs: modules that appear in the same analyses often
@@ -296,11 +302,15 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
296
302
  */
297
303
  // Module labels contain spaces, so pair keys use a separator that cannot
298
304
  // appear in a label.
299
- const PAIR_SEP = "\u0000";
300
- function coChangePairs(rows) {
305
+ const PAIR_SEP = '\u0000';
306
+ // Exportada para el test de paridad con la copia del brief hospedado
307
+ // (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
308
+ // mismo repo enseñaría acoplamientos distintos según por dónde entre el
309
+ // agente — y la deriva sería silenciosa.
310
+ export function coChangePairs(rows) {
301
311
  const byAnalysis = new Map();
302
312
  for (const r of rows) {
303
- const label = (r.module ?? "").trim();
313
+ const label = (r.module ?? '').trim();
304
314
  if (!label)
305
315
  continue;
306
316
  let set = byAnalysis.get(r.changelog_id);
@@ -338,25 +348,25 @@ function coChangePairs(rows) {
338
348
  export async function upsertSection(file, section) {
339
349
  let content = null;
340
350
  try {
341
- content = await readFile(file, "utf8");
351
+ content = await readFile(file, 'utf8');
342
352
  }
343
353
  catch {
344
354
  content = null;
345
355
  }
346
356
  if (content === null) {
347
- await writeFile(file, section + "\n");
348
- return "created";
357
+ await writeFile(file, section + '\n');
358
+ return 'created';
349
359
  }
350
360
  // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
351
361
  // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
352
362
  // nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
353
- const LEGACY_START = "<!-- appatlas:start -->";
354
- const LEGACY_END = "<!-- appatlas:end -->";
363
+ const LEGACY_START = '<!-- appatlas:start -->';
364
+ const LEGACY_END = '<!-- appatlas:end -->';
355
365
  const legacyStart = content.indexOf(LEGACY_START);
356
366
  const legacyEnd = content.indexOf(LEGACY_END);
357
367
  if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
358
368
  content = (content.slice(0, legacyStart) +
359
- content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, "\n\n");
369
+ content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, '\n\n');
360
370
  await writeFile(file, content);
361
371
  }
362
372
  const start = content.indexOf(START);
@@ -366,12 +376,12 @@ export async function upsertSection(file, section) {
366
376
  // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
367
377
  // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
368
378
  if (next === content)
369
- return "unchanged";
379
+ return 'unchanged';
370
380
  await writeFile(file, next);
371
- return "updated";
381
+ return 'updated';
372
382
  }
373
- const separator = content.endsWith("\n") ? "\n" : "\n\n";
374
- await writeFile(file, content + separator + section + "\n");
375
- return "appended";
383
+ const separator = content.endsWith('\n') ? '\n' : '\n\n';
384
+ await writeFile(file, content + separator + section + '\n');
385
+ return 'appended';
376
386
  }
377
387
  //# sourceMappingURL=sync.js.map
package/dist/tools.js CHANGED
@@ -4,9 +4,20 @@
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";
7
8
  import { z } from "zod";
9
+ import { execFileAsync } from "./git.js";
10
+ import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
8
11
  import { SupabaseError } from "./supabase.js";
9
12
  const CHARACTER_LIMIT = 25_000;
13
+ /**
14
+ * El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
15
+ * agente afirmó un valor revertido porque la nota hablaba en presente). El
16
+ * ancla de deriva de derivaContraHead lo dice con precisión cuando hay árbol
17
+ * y hash; esta línea fija cubre el resto de los casos — nunca las dos a la
18
+ * vez.
19
+ */
20
+ const TEMPORAL_CONTRACT = "Notes describe the code AS OF their date — concrete values (numbers, limits, names) may have changed since. Verify in the code before asserting them.";
10
21
  // ── Helpers ───────────────────────────────────────────────────────────────────
11
22
  /**
12
23
  * Lo que de VERDAD llega al modelo. Espejo de servedCharsOf en
@@ -84,11 +95,37 @@ export function quotedInList(values) {
84
95
  .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
85
96
  .join(",");
86
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
+ }
87
124
  // Consultation metering (never billing): each successful read leaves a row in
88
125
  // atlas_reads so the web can show "your agent consulted the atlas N times".
89
126
  // Best-effort and non-blocking — metering must never break or slow a read.
90
127
  // user_id is filled server-side (column default auth.uid()).
91
- function recordRead(db, tool, projectFilter, charsServed) {
128
+ function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
92
129
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
93
130
  void db
94
131
  .insertRow("atlas_reads", {
@@ -96,6 +133,11 @@ function recordRead(db, tool, projectFilter, charsServed) {
96
133
  tool,
97
134
  source: "stdio",
98
135
  chars_served: charsServed,
136
+ // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
137
+ // metering, jamás contrato, y un servidor viejo simplemente no la manda.
138
+ ...(latencyMs !== undefined
139
+ ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
140
+ : {}),
99
141
  })
100
142
  .catch(() => { });
101
143
  }
@@ -103,8 +145,119 @@ function recordRead(db, tool, projectFilter, charsServed) {
103
145
  function ilikePattern(search) {
104
146
  return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
105
147
  }
148
+ /**
149
+ * ¿El árbol de trabajo donde corre este servidor ES el proyecto consultado?
150
+ *
151
+ * Solo entonces un grep local puede refutar una alerta: la tool recibe
152
+ * `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
153
+ * En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
154
+ * avisoRefutado con expect='present' lo tomaría como refutación — silenciar
155
+ * una alerta verdadera por mirar donde no era es peor que el ruido. Misma
156
+ * identidad que usa el guardián: CHANGEBOOK_PROJECT o el basename del
157
+ * directorio, pasados por la slugify del servidor.
158
+ */
159
+ function cwdEsElProyecto(project) {
160
+ const candidato = process.env.CHANGEBOOK_PROJECT?.trim() ||
161
+ path.basename(path.resolve(process.cwd()));
162
+ return slugifyProject(candidato) === slugifyProject(project.trim());
163
+ }
164
+ /**
165
+ * "Atlas current up to commit X — your HEAD is N ahead": la señal MECÁNICA de
166
+ * que la respuesta es memoria, no estado (benchmark 2026-07-20: el agente
167
+ * repitió un valor revertido porque nada le decía hasta qué commit llegaba lo
168
+ * que leía). Fail-open total: sin hash, con hash desconocido (clon shallow,
169
+ * historia sin fetch) o sin repo, no hay línea — el ancla jamás rompe la
170
+ * lectura que ancla.
171
+ */
172
+ export async function derivaContraHead(dir, hash) {
173
+ if (!hash || !/^[0-9a-f]{7,64}$/i.test(hash))
174
+ return null;
175
+ try {
176
+ const { stdout } = await execFileAsync("git", ["rev-list", "--count", `${hash}..HEAD`], { cwd: dir, timeout: 2_000 });
177
+ const n = Number(stdout.trim());
178
+ if (!Number.isFinite(n))
179
+ return null;
180
+ const corto = hash.slice(0, 7);
181
+ return n === 0
182
+ ? `Atlas current up to commit ${corto} — matches your HEAD.`
183
+ : `Atlas current up to commit ${corto} — your HEAD is ${n} commit(s) ahead; later changes are NOT reflected here. Verify current values in the code before asserting them.`;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
106
189
  // ── Registration ──────────────────────────────────────────────────────────────
107
190
  export function registerTools(server, db) {
191
+ // Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
192
+ // exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
193
+ server.registerTool("atlas_symbol_lookup", {
194
+ title: "Where is this symbol defined?",
195
+ 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.
196
+
197
+ Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
198
+
199
+ Args:
200
+ - symbol (required): the identifier to look up.
201
+ - project (recommended): the repo you are working in (folder name or slug).
202
+
203
+ Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
204
+ inputSchema: {
205
+ symbol: z
206
+ .string()
207
+ .min(2)
208
+ .max(120)
209
+ .describe("Identifier to look up (exported definition)"),
210
+ project: z
211
+ .string()
212
+ .min(1)
213
+ .max(120)
214
+ .describe("Project to scope to (repo folder name or slug)"),
215
+ },
216
+ annotations: {
217
+ readOnlyHint: true,
218
+ destructiveHint: false,
219
+ idempotentHint: true,
220
+ openWorldHint: true,
221
+ },
222
+ }, async ({ symbol, project }) => {
223
+ try {
224
+ const t0 = Date.now();
225
+ const pf = await db.projectFilterFor(project);
226
+ const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
227
+ pf;
228
+ let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
229
+ let exact = true;
230
+ if (matches.length === 0) {
231
+ exact = false;
232
+ matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
233
+ }
234
+ const lines = [`# Symbol lookup: ${symbol}`, ""];
235
+ if (matches.length === 0) {
236
+ 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.");
237
+ }
238
+ for (const m of matches) {
239
+ lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
240
+ }
241
+ if (matches.length > 0) {
242
+ lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
243
+ }
244
+ const salida = toolResult(lines.join("\n"), {
245
+ symbol,
246
+ exact_match: exact,
247
+ matches: matches.map((m) => ({
248
+ symbol: m.symbol,
249
+ file: m.file,
250
+ kind: m.kind,
251
+ commit: m.commit_hash?.slice(0, 7) ?? null,
252
+ })),
253
+ });
254
+ recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
255
+ return salida;
256
+ }
257
+ catch (error) {
258
+ return errorResult(error);
259
+ }
260
+ });
108
261
  server.registerTool("atlas_recent_changes", {
109
262
  title: "Recent ChangeBook changes",
110
263
  description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
@@ -117,7 +270,7 @@ Args:
117
270
  - search (optional): case-insensitive text filter over the business and technical summaries.
118
271
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
119
272
 
120
- Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
273
+ 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.
121
274
 
122
275
  Don't use for per-module deep dives — use atlas_module_detail for that.`,
123
276
  inputSchema: {
@@ -139,8 +292,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
139
292
  },
140
293
  }, async ({ limit, offset, search, project, include_tech }) => {
141
294
  try {
295
+ const t0 = Date.now();
142
296
  const pf = await db.projectFilterFor(project);
143
- let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
297
+ let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash` +
144
298
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
145
299
  pf;
146
300
  if (search) {
@@ -149,12 +303,14 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
149
303
  }
150
304
  const rows = await db.rest(query);
151
305
  const modulesByChange = new Map();
306
+ let filesByChange = new Map();
152
307
  if (rows.length > 0) {
153
308
  const ids = rows.map((r) => r.id).join(",");
154
309
  const mods = await db.rest(
155
- // Only the module name and risk are used below; don't pull note/tech/
156
- // excerpt/files (up to ~1.5k each) for every row of every change.
157
- `change_module?select=changelog_id,module,risk&changelog_id=in.(${ids})`);
310
+ // note/tech/excerpt stay out (~1.5k each per row); `files` comes in
311
+ // deliberately (commit mirror, benchmark 2026-07-20) and is served
312
+ // as a capped union, never raw.
313
+ `change_module?select=changelog_id,module,risk,files&changelog_id=in.(${ids})`);
158
314
  for (const m of mods) {
159
315
  const list = modulesByChange.get(m.changelog_id);
160
316
  if (list)
@@ -162,6 +318,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
162
318
  else
163
319
  modulesByChange.set(m.changelog_id, [m]);
164
320
  }
321
+ filesByChange = filesUnionByChange(mods);
165
322
  }
166
323
  const changes = rows.map((r) => ({
167
324
  id: r.id,
@@ -174,6 +331,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
174
331
  // que casaron los resultados.
175
332
  ...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
176
333
  diff_chars: r.diff_character_count ?? null,
334
+ commit: r.commit_hash?.slice(0, 7) ?? null,
335
+ files: filesByChange.get(r.id)?.files ?? [],
336
+ files_more: filesByChange.get(r.id)?.more ?? 0,
177
337
  modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
178
338
  module: m.module,
179
339
  risk: m.risk,
@@ -190,11 +350,13 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
190
350
  const mods = c.modules
191
351
  .map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
192
352
  .join(", ");
193
- lines.push(`## ${c.date} — ${c.business_impact}`);
353
+ lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}` : ""} — ${c.business_impact}`);
194
354
  if ((include_tech || search) && c.summary_tech)
195
355
  lines.push(`- Tech: ${c.summary_tech}`);
196
356
  if (mods)
197
357
  lines.push(`- Modules: ${mods}`);
358
+ if (c.files.length > 0)
359
+ lines.push(`- Files: ${c.files.join(", ")}${c.files_more > 0 ? ` (+${c.files_more} more)` : ""}`);
198
360
  lines.push("");
199
361
  }
200
362
  if (changes.length === 0) {
@@ -204,7 +366,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
204
366
  }
205
367
  const changesText = lines.join("\n");
206
368
  const salida = toolResult(changesText, output);
207
- recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
369
+ recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida), Date.now() - t0);
208
370
  return salida;
209
371
  }
210
372
  catch (error) {
@@ -236,6 +398,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
236
398
  },
237
399
  }, async ({ domain, project }) => {
238
400
  try {
401
+ const t0 = Date.now();
239
402
  const pf = await db.projectFilterFor(project);
240
403
  let query =
241
404
  // The aggregation below uses only these columns; note/tech/excerpt
@@ -279,7 +442,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
279
442
  }
280
443
  const modulesText = lines.join("\n");
281
444
  const salida = toolResult(modulesText, output);
282
- recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
445
+ recordRead(db, "atlas_modules", pf, servedCharsOf(salida), Date.now() - t0);
283
446
  return salida;
284
447
  }
285
448
  catch (error) {
@@ -299,7 +462,7 @@ Args:
299
462
  - full (default false): return the diff excerpts verbatim instead of the token-saving previews. Only pass it when you actually need the code lines.
300
463
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
301
464
 
302
- Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }`,
465
+ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }. Notes describe the code AS OF their commit/date — verify current values in the code before asserting them.`,
303
466
  inputSchema: {
304
467
  module: z.string().min(1).max(120)
305
468
  .describe("Exact module name (see atlas_modules)"),
@@ -320,6 +483,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
320
483
  },
321
484
  }, async ({ module, limit, include_excerpts, full, project }) => {
322
485
  try {
486
+ const t0 = Date.now();
323
487
  const pf = await db.projectFilterFor(project);
324
488
  const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
325
489
  `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
@@ -335,7 +499,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
335
499
  };
336
500
  }
337
501
  const ids = [...new Set(rows.map((r) => r.changelog_id))].join(",");
338
- const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count&id=in.(${ids})`);
502
+ const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash&id=in.(${ids})`);
339
503
  const logById = new Map(logs.map((l) => [l.id, l]));
340
504
  const changes = rows.map((r) => {
341
505
  let excerpt = null;
@@ -352,6 +516,9 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
352
516
  }
353
517
  return {
354
518
  date: day(r.created_at),
519
+ // El commit del que salió cada entrada: la nota deja de flotar en
520
+ // el tiempo y se puede cuadrar contra git.
521
+ commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
355
522
  risk: r.risk,
356
523
  note: r.note,
357
524
  tech: r.tech,
@@ -361,6 +528,14 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
361
528
  excerpt_truncated: excerptTruncated,
362
529
  };
363
530
  });
531
+ // Ancla temporal de la respuesta entera: el commit más nuevo servido,
532
+ // comparado con el HEAD del árbol — solo si este árbol ES el proyecto
533
+ // (misma puerta que la refutación de alertas).
534
+ const ancla = cwdEsElProyecto(project)
535
+ ? await derivaContraHead(process.cwd(), rows
536
+ .map((r) => logById.get(r.changelog_id)?.commit_hash)
537
+ .find(Boolean) ?? null)
538
+ : null;
364
539
  const latest = rows[0];
365
540
  const output = {
366
541
  module,
@@ -373,10 +548,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
373
548
  `# Module: ${module}` +
374
549
  (latest.domain ? ` (${latest.domain}` +
375
550
  (latest.category ? ` / ${latest.category}` : "") + ")" : ""),
376
- "",
377
551
  ];
552
+ if (ancla)
553
+ lines.push(ancla);
554
+ lines.push("");
378
555
  for (const c of changes) {
379
- lines.push(`## ${c.date}${c.risk ? ` — risk: ${c.risk}` : ""}`);
556
+ lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
380
557
  if (c.business_impact)
381
558
  lines.push(`- Impact: ${c.business_impact}`);
382
559
  if (c.note)
@@ -393,9 +570,11 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
393
570
  }
394
571
  lines.push("");
395
572
  }
573
+ if (!ancla)
574
+ lines.push(TEMPORAL_CONTRACT, "");
396
575
  const detailText = lines.join("\n");
397
576
  const salida = toolResult(detailText, output);
398
- recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
577
+ recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida), Date.now() - t0);
399
578
  return salida;
400
579
  }
401
580
  catch (error) {
@@ -404,7 +583,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
404
583
  });
405
584
  server.registerTool("atlas_file_context", {
406
585
  title: "Context of the files you are about to edit",
407
- 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.
586
+ 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).
408
587
 
409
588
  Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
410
589
 
@@ -412,7 +591,7 @@ Args:
412
591
  - files (required): 1-8 repo-relative paths.
413
592
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
414
593
 
415
- Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts }] }`,
594
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, watched_values: [{ name, value, commit }] }] }`,
416
595
  inputSchema: {
417
596
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
418
597
  .describe("Repo-relative paths you are about to edit"),
@@ -427,6 +606,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
427
606
  },
428
607
  }, async ({ files, project }) => {
429
608
  try {
609
+ const t0 = Date.now();
430
610
  const pf = await db.projectFilterFor(project);
431
611
  const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
432
612
  const perFile = await Promise.all(paths.map(async (file) => {
@@ -455,18 +635,58 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
455
635
  const moduleNames = [
456
636
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
457
637
  ];
458
- const alerts = moduleNames.length
459
- ? await db.rest(`regression_alerts?select=module,plain&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
638
+ const [alerts, watched] = await Promise.all([
639
+ moduleNames.length
640
+ ? 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` +
641
+ pf)
642
+ : Promise.resolve([]),
643
+ // Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
644
+ // su commit, extraído mecánicamente en cada ingesta — la cura del
645
+ // fallo de frescura del benchmark 2026-07-20. Best-effort: sin la
646
+ // tabla, el contexto sigue sirviéndose.
647
+ db
648
+ .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
460
649
  pf)
461
- : [];
650
+ .catch(() => []),
651
+ ]);
652
+ const watchedByFile = new Map();
653
+ for (const w of watched) {
654
+ watchedByFile.set(w.file, [...(watchedByFile.get(w.file) ?? []), w]);
655
+ }
656
+ // Refutación al servir (benchmark 2026-07-20): el mismo grep que el
657
+ // guardián corre en el pre-commit, pero aquí, en la consulta que el
658
+ // agente hace ANTES de editar — 4 de 7 alertas abiertas se caían con
659
+ // un grep (migración alert_evidence). Fail-open en todo: sin
660
+ // evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
661
+ // cwd no es el proyecto consultado, no se refuta nada (ver
662
+ // cwdEsElProyecto).
663
+ const refutada = cwdEsElProyecto(project)
664
+ ? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
665
+ : () => false;
462
666
  const alertsByModule = new Map();
463
667
  for (const a of alerts) {
464
668
  const m = (a.module ?? "").trim();
465
669
  if (!m || !a.plain)
466
670
  continue;
671
+ if (refutada(a))
672
+ continue;
467
673
  alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
468
674
  }
469
- const lines = [`# File context (${paths.length} file(s))`, ""];
675
+ // Ancla temporal: el último commit analizado del proyecto vs el HEAD
676
+ // de este árbol (misma puerta de proyecto que la refutación).
677
+ // Best-effort: el ancla jamás rompe la lectura que ancla.
678
+ let ancla = null;
679
+ if (cwdEsElProyecto(project)) {
680
+ const ultimo = await db
681
+ .rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
682
+ pf)
683
+ .catch(() => []);
684
+ ancla = await derivaContraHead(process.cwd(), ultimo[0]?.commit_hash ?? null);
685
+ }
686
+ const lines = [`# File context (${paths.length} file(s))`];
687
+ if (ancla)
688
+ lines.push(ancla);
689
+ lines.push("");
470
690
  for (const f of perFile) {
471
691
  lines.push(`## ${f.file}`);
472
692
  if (f.modules.length === 0) {
@@ -475,17 +695,43 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
475
695
  for (const m of f.modules) {
476
696
  lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
477
697
  (m.risk ? `, risk: ${m.risk}` : ""));
478
- if (m.last_note)
479
- lines.push(` - Latest note: ${m.last_note}`);
698
+ if (m.last_note) {
699
+ // Con fecha: una nota es una observación fechada, no estado.
700
+ lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
701
+ }
480
702
  for (const plain of alertsByModule.get(m.module) ?? []) {
481
703
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
482
704
  }
483
705
  }
706
+ for (const w of watchedByFile.get(f.file) ?? []) {
707
+ lines.push(`- Current value: ${w.name} = ${w.value}` +
708
+ (w.commit_hash
709
+ ? ` (as of commit ${w.commit_hash.slice(0, 7)})`
710
+ : ""));
711
+ }
484
712
  lines.push("");
485
713
  }
714
+ if (!ancla)
715
+ lines.push(TEMPORAL_CONTRACT, "");
486
716
  const contextText = lines.join("\n");
487
- const salida = toolResult(contextText, { files: perFile });
488
- recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
717
+ // open_alerts entra en el structured desde el 2026-07-20: la
718
+ // descripción lo prometía y solo viajaba en el texto (bug cazado por
719
+ // el verificador adversarial del benchmark).
720
+ const salida = toolResult(contextText, {
721
+ files: perFile.map((f) => ({
722
+ ...f,
723
+ open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
724
+ module: m.module,
725
+ plain,
726
+ }))),
727
+ watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
728
+ name: w.name,
729
+ value: w.value,
730
+ commit: w.commit_hash?.slice(0, 7) ?? null,
731
+ })),
732
+ })),
733
+ });
734
+ recordRead(db, "atlas_file_context", pf, servedCharsOf(salida), Date.now() - t0);
489
735
  return salida;
490
736
  }
491
737
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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.2",
5
+ "version": "0.4.4",
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.2",
18
+ "version": "0.4.4",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }