changebook 0.4.2 → 0.4.3

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,23 @@ 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
+ '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).',
160
+ '',
158
161
  ...(projectName
159
162
  ? [
160
163
  `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
161
- "",
164
+ '',
162
165
  ]
163
166
  : []),
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
- "",
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.',
168
+ '',
166
169
  ];
167
170
  if (modules.length === 0) {
168
171
  return [
169
172
  ...head,
170
- "_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._",
173
+ '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
171
174
  END,
172
- ].join("\n");
175
+ ].join('\n');
173
176
  }
174
177
  // Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
175
178
  // nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
@@ -181,8 +184,8 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
181
184
  // las sesiones. El riesgo se marca solo cuando NO es bajo: "riesgo low"
182
185
  // repetido cuarenta veces es ruido que se paga igual que la senal.
183
186
  const moduleLines = modules.map((m) => {
184
- const riesgo = m.risk && m.risk !== "low" ? ` ⚠ ${m.risk}` : "";
185
- const area = m.domain ? ` · ${m.domain}` : "";
187
+ const riesgo = m.risk && m.risk !== 'low' ? ` ⚠ ${m.risk}` : '';
188
+ const area = m.domain ? ` · ${m.domain}` : '';
186
189
  return `- **${m.module}**${area}${riesgo}`;
187
190
  });
188
191
  // Prevention beats detection: give the agent the co-change dependencies
@@ -191,29 +194,29 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
191
194
  // AI-detected regressions from the latest analyses: the most urgent thing
192
195
  // an agent can know before editing — a change already broke a coupling.
193
196
  const alertLines = alerts
194
- .filter((a) => (a.plain ?? "").trim())
197
+ .filter((a) => (a.plain ?? '').trim())
195
198
  .map((a) => {
196
- const mod = (a.module ?? "").trim();
199
+ const mod = (a.module ?? '').trim();
197
200
  // 130 y no 200: en un bloque de coste FIJO el aviso es un titular, no
198
201
  // un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
199
202
  // y expulsaban el mapa. El texto entero sigue a una llamada de
200
203
  // `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))}`;
204
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? '').slice(0, 130))}`;
202
205
  });
203
206
  const hotspotLines = modules
204
- .filter((m) => m.risk === "hotspot")
207
+ .filter((m) => m.risk === 'hotspot')
205
208
  .slice(0, 5)
206
209
  .map((m) => {
207
- const note = sanitizeCell((m.note ?? "").slice(0, 110));
208
- return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ""}`;
210
+ const note = sanitizeCell((m.note ?? '').slice(0, 110));
211
+ return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
209
212
  });
210
- const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? "").slice(0, 140))}`);
213
+ const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
211
214
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
212
215
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
213
216
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
214
217
  // llevar la misma señal encolada varias veces).
215
218
  const taskTitles = [
216
- ...new Set(pendingTasks.map((t) => (t.title ?? "").trim()).filter(Boolean)),
219
+ ...new Set(pendingTasks.map((t) => (t.title ?? '').trim()).filter(Boolean)),
217
220
  ].slice(0, 3);
218
221
  const taskLines = taskTitles.length > 0
