changebook 0.4.8 → 0.4.10
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/README.md +3 -0
- package/dist/analyze.js +5 -3
- package/dist/context.js +76 -29
- package/dist/credentials.js +42 -0
- package/dist/git.js +77 -2
- package/dist/guard.js +267 -23
- package/dist/impact.js +630 -0
- package/dist/import.js +2 -2
- package/dist/index.js +54 -1
- package/dist/login.js +61 -1
- package/dist/supabase.js +48 -4
- package/dist/sync.js +94 -23
- package/dist/tools.js +101 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -21,7 +21,8 @@ import { runGuard } from "./guard.js";
|
|
|
21
21
|
import { hookStatus, installHook, uninstallHook } from "./hook.js";
|
|
22
22
|
import { importHistory } from "./import.js";
|
|
23
23
|
import { registerAgents } from "./init.js";
|
|
24
|
-
import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
|
|
24
|
+
import { contextHookInstalled, installContextHook, installSettingsHook, printContext, settingsHookInstalled, uninstallContextHook, uninstallSettingsHook, } from "./context.js";
|
|
25
|
+
import { IMPACT_HOOK, printImpact, warmImpactCache } from "./impact.js";
|
|
25
26
|
import { login } from "./login.js";
|
|
26
27
|
import { AUTH_HELP, Supabase } from "./supabase.js";
|
|
27
28
|
import { syncContextFiles } from "./sync.js";
|
|
@@ -52,6 +53,12 @@ Usage:
|
|
|
52
53
|
changebook hook-context install|uninstall|status [dir]
|
|
53
54
|
Push the atlas into EVERY Claude Code session at turn 0
|
|
54
55
|
via a SessionStart hook in .claude/settings.json
|
|
56
|
+
changebook impact Print the blast radius of the file a PreToolUse hook
|
|
57
|
+
payload (on stdin) is about to edit
|
|
58
|
+
changebook hook-impact install|uninstall|status [dir]
|
|
59
|
+
Tell the agent who depends on a file BEFORE it edits it,
|
|
60
|
+
via a PreToolUse hook in .claude/settings.json. Never
|
|
61
|
+
blocks an edit; silent when there is nothing to say
|
|
55
62
|
changebook init [dir] login + register MCP in every agent found + hook + sync
|
|
56
63
|
changebook open Open the web atlas in the browser
|
|
57
64
|
changebook serve Run the MCP server on stdio (default with no arguments)
|
|
@@ -246,6 +253,43 @@ async function main() {
|
|
|
246
253
|
}
|
|
247
254
|
return;
|
|
248
255
|
}
|
|
256
|
+
case "impact": {
|
|
257
|
+
// --warm es el lado DESACOPLADO, el que sí toca la red: lo lanza el propio
|
|
258
|
+
// hook en un proceso aparte cuando encuentra la caché fría. No lee stdin y
|
|
259
|
+
// no escribe en stdout (ver calentarDesacoplado: su salida iría a la
|
|
260
|
+
// tubería que Claude Code lee como respuesta del hook).
|
|
261
|
+
if (arg === "--warm") {
|
|
262
|
+
await warmImpactCache(new Supabase(), process.argv[4] ?? process.cwd());
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Camino crítico de CADA edición: como `context`, jamás requireCredentials
|
|
266
|
+
// (saldría con 1) y jamás help. printImpact falla abierto — sin sesión,
|
|
267
|
+
// sin caché o lento, cero salida y exit 0. Y nunca sale con 2, que es el
|
|
268
|
+
// código con el que Claude Code BLOQUEA la edición.
|
|
269
|
+
await printImpact(new Supabase());
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case "hook-impact": {
|
|
273
|
+
const dir = process.argv[4] ?? process.cwd();
|
|
274
|
+
if (arg === "install") {
|
|
275
|
+
const r = installSettingsHook(dir, IMPACT_HOOK);
|
|
276
|
+
console.error(r === "installed"
|
|
277
|
+
? `✓ PreToolUse hook installed (${dir}/.claude/settings.json). Before every edit, the agent now gets told who depends on the file it is about to touch.`
|
|
278
|
+
: "PreToolUse hook already installed.");
|
|
279
|
+
}
|
|
280
|
+
else if (arg === "uninstall") {
|
|
281
|
+
const r = uninstallSettingsHook(dir, IMPACT_HOOK);
|
|
282
|
+
console.error(r === "removed"
|
|
283
|
+
? "✓ PreToolUse hook removed."
|
|
284
|
+
: "No ChangeBook PreToolUse hook found.");
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
console.error(settingsHookInstalled(dir, IMPACT_HOOK)
|
|
288
|
+
? `✓ ChangeBook PreToolUse hook installed (${dir}/.claude/settings.json).`
|
|
289
|
+
: "✗ No ChangeBook PreToolUse hook. Install with: changebook hook-impact install");
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
249
293
|
case "init": {
|
|
250
294
|
let db = new Supabase();
|
|
251
295
|
if (!db.hasCredentials()) {
|
|
@@ -272,6 +316,15 @@ async function main() {
|
|
|
272
316
|
console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
|
|
273
317
|
"(no tool call needed, never stale):\n changebook hook-context install");
|
|
274
318
|
}
|
|
319
|
+
// El segundo canal se ofrece aparte porque responde a otra pregunta. El
|
|
320
|
+
// primero da el mapa al abrir; este avisa de a quién te llevas por delante
|
|
321
|
+
// justo antes de escribir, que es lo que hace falta cuando nadie pregunta
|
|
322
|
+
// nada. Se ofrece, NUNCA se instala en silencio: mismo motivo que el otro,
|
|
323
|
+
// .claude/settings.json se commitea y se comparte.
|
|
324
|
+
if (!settingsHookInstalled(dir, IMPACT_HOOK)) {
|
|
325
|
+
console.error("\nOptional: before every edit, tell the agent who depends on the file " +
|
|
326
|
+
"it is about to touch:\n changebook hook-impact install");
|
|
327
|
+
}
|
|
275
328
|
console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
|
|
276
329
|
return;
|
|
277
330
|
}
|
package/dist/login.js
CHANGED
|
@@ -15,6 +15,38 @@ import * as http from "node:http";
|
|
|
15
15
|
import { atlasWebUrl, openInBrowser } from "./browser.js";
|
|
16
16
|
import { credentialsPath, saveCredentials } from "./credentials.js";
|
|
17
17
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
18
|
+
const SUPABASE_URL = process.env.CHANGEBOOK_SUPABASE_URL ??
|
|
19
|
+
"https://oyosihxkecspjkiligga.supabase.co";
|
|
20
|
+
const ANON_KEY = process.env.CHANGEBOOK_SUPABASE_ANON_KEY ??
|
|
21
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
|
|
22
|
+
/**
|
|
23
|
+
* Canjea un codigo de un solo uso por una sesion PROPIA de este CLI.
|
|
24
|
+
*
|
|
25
|
+
* Espejo de `exchangeTokenHash` de la extension (src/auth/supabaseAuth.ts), que
|
|
26
|
+
* lleva usando este camino desde que se diagnostico el problema. El backend
|
|
27
|
+
* acuña el codigo con `generateLink({type:"magiclink"})` en la funcion
|
|
28
|
+
* editor-handoff — no se manda ningun correo — y aqui se cambia por tokens que
|
|
29
|
+
* no comparte nadie mas.
|
|
30
|
+
*/
|
|
31
|
+
async function canjearTokenHash(tokenHash) {
|
|
32
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/verify`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ type: "magiclink", token_hash: tokenHash }),
|
|
36
|
+
signal: AbortSignal.timeout(30_000),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`No se pudo canjear el codigo de acceso (${res.status}). Vuelve a intentarlo.`);
|
|
40
|
+
}
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
if (!data.refresh_token) {
|
|
43
|
+
throw new Error("El canje no devolvio una sesion utilizable.");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
access_token: data.access_token ?? undefined,
|
|
47
|
+
refresh_token: data.refresh_token,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
18
50
|
const CALLBACK_PAGE = `<!DOCTYPE html>
|
|
19
51
|
<html><head><meta charset="utf-8"><title>ChangeBook</title></head>
|
|
20
52
|
<body style="font-family: sans-serif; max-width: 480px; margin: 80px auto; text-align: center;">
|
|
@@ -22,6 +54,7 @@ const CALLBACK_PAGE = `<!DOCTYPE html>
|
|
|
22
54
|
<script>
|
|
23
55
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
24
56
|
const payload = {
|
|
57
|
+
token_hash: params.get("token_hash"),
|
|
25
58
|
access_token: params.get("access_token"),
|
|
26
59
|
refresh_token: params.get("refresh_token"),
|
|
27
60
|
state: params.get("state"),
|
|
@@ -62,6 +95,28 @@ export async function login() {
|
|
|
62
95
|
res.end();
|
|
63
96
|
return;
|
|
64
97
|
}
|
|
98
|
+
// VIA PREFERIDA: un codigo de un solo uso que se canjea por una
|
|
99
|
+
// sesion PROPIA de este CLI. La via heredada —recibir los tokens
|
|
100
|
+
// vivos de la pestana web— entrega a dos clientes el MISMO refresh
|
|
101
|
+
// token rotatorio, y Supabase revoca la familia entera en cuanto uno
|
|
102
|
+
// de los dos lo rota. Como el navegador refresca solo en segundo
|
|
103
|
+
// plano, siempre gana el, y la sesion del CLI moria ~30 min despues
|
|
104
|
+
// de cada login, con reloj. La extension ya usaba este camino desde
|
|
105
|
+
// que se diagnostico; al CLI no se le habia traido.
|
|
106
|
+
if (data.token_hash) {
|
|
107
|
+
canjearTokenHash(data.token_hash).then((sesion) => {
|
|
108
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
109
|
+
res.end('{"ok":true}');
|
|
110
|
+
server.close();
|
|
111
|
+
resolve(sesion);
|
|
112
|
+
}, (err) => {
|
|
113
|
+
res.writeHead(400);
|
|
114
|
+
res.end();
|
|
115
|
+
server.close();
|
|
116
|
+
reject(err);
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
65
120
|
if (!data.refresh_token) {
|
|
66
121
|
res.writeHead(400);
|
|
67
122
|
res.end();
|
|
@@ -93,7 +148,12 @@ export async function login() {
|
|
|
93
148
|
reject(new Error("Could not open a loopback port."));
|
|
94
149
|
return;
|
|
95
150
|
}
|
|
96
|
-
|
|
151
|
+
// `th=1` anuncia que este CLI sabe canjear un token_hash. La web solo lo
|
|
152
|
+
// manda si lo ve: sin la marca, un CLI antiguo recibiria un codigo que no
|
|
153
|
+
// entiende y el login fallaria sin explicacion. Arreglar un fallo de
|
|
154
|
+
// sesion rompiendo el login de quien no ha actualizado no seria arreglar
|
|
155
|
+
// nada.
|
|
156
|
+
const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}&th=1`;
|
|
97
157
|
console.error("Opening your browser to sign in to ChangeBook…");
|
|
98
158
|
console.error(`If it doesn't open, visit:\n\n ${url}\n`);
|
|
99
159
|
openInBrowser(url);
|
package/dist/supabase.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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, withCredentialsLock, } from './credentials.js';
|
|
8
|
+
import { clearRefreshInFlight, credentialsPath, loadCredentials, markRefreshInFlight, readRefreshInFlight, saveCredentials, withCredentialsLock, } from './credentials.js';
|
|
9
9
|
import { slugifyProject } from './guard.js';
|
|
10
10
|
// Public defaults — the anon key is the same public key the web app ships.
|
|
11
11
|
const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
|
|
@@ -335,10 +335,30 @@ export class Supabase {
|
|
|
335
335
|
return;
|
|
336
336
|
}
|
|
337
337
|
this.refreshToken = plan.refresh_token;
|
|
338
|
+
// Una renovacion anterior que quedo a medias: la dejo escrita ANTES de que
|
|
339
|
+
// esta salga, para no confundirla con la mia.
|
|
340
|
+
const aMedias = this.persistRotation ? readRefreshInFlight() : null;
|
|
341
|
+
// Supabase rota el token AL RECIBIR la peticion, asi que desde esta linea y
|
|
342
|
+
// hasta que se guarde el nuevo, el token del disco esta muerto sin que nadie
|
|
343
|
+
// lo sepa. Dejar constancia no evita que maten al proceso en esa ventana
|
|
344
|
+
// —el analyze post-commit corre desacoplado— pero convierte el sintoma de
|
|
345
|
+
// dentro de media hora en algo explicable.
|
|
346
|
+
if (this.persistRotation)
|
|
347
|
+
markRefreshInFlight();
|
|
338
348
|
const res = await this.refreshOnce(this.refreshToken);
|
|
339
349
|
if (!res.ok) {
|
|
340
350
|
const body = (await res.text()).slice(0, 300);
|
|
341
|
-
|
|
351
|
+
// El token esta gastado o muerto de todos modos: la marca ya no sirve.
|
|
352
|
+
if (this.persistRotation)
|
|
353
|
+
clearRefreshInFlight();
|
|
354
|
+
const yaUsado = /already[ _]used|refresh[ _]token[ _]not[ _]valid/i.test(body);
|
|
355
|
+
const explicacion = yaUsado && aMedias
|
|
356
|
+
? `\n\nQUE PASO: una renovacion anterior empezo el ${aMedias.at} (proceso ${aMedias.pid}) y no llego a terminar. ` +
|
|
357
|
+
`El servidor rotó el token al recibirla, pero este equipo no guardó el nuevo — normalmente porque mataron al proceso ` +
|
|
358
|
+
`(el analisis post-commit corre en segundo plano). Al reintentar con el viejo, Supabase lo toma por robado y cierra la sesion. ` +
|
|
359
|
+
`No has hecho nada mal y no hay nada que reparar: basta con volver a entrar.`
|
|
360
|
+
: '';
|
|
361
|
+
throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}${explicacion}\n\n${AUTH_HELP}`, res.status);
|
|
342
362
|
}
|
|
343
363
|
const data = (await res.json());
|
|
344
364
|
this.accessToken = data.access_token;
|
|
@@ -352,9 +372,33 @@ export class Supabase {
|
|
|
352
372
|
refresh_token: this.refreshToken,
|
|
353
373
|
access_token: this.accessToken,
|
|
354
374
|
});
|
|
375
|
+
// Guardado: la ventana peligrosa se ha cerrado. Se retira la marca
|
|
376
|
+
// DESPUES de escribir, no antes — al reves dejaria un hueco en el que
|
|
377
|
+
// ni hay marca ni hay token nuevo, que es justo el estado que la marca
|
|
378
|
+
// existe para poder contar.
|
|
379
|
+
clearRefreshInFlight();
|
|
355
380
|
}
|
|
356
|
-
catch {
|
|
357
|
-
//
|
|
381
|
+
catch (err) {
|
|
382
|
+
// NO es inofensivo, aunque lo parezca desde aquí.
|
|
383
|
+
//
|
|
384
|
+
// Este proceso sigue funcionando con su sesion en memoria, y por eso el
|
|
385
|
+
// codigo anterior se lo tragaba en silencio. Pero el token rotado se ha
|
|
386
|
+
// perdido: Supabase ya invalido el viejo, asi que el SIGUIENTE proceso
|
|
387
|
+
// leera del disco un token gastado, lo reenviara, y la deteccion de
|
|
388
|
+
// reutilizacion revocara la sesion entera. El sintoma aparece minutos u
|
|
389
|
+
// horas despues, sin ninguna relacion visible con este fallo de
|
|
390
|
+
// escritura — que es lo que lo hacia indiagnosticable.
|
|
391
|
+
//
|
|
392
|
+
// Firma en los logs de auth del 2026-07-25: un refresco con 200 a las
|
|
393
|
+
// 06:51:54 y, diez minutos mas tarde y sin nada en medio, un 400
|
|
394
|
+
// "Possible abuse attempt". Es exactamente lo que deja este camino.
|
|
395
|
+
//
|
|
396
|
+
// No se lanza: tumbar la orden en curso castigaria al usuario por algo
|
|
397
|
+
// que todavia funciona. Se avisa, alto y con el arreglo puesto.
|
|
398
|
+
const motivo = err instanceof Error ? err.message : String(err);
|
|
399
|
+
console.error(`ChangeBook: no se pudo guardar la sesion renovada en ${credentialsPath()} (${motivo}).\n` +
|
|
400
|
+
`Esta orden termina bien, pero la siguiente reutilizara un token ya gastado y la sesion se cerrara sola.\n` +
|
|
401
|
+
`Comprueba los permisos de ese fichero y vuelve a entrar con: changebook login`);
|
|
358
402
|
}
|
|
359
403
|
}
|
|
360
404
|
}
|
package/dist/sync.js
CHANGED
|
@@ -200,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
200
200
|
// la tool solo cubre definiciones y el agente acababa pagando atlas Y
|
|
201
201
|
// grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
|
|
202
202
|
// hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
|
|
203
|
-
|
|
203
|
+
// Los VERBOS son literales a propósito. La ley medida el 2026-07-21 fue que
|
|
204
|
+
// la adopción es coincidencia LITERAL (los disparadores literales la movieron
|
|
205
|
+
// de 60/100/0 a 100/100/60), y el 2026-07-26 se comprobó el otro lado de la
|
|
206
|
+
// misma ley: T4 pidió «vas a cambiar el comportamiento de la función X» y el
|
|
207
|
+
// agente, con el atlas disponible, fue directo a grep — 32 turnos, 1,46M
|
|
208
|
+
// tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
|
|
209
|
+
// habla quien programa con un agente. Quien no pregunta nada no reconoce un
|
|
210
|
+
// disparador abstracto: hay que nombrar sus verbos.
|
|
211
|
+
'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
|
|
204
212
|
'',
|
|
205
213
|
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
|
|
206
214
|
'',
|
|
207
215
|
// Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
|
|
208
|
-
|
|
216
|
+
//
|
|
217
|
+
// «Anuncia» pasó a «di qué harías y ESPERA su OK» el 2026-07-26, a petición
|
|
218
|
+
// de Raúl y con razón: anunciar y ponerse a trabajar en el mismo turno no le
|
|
219
|
+
// deja decidir nada. Y valía para «un encargo», o sea solo para la cola;
|
|
220
|
+
// ahora vale al abrir, que es cuando el agente elige por su cuenta qué mirar
|
|
221
|
+
// y qué tocar. Lo que se gasta aquí se recupera en la línea de la cola, que
|
|
222
|
+
// decía esto mismo por segunda vez.
|
|
223
|
+
`${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Al abrir: di en 2 líneas qué harías y por qué, y ESPERA su OK antes de tocar código. DILE los riesgos que el atlas te enseñe — él no los ve.`,
|
|
209
224
|
'',
|
|
210
225
|
];
|
|
211
226
|
if (modules.length === 0) {
|
|
@@ -266,27 +281,29 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
266
281
|
].slice(0, 3);
|
|
267
282
|
const taskLines = taskTitles.length > 0
|
|
268
283
|
? [
|
|
269
|
-
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto.
|
|
284
|
+
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
|
|
270
285
|
...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
|
|
271
286
|
]
|
|
272
287
|
: [];
|
|
273
|
-
// El orden de renderizado (legibilidad) y la prioridad de presupuesto
|
|
274
|
-
//
|
|
275
|
-
//
|
|
288
|
+
// El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
|
|
289
|
+
// recorta primero) son independientes. Los dos importan, y hasta el 2026-07-26
|
|
290
|
+
// solo uno estaba bien.
|
|
291
|
+
//
|
|
292
|
+
// EL RIESGO VA PRIMERO AL LEER, y eso es el cambio. El presupuesto ya
|
|
293
|
+
// priorizaba las regresiones sobre el mapa, pero el ORDEN DE LECTURA ponía
|
|
294
|
+
// `### Módulos` en cabeza, así que lo que estaba frágil quedaba debajo de una
|
|
295
|
+
// lista de quince módulos. Medido ese día con 30 corridas: el bloque empujado
|
|
296
|
+
// al prompt del agente entrega 0,40 de los hechos de riesgo contra 0,60 de la
|
|
297
|
+
// tool, y las cinco corridas del brazo con empuje van de 0,4 a 0,6 mientras las
|
|
298
|
+
// de git puro van de 0 a 0,2. O sea: el canal llega, el agente lo tiene
|
|
299
|
+
// delante, y no lo usa. La hipótesis que queda es la posición.
|
|
300
|
+
//
|
|
301
|
+
// Y `hotspots` sube de prioridad 4 a 1. Con 4 era la penúltima en caer, así que
|
|
302
|
+
// en un proyecto con muchos módulos se recortaba y los críticos solo aparecían
|
|
303
|
+
// como un ⚠ inline dentro de la lista del mapa — el sitio exacto donde no se
|
|
304
|
+
// leen. Los módulos bajan a 3: el mapa se puede pedir con una tool, el riesgo
|
|
305
|
+
// de hoy no está en ningún sitio más.
|
|
276
306
|
const sections = [
|
|
277
|
-
{ key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
|
|
278
|
-
{
|
|
279
|
-
key: 'tasks',
|
|
280
|
-
priority: 1,
|
|
281
|
-
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
282
|
-
lines: taskLines,
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
key: 'couplings',
|
|
286
|
-
priority: 3,
|
|
287
|
-
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
288
|
-
lines: couplingLines,
|
|
289
|
-
},
|
|
290
307
|
{
|
|
291
308
|
key: 'alerts',
|
|
292
309
|
priority: 0,
|
|
@@ -301,10 +318,23 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
301
318
|
},
|
|
302
319
|
{
|
|
303
320
|
key: 'hotspots',
|
|
304
|
-
priority:
|
|
305
|
-
title: '###
|
|
321
|
+
priority: 1,
|
|
322
|
+
title: '### Módulos críticos (frágiles por diseño: revisa antes de modificar)',
|
|
306
323
|
lines: hotspotLines,
|
|
307
324
|
},
|
|
325
|
+
{
|
|
326
|
+
key: 'couplings',
|
|
327
|
+
priority: 2,
|
|
328
|
+
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
329
|
+
lines: couplingLines,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
key: 'tasks',
|
|
333
|
+
priority: 1,
|
|
334
|
+
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
335
|
+
lines: taskLines,
|
|
336
|
+
},
|
|
337
|
+
{ key: 'modules', priority: 3, title: '### Módulos', lines: moduleLines },
|
|
308
338
|
{
|
|
309
339
|
key: 'changes',
|
|
310
340
|
priority: 5,
|
|
@@ -313,21 +343,62 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
313
343
|
},
|
|
314
344
|
];
|
|
315
345
|
let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
|
|
346
|
+
// ── El suelo del mapa ──────────────────────────────────────────────────────
|
|
347
|
+
//
|
|
348
|
+
// El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
|
|
349
|
+
// Medido el 2026-07-26 al hacerlo: con datos de producción reales (1 regresión,
|
|
350
|
+
// 1 control en riesgo, 2 módulos críticos, 2 encargos) el mapa cayó de 18
|
|
351
|
+
// módulos a DOS.
|
|
352
|
+
//
|
|
353
|
+
// Y eso no es un detalle estético: el estudio de 76 agentes del 2026-07-20
|
|
354
|
+
// concluyó que el mapa expulsado era «la causa nº 1 de que el agente ignore o
|
|
355
|
+
// desconfíe del atlas». Cambiar un fallo medido por otro fallo medido no es una
|
|
356
|
+
// mejora.
|
|
357
|
+
//
|
|
358
|
+
// Así que el reparto deja de ser el-que-llega-primero-se-lo-lleva-todo: se
|
|
359
|
+
// reserva lo que cuestan las primeras MIN_LINEAS_MAPA líneas del mapa, y solo
|
|
360
|
+
// el resto se disputa por prioridad. Si el mapa tiene menos líneas que el
|
|
361
|
+
// suelo, sobra menos reserva; si no hay mapa, no se reserva nada.
|
|
362
|
+
const MIN_LINEAS_MAPA = 6;
|
|
363
|
+
const mapa = sections.find((x) => x.key === 'modules');
|
|
364
|
+
let reservaMapa = 0;
|
|
365
|
+
if (mapa && mapa.lines.length > 0) {
|
|
366
|
+
reservaMapa = mapa.title.length + 2;
|
|
367
|
+
for (const line of mapa.lines.slice(0, MIN_LINEAS_MAPA)) {
|
|
368
|
+
reservaMapa += line.length + 1;
|
|
369
|
+
}
|
|
370
|
+
// Nunca más de un tercio: el suelo protege al mapa, no lo convierte en el
|
|
371
|
+
// dueño del bloque.
|
|
372
|
+
reservaMapa = Math.min(reservaMapa, Math.floor(budget / 3));
|
|
373
|
+
// Se APARTA del fondo común aquí. Sin esto, dárselo luego al mapa como
|
|
374
|
+
// `budget + reserva` lo contaba dos veces y el bloque se pasaba del tope
|
|
375
|
+
// (medido: 2.344 de 2.000).
|
|
376
|
+
budget -= reservaMapa;
|
|
377
|
+
}
|
|
316
378
|
const includedCount = new Map();
|
|
317
379
|
for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
|
|
318
380
|
if (s.lines.length === 0)
|
|
319
381
|
continue;
|
|
382
|
+
// El mapa gasta su reserva ADEMÁS de lo que haya quedado libre.
|
|
383
|
+
const disponible = s.key === 'modules' ? budget + reservaMapa : budget;
|
|
320
384
|
let cost = s.title.length + 2; // título + línea en blanco separadora
|
|
321
385
|
let count = 0;
|
|
322
386
|
for (const line of s.lines) {
|
|
323
|
-
if (cost + line.length >
|
|
387
|
+
if (cost + line.length > disponible)
|
|
324
388
|
break;
|
|
325
389
|
cost += line.length + 1;
|
|
326
390
|
count += 1;
|
|
327
391
|
}
|
|
328
392
|
if (count > 0) {
|
|
329
393
|
includedCount.set(s.key, count);
|
|
330
|
-
|
|
394
|
+
if (s.key === 'modules') {
|
|
395
|
+
// Lo que consumió por encima de su reserva sale del fondo común.
|
|
396
|
+
budget -= Math.max(0, cost - reservaMapa);
|
|
397
|
+
reservaMapa = 0;
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
budget -= cost;
|
|
401
|
+
}
|
|
331
402
|
}
|
|
332
403
|
}
|
|
333
404
|
const lines = [...head];
|
package/dist/tools.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { execFileAsync } from "./git.js";
|
|
10
|
-
import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
|
|
10
|
+
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
11
11
|
import { SupabaseError } from "./supabase.js";
|
|
12
12
|
const CHARACTER_LIMIT = 25_000;
|
|
13
13
|
/**
|
|
@@ -96,6 +96,49 @@ export function quotedInList(values) {
|
|
|
96
96
|
.join(",");
|
|
97
97
|
}
|
|
98
98
|
export const FILES_CAP = 8;
|
|
99
|
+
const MAX_DEPENDENTS = 6;
|
|
100
|
+
/** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
|
|
101
|
+
* El mapa de la web dibuja las flechas sobre esta misma ventana: dos
|
|
102
|
+
* ventanas distintas darían dependencias distintas según se mire el dibujo
|
|
103
|
+
* o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
|
|
104
|
+
const MODULE_GRAPH_WINDOW_ROWS = 1000;
|
|
105
|
+
export function dependentsOf(targets, rows) {
|
|
106
|
+
const graph = new Map();
|
|
107
|
+
for (const r of rows) {
|
|
108
|
+
const label = (r.module ?? "").trim();
|
|
109
|
+
if (!label || graph.has(label.toLowerCase()))
|
|
110
|
+
continue;
|
|
111
|
+
const deps = Array.isArray(r.deps)
|
|
112
|
+
? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
|
|
113
|
+
: [];
|
|
114
|
+
graph.set(label.toLowerCase(), deps);
|
|
115
|
+
}
|
|
116
|
+
const reverse = new Map();
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
const from = (r.module ?? "").trim();
|
|
119
|
+
if (!from)
|
|
120
|
+
continue;
|
|
121
|
+
for (const to of graph.get(from.toLowerCase()) ?? []) {
|
|
122
|
+
const key = to.toLowerCase();
|
|
123
|
+
if (key === from.toLowerCase())
|
|
124
|
+
continue;
|
|
125
|
+
const set = reverse.get(key) ?? new Set();
|
|
126
|
+
set.add(from);
|
|
127
|
+
reverse.set(key, set);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const out = new Map();
|
|
131
|
+
for (const t of targets) {
|
|
132
|
+
const label = (t ?? "").trim();
|
|
133
|
+
if (!label)
|
|
134
|
+
continue;
|
|
135
|
+
const found = reverse.get(label.toLowerCase());
|
|
136
|
+
if (!found || found.size === 0)
|
|
137
|
+
continue;
|
|
138
|
+
out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
99
142
|
/**
|
|
100
143
|
* Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
|
|
101
144
|
* paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
|
|
@@ -735,7 +778,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
735
778
|
for (const f of perFile) {
|
|
736
779
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
737
780
|
}
|
|
738
|
-
const [alerts, watched, recidivismRows] = await Promise.all([
|
|
781
|
+
const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
|
|
739
782
|
moduleNames.length
|
|
740
783
|
? 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` +
|
|
741
784
|
pf)
|
|
@@ -759,6 +802,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
759
802
|
pf)
|
|
760
803
|
.catch(() => [])
|
|
761
804
|
: Promise.resolve([]),
|
|
805
|
+
// Radio de impacto: grafo COMPLETO del proyecto (no filtrado por
|
|
806
|
+
// moduleNames — quién depende de X vive en las filas de OTROS
|
|
807
|
+
// módulos). En el mismo Promise.all: cero rondas extra, cero turnos
|
|
808
|
+
// extra. Best-effort: sin columna, el contexto se sirve sin radio.
|
|
809
|
+
db
|
|
810
|
+
.rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
811
|
+
pf)
|
|
812
|
+
.catch(() => []),
|
|
762
813
|
]);
|
|
763
814
|
const watchedByFile = new Map();
|
|
764
815
|
for (const w of watched) {
|
|
@@ -771,22 +822,55 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
771
822
|
// evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
|
|
772
823
|
// cwd no es el proyecto consultado, no se refuta nada (ver
|
|
773
824
|
// cwdEsElProyecto).
|
|
774
|
-
const
|
|
825
|
+
const enElProyecto = cwdEsElProyecto(project);
|
|
826
|
+
const refutada = enElProyecto
|
|
775
827
|
? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
|
|
776
828
|
: () => false;
|
|
777
|
-
|
|
829
|
+
// La otra mitad, desde el 2026-07-26: un aviso 'absent' cuyo símbolo
|
|
830
|
+
// SIGUE apareciendo ya no se descarta (era ambiguo y tiraba las dos
|
|
831
|
+
// alertas que predijeron el desfase de esquema de producción). Se sirve
|
|
832
|
+
// diciendo dónde aparece, que es contestar el condicional del texto
|
|
833
|
+
// ("...si los hubiera") en vez de prohibir la redacción.
|
|
834
|
+
//
|
|
835
|
+
// Mismas palabras que el hook (impactText) a propósito: dos redacciones
|
|
836
|
+
// del mismo hecho son dos hechos para el agente.
|
|
837
|
+
const localizada = enElProyecto
|
|
838
|
+
? (a) => dondeApareceElSimbolo(a, (s) => ficherosEnRepo(process.cwd(), s))
|
|
839
|
+
: () => null;
|
|
840
|
+
// Se construye la lista ANTES de renderizar para poder dejar una linea
|
|
841
|
+
// por texto: dos avisos con la misma frase se leen como una repeticion
|
|
842
|
+
// aunque por dentro sean afirmaciones opuestas, y sobre dos frases
|
|
843
|
+
// identicas no se puede actuar distinto. Gana el que trae comprobacion.
|
|
844
|
+
const porModulo = new Map();
|
|
778
845
|
for (const a of alerts) {
|
|
779
846
|
const m = (a.module ?? "").trim();
|
|
780
847
|
if (!m || !a.plain)
|
|
781
848
|
continue;
|
|
782
849
|
if (refutada(a))
|
|
783
850
|
continue;
|
|
784
|
-
|
|
851
|
+
const donde = localizada(a);
|
|
852
|
+
const fuera = dondeComprobarlo(a);
|
|
853
|
+
let extra = "";
|
|
854
|
+
if (donde) {
|
|
855
|
+
extra +=
|
|
856
|
+
`\n → CHECKED NOW: "${(a.evidence_symbol ?? "").trim()}" still appears in ${donde.join(", ")}` +
|
|
857
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`;
|
|
858
|
+
}
|
|
859
|
+
// El repo no puede contestar esta afirmacion: se dice, en vez de
|
|
860
|
+
// dejar que se compruebe donde no era.
|
|
861
|
+
if (fuera)
|
|
862
|
+
extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
|
|
863
|
+
porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
|
|
864
|
+
}
|
|
865
|
+
const alertsByModule = new Map();
|
|
866
|
+
for (const [m, entradas] of porModulo) {
|
|
867
|
+
alertsByModule.set(m, unoPorTexto(entradas, (e) => e.plain, (e) => e.extra.length > 0).map((e) => e.plain + e.extra));
|
|
785
868
|
}
|
|
786
869
|
// Reincidencia: nº de problemas de regresión DISTINTOS por módulo
|
|
787
870
|
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
|
788
871
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
789
872
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
873
|
+
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
790
874
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
791
875
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
792
876
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -829,6 +913,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
829
913
|
for (const plain of alertsByModule.get(m.module) ?? []) {
|
|
830
914
|
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
831
915
|
}
|
|
916
|
+
const dependents = dependentsByModule.get(m.module);
|
|
917
|
+
if (dependents?.length) {
|
|
918
|
+
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
919
|
+
}
|
|
832
920
|
}
|
|
833
921
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
834
922
|
lines.push(`- Current value: ${w.name} = ${w.value}` +
|
|
@@ -864,6 +952,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
864
952
|
prior_regressions: recidivismByModule.get(m.module) ?? 0,
|
|
865
953
|
}))
|
|
866
954
|
.filter((x) => x.prior_regressions >= 2),
|
|
955
|
+
// Espejo del hospedado: el structuredContent no puede prometer
|
|
956
|
+
// menos que la prosa.
|
|
957
|
+
dependents: f.modules
|
|
958
|
+
.map((m) => ({
|
|
959
|
+
module: m.module,
|
|
960
|
+
dependents: dependentsByModule.get(m.module) ?? [],
|
|
961
|
+
}))
|
|
962
|
+
.filter((x) => x.dependents.length > 0),
|
|
867
963
|
watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
|
|
868
964
|
name: w.name,
|
|
869
965
|
value: w.value,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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.10",
|
|
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.10",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|