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 +38 -22
- package/dist/sync.js +71 -61
- package/dist/tools.js +270 -24
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
9
|
-
import { slugifyProject } from
|
|
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 =
|
|
12
|
-
const DEFAULT_ANON_KEY =
|
|
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(
|
|
78
|
+
const some = await this.rest('projects?select=id&limit=2');
|
|
79
79
|
if (some.length > 1) {
|
|
80
|
-
throw new SupabaseError(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
181
|
+
method: 'POST',
|
|
166
182
|
headers: {
|
|
167
183
|
apikey: this.anonKey,
|
|
168
184
|
Authorization: `Bearer ${this.accessToken}`,
|
|
169
|
-
|
|
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:
|
|
193
|
+
method: 'POST',
|
|
178
194
|
headers: {
|
|
179
195
|
apikey: this.anonKey,
|
|
180
196
|
Authorization: `Bearer ${this.accessToken}`,
|
|
181
|
-
|
|
182
|
-
Prefer:
|
|
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:
|
|
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:
|
|
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:
|
|
300
|
-
headers: { apikey: this.anonKey,
|
|
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:
|
|
348
|
+
method: 'POST',
|
|
333
349
|
headers: {
|
|
334
350
|
apikey: this.anonKey,
|
|
335
351
|
Authorization: `Bearer ${this.accessToken}`,
|
|
336
|
-
|
|
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
|
|
14
|
-
import path from
|
|
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 =
|
|
21
|
-
const 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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
96
|
+
.callRpc('list_agent_tasks', {
|
|
94
97
|
p_project_id: projectId,
|
|
95
98
|
})
|
|
96
|
-
.then((rows) => rows.filter((r) => r.status ===
|
|
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 [
|
|
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(
|
|
114
|
+
.insertRow('atlas_reads', {
|
|
112
115
|
project_id: projectId,
|
|
113
|
-
tool:
|
|
114
|
-
source:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 !==
|
|
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 ??
|
|
200
|
+
.filter((a) => (a.plain ?? '').trim())
|
|
195
201
|
.map((a) => {
|
|
196
|
-
const mod = (a.module ??
|
|
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}**` :
|
|
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 ===
|
|
210
|
+
.filter((m) => m.risk === 'hotspot')
|
|
205
211
|
.slice(0, 5)
|
|
206
212
|
.map((m) => {
|
|
207
|
-
const note = sanitizeCell((m.note ??
|
|
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 ??
|
|
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 ??
|
|
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:
|
|
234
|
+
{ key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
|
|
229
235
|
{
|
|
230
|
-
key:
|
|
236
|
+
key: 'tasks',
|
|
231
237
|
priority: 1,
|
|
232
|
-
title:
|
|
238
|
+
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
233
239
|
lines: taskLines,
|
|
234
240
|
},
|
|
235
241
|
{
|
|
236
|
-
key:
|
|
242
|
+
key: 'couplings',
|
|
237
243
|
priority: 3,
|
|
238
|
-
title:
|
|
244
|
+
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
239
245
|
lines: couplingLines,
|
|
240
246
|
},
|
|
241
247
|
{
|
|
242
|
-
key:
|
|
248
|
+
key: 'alerts',
|
|
243
249
|
priority: 0,
|
|
244
|
-
title:
|
|
250
|
+
title: '### Regresiones detectadas (resolver o verificar YA)',
|
|
245
251
|
lines: alertLines,
|
|
246
252
|
},
|
|
247
253
|
{
|
|
248
|
-
key:
|
|
254
|
+
key: 'hotspots',
|
|
249
255
|
priority: 4,
|
|
250
|
-
title:
|
|
256
|
+
title: '### Avisos abiertos (revisar antes de modificar)',
|
|
251
257
|
lines: hotspotLines,
|
|
252
258
|
},
|
|
253
259
|
{
|
|
254
|
-
key:
|
|
260
|
+
key: 'changes',
|
|
255
261
|
priority: 5,
|
|
256
|
-
title:
|
|
262
|
+
title: '### Últimos cambios',
|
|
257
263
|
lines: changeLines,
|
|
258
264
|
},
|
|
259
265
|
];
|
|
260
|
-
let budget = SYNC_BUDGET_CHARS - head.join(
|
|
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(
|
|
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 =
|
|
300
|
-
|
|
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 ??
|
|
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,
|
|
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 +
|
|
348
|
-
return
|
|
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 =
|
|
354
|
-
const LEGACY_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,
|
|
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
|
|
379
|
+
return 'unchanged';
|
|
370
380
|
await writeFile(file, next);
|
|
371
|
-
return
|
|
381
|
+
return 'updated';
|
|
372
382
|
}
|
|
373
|
-
const separator = content.endsWith(
|
|
374
|
-
await writeFile(file, content + separator + section +
|
|
375
|
-
return
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
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,
|
|
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 =
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
18
|
+
"version": "0.4.4",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|