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 +38 -22
- package/dist/sync.js +68 -61
- package/dist/tools.js +105 -8
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 !==
|
|
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 ??
|
|
197
|
+
.filter((a) => (a.plain ?? '').trim())
|
|
195
198
|
.map((a) => {
|
|
196
|
-
const mod = (a.module ??
|
|
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}**` :
|
|
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 ===
|
|
207
|
+
.filter((m) => m.risk === 'hotspot')
|
|
205
208
|
.slice(0, 5)
|
|
206
209
|
.map((m) => {
|
|
207
|
-
const note = sanitizeCell((m.note ??
|
|
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 ??
|
|
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 ??
|
|
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:
|
|
231
|
+
{ key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
|
|
229
232
|
{
|
|
230
|
-
key:
|
|
233
|
+
key: 'tasks',
|
|
231
234
|
priority: 1,
|
|
232
|
-
title:
|
|
235
|
+
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
233
236
|
lines: taskLines,
|
|
234
237
|
},
|
|
235
238
|
{
|
|
236
|
-
key:
|
|
239
|
+
key: 'couplings',
|
|
237
240
|
priority: 3,
|
|
238
|
-
title:
|
|
241
|
+
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
239
242
|
lines: couplingLines,
|
|
240
243
|
},
|
|
241
244
|
{
|
|
242
|
-
key:
|
|
245
|
+
key: 'alerts',
|
|
243
246
|
priority: 0,
|
|
244
|
-
title:
|
|
247
|
+
title: '### Regresiones detectadas (resolver o verificar YA)',
|
|
245
248
|
lines: alertLines,
|
|
246
249
|
},
|
|
247
250
|
{
|
|
248
|
-
key:
|
|
251
|
+
key: 'hotspots',
|
|
249
252
|
priority: 4,
|
|
250
|
-
title:
|
|
253
|
+
title: '### Avisos abiertos (revisar antes de modificar)',
|
|
251
254
|
lines: hotspotLines,
|
|
252
255
|
},
|
|
253
256
|
{
|
|
254
|
-
key:
|
|
257
|
+
key: 'changes',
|
|
255
258
|
priority: 5,
|
|
256
|
-
title:
|
|
259
|
+
title: '### Últimos cambios',
|
|
257
260
|
lines: changeLines,
|
|
258
261
|
},
|
|
259
262
|
];
|
|
260
|
-
let budget = SYNC_BUDGET_CHARS - head.join(
|
|
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(
|
|
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 =
|
|
300
|
-
|
|
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 ??
|
|
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,
|
|
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 +
|
|
348
|
-
return
|
|
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 =
|
|
354
|
-
const LEGACY_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,
|
|
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
|
|
376
|
+
return 'unchanged';
|
|
370
377
|
await writeFile(file, next);
|
|
371
|
-
return
|
|
378
|
+
return 'updated';
|
|
372
379
|
}
|
|
373
|
-
const separator = content.endsWith(
|
|
374
|
-
await writeFile(file, content + separator + section +
|
|
375
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
18
|
+
"version": "0.4.3",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|