219
222
  ? [
@@ -225,39 +228,39 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
225
228
  // (utilidad para el agente) son independientes: si no cabe todo, caen
226
229
  // primero los últimos cambios y los módulos, nunca las regresiones.
227
230
  const sections = [
228
- { key: "modules", priority: 2, title: "### Módulos", lines: moduleLines },
231
+ { key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
229
232
  {
230
- key: "tasks",
233
+ key: 'tasks',
231
234
  priority: 1,
232
- title: "### Encargos pendientes del dueño (proponte atacarlos)",
235
+ title: '### Encargos pendientes del dueño (proponte atacarlos)',
233
236
  lines: taskLines,
234
237
  },
235
238
  {
236
- key: "couplings",
239
+ key: 'couplings',
237
240
  priority: 3,
238
- title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
241
+ title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
239
242
  lines: couplingLines,
240
243
  },
241
244
  {
242
- key: "alerts",
245
+ key: 'alerts',
243
246
  priority: 0,
244
- title: "### Regresiones detectadas (resolver o verificar YA)",
247
+ title: '### Regresiones detectadas (resolver o verificar YA)',
245
248
  lines: alertLines,
246
249
  },
247
250
  {
248
- key: "hotspots",
251
+ key: 'hotspots',
249
252
  priority: 4,
250
- title: "### Avisos abiertos (revisar antes de modificar)",
253
+ title: '### Avisos abiertos (revisar antes de modificar)',
251
254
  lines: hotspotLines,
252
255
  },
253
256
  {
254
- key: "changes",
257
+ key: 'changes',
255
258
  priority: 5,
256
- title: "### Últimos cambios",
259
+ title: '### Últimos cambios',
257
260
  lines: changeLines,
258
261
  },
259
262
  ];
260
- let budget = SYNC_BUDGET_CHARS - head.join("\n").length - END.length;
263
+ let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
261
264
  const includedCount = new Map();
262
265
  for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
263
266
  if (s.lines.length === 0)
@@ -282,12 +285,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
282
285
  if (count === 0)
283
286
  continue;
284
287
  if (!first)
285
- lines.push("");
288
+ lines.push('');
286
289
  lines.push(s.title, ...s.lines.slice(0, count));
287
290
  first = false;
288
291
  }
289
292
  lines.push(END);
290
- return lines.join("\n");
293
+ return lines.join('\n');
291
294
  }
292
295
  /**
293
296
  * Symmetric co-change pairs: modules that appear in the same analyses often
@@ -296,11 +299,15 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
296
299
  */
297
300
  // Module labels contain spaces, so pair keys use a separator that cannot
298
301
  // appear in a label.
299
- const PAIR_SEP = "\u0000";
300
- function coChangePairs(rows) {
302
+ const PAIR_SEP = '\u0000';
303
+ // Exportada para el test de paridad con la copia del brief hospedado
304
+ // (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
305
+ // mismo repo enseñaría acoplamientos distintos según por dónde entre el
306
+ // agente — y la deriva sería silenciosa.
307
+ export function coChangePairs(rows) {
301
308
  const byAnalysis = new Map();
302
309
  for (const r of rows) {
303
- const label = (r.module ?? "").trim();
310
+ const label = (r.module ?? '').trim();
304
311
  if (!label)
305
312
  continue;
306
313
  let set = byAnalysis.get(r.changelog_id);
@@ -338,25 +345,25 @@ function coChangePairs(rows) {
338
345
  export async function upsertSection(file, section) {
339
346
  let content = null;
340
347
  try {
341
- content = await readFile(file, "utf8");
348
+ content = await readFile(file, 'utf8');
342
349
  }
343
350
  catch {
344
351
  content = null;
345
352
  }
346
353
  if (content === null) {
347
- await writeFile(file, section + "\n");
348
- return "created";
354
+ await writeFile(file, section + '\n');
355
+ return 'created';
349
356
  }
350
357
  // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
351
358
  // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
352
359
  // 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 -->";
360
+ const LEGACY_START = '<!-- appatlas:start -->';
361
+ const LEGACY_END = '<!-- appatlas:end -->';
355
362
  const legacyStart = content.indexOf(LEGACY_START);
356
363
  const legacyEnd = content.indexOf(LEGACY_END);
357
364
  if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
358
365
  content = (content.slice(0, legacyStart) +
359
- content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, "\n\n");
366
+ content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, '\n\n');
360
367
  await writeFile(file, content);
361
368
  }
362
369
  const start = content.indexOf(START);
@@ -366,12 +373,12 @@ export async function upsertSection(file, section) {
366
373
  // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
367
374
  // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
368
375
  if (next === content)
369
- return "unchanged";
376
+ return 'unchanged';
370
377
  await writeFile(file, next);
371
- return "updated";
378
+ return 'updated';
372
379
  }
373
- const separator = content.endsWith("\n") ? "\n" : "\n\n";
374
- await writeFile(file, content + separator + section + "\n");
375
- return "appended";
380
+ const separator = content.endsWith('\n') ? '\n' : '\n\n';
381
+ await writeFile(file, content + separator + section + '\n');
382
+ return 'appended';
376
383
  }
377
384
  //# 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
@@ -103,6 +114,47 @@ function recordRead(db, tool, projectFilter, charsServed) {
103
114
  function ilikePattern(search) {
104
115
  return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
105
116
  }
117
+ /**
118
+ * ¿El árbol de trabajo donde corre este servidor ES el proyecto consultado?
119
+ *
120
+ * Solo entonces un grep local puede refutar una alerta: la tool recibe
121
+ * `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
122
+ * En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
123
+ * avisoRefutado con expect='present' lo tomaría como refutación — silenciar
124
+ * una alerta verdadera por mirar donde no era es peor que el ruido. Misma
125
+ * identidad que usa el guardián: CHANGEBOOK_PROJECT o el basename del
126
+ * directorio, pasados por la slugify del servidor.
127
+ */
128
+ function cwdEsElProyecto(project) {
129
+ const candidato = process.env.CHANGEBOOK_PROJECT?.trim() ||
130
+ path.basename(path.resolve(process.cwd()));
131
+ return slugifyProject(candidato) === slugifyProject(project.trim());
132
+ }
133
+ /**
134
+ * "Atlas current up to commit X — your HEAD is N ahead": la señal MECÁNICA de
135
+ * que la respuesta es memoria, no estado (benchmark 2026-07-20: el agente
136
+ * repitió un valor revertido porque nada le decía hasta qué commit llegaba lo
137
+ * que leía). Fail-open total: sin hash, con hash desconocido (clon shallow,
138
+ * historia sin fetch) o sin repo, no hay línea — el ancla jamás rompe la
139
+ * lectura que ancla.
140
+ */
141
+ export async function derivaContraHead(dir, hash) {
142
+ if (!hash || !/^[0-9a-f]{7,64}$/i.test(hash))
143
+ return null;
144
+ try {
145
+ const { stdout } = await execFileAsync("git", ["rev-list", "--count", `${hash}..HEAD`], { cwd: dir, timeout: 2_000 });
146
+ const n = Number(stdout.trim());
147
+ if (!Number.isFinite(n))
148
+ return null;
149
+ const corto = hash.slice(0, 7);
150
+ return n === 0
151
+ ? `Atlas current up to commit ${corto} — matches your HEAD.`
152
+ : `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.`;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
106
158
  // ── Registration ──────────────────────────────────────────────────────────────
107
159
  export function registerTools(server, db) {
108
160
  server.registerTool("atlas_recent_changes", {
@@ -299,7 +351,7 @@ Args:
299
351
  - 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
352
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
301
353
 
302
- Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }`,
354
+ 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
355
  inputSchema: {
304
356
  module: z.string().min(1).max(120)
305
357
  .describe("Exact module name (see atlas_modules)"),
@@ -335,7 +387,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
335
387
  };
336
388
  }
337
389
  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})`);
390
+ const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash&id=in.(${ids})`);
339
391
  const logById = new Map(logs.map((l) => [l.id, l]));
340
392
  const changes = rows.map((r) => {
341
393
  let excerpt = null;
@@ -352,6 +404,9 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
352
404
  }
353
405
  return {
354
406
  date: day(r.created_at),
407
+ // El commit del que salió cada entrada: la nota deja de flotar en
408
+ // el tiempo y se puede cuadrar contra git.
409
+ commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
355
410
  risk: r.risk,
356
411
  note: r.note,
357
412
  tech: r.tech,
@@ -361,6 +416,14 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
361
416
  excerpt_truncated: excerptTruncated,
362
417
  };
363
418
  });
419
+ // Ancla temporal de la respuesta entera: el commit más nuevo servido,
420
+ // 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;
364
427
  const latest = rows[0];
365
428
  const output = {
366
429
  module,
@@ -373,10 +436,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
373
436
  `# Module: ${module}` +
374
437
  (latest.domain ? ` (${latest.domain}` +
375
438
  (latest.category ? ` / ${latest.category}` : "") + ")" : ""),
376
- "",
377
439
  ];
440
+ if (ancla)
441
+ lines.push(ancla);
442
+ lines.push("");
378
443
  for (const c of changes) {
379
- lines.push(`## ${c.date}${c.risk ? ` — risk: ${c.risk}` : ""}`);
444
+ lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
380
445
  if (c.business_impact)
381
446
  lines.push(`- Impact: ${c.business_impact}`);
382
447
  if (c.note)
@@ -393,6 +458,8 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
393
458
  }
394
459
  lines.push("");
395
460
  }
461
+ if (!ancla)
462
+ lines.push(TEMPORAL_CONTRACT, "");
396
463
  const detailText = lines.join("\n");
397
464
  const salida = toolResult(detailText, output);
398
465
  recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
@@ -456,17 +523,43 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
456
523
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
457
524
  ];
458
525
  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` +
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` +
460
527
  pf)
461
528
  : [];
529
+ // Refutación al servir (benchmark 2026-07-20): el mismo grep que el
530
+ // guardián corre en el pre-commit, pero aquí, en la consulta que el
531
+ // agente hace ANTES de editar — 4 de 7 alertas abiertas se caían con
532
+ // un grep (migración alert_evidence). Fail-open en todo: sin
533
+ // evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
534
+ // cwd no es el proyecto consultado, no se refuta nada (ver
535
+ // cwdEsElProyecto).
536
+ const refutada = cwdEsElProyecto(project)
537
+ ? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
538
+ : () => false;
462
539
  const alertsByModule = new Map();
463
540
  for (const a of alerts) {
464
541
  const m = (a.module ?? "").trim();
465
542
  if (!m || !a.plain)
466
543
  continue;
544
+ if (refutada(a))
545
+ continue;
467
546
  alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
468
547
  }
469
- const lines = [`# File context (${paths.length} file(s))`, ""];
548
+ // Ancla temporal: el último commit analizado del proyecto vs el HEAD
549
+ // de este árbol (misma puerta de proyecto que la refutación).
550
+ // Best-effort: el ancla jamás rompe la lectura que ancla.
551
+ let ancla = null;
552
+ if (cwdEsElProyecto(project)) {
553
+ const ultimo = await db
554
+ .rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
555
+ pf)
556
+ .catch(() => []);
557
+ ancla = await derivaContraHead(process.cwd(), ultimo[0]?.commit_hash ?? null);
558
+ }
559
+ const lines = [`# File context (${paths.length} file(s))`];
560
+ if (ancla)
561
+ lines.push(ancla);
562
+ lines.push("");
470
563
  for (const f of perFile) {
471
564
  lines.push(`## ${f.file}`);
472
565
  if (f.modules.length === 0) {
@@ -475,14 +568,18 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
475
568
  for (const m of f.modules) {
476
569
  lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
477
570
  (m.risk ? `, risk: ${m.risk}` : ""));
478
- if (m.last_note)
479
- lines.push(` - Latest note: ${m.last_note}`);
571
+ if (m.last_note) {
572
+ // Con fecha: una nota es una observación fechada, no estado.
573
+ lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
574
+ }
480
575
  for (const plain of alertsByModule.get(m.module) ?? []) {
481
576
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
482
577
  }
483
578
  }
484
579
  lines.push("");
485
580
  }
581
+ if (!ancla)
582
+ lines.push(TEMPORAL_CONTRACT, "");
486
583
  const contextText = lines.join("\n");
487
584
  const salida = toolResult(contextText, { files: perFile });
488
585
  recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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.3",
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.3",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }