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