changebook 0.4.7 → 0.4.9
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 +7 -9
- package/dist/analyze.js +4 -2
- package/dist/context.js +114 -35
- package/dist/credentials.js +42 -0
- package/dist/feed.js +147 -0
- package/dist/git.js +47 -1
- package/dist/guard.js +30 -7
- package/dist/hook.js +20 -2
- package/dist/impact.js +512 -0
- package/dist/index.js +93 -7
- package/dist/login.js +61 -1
- package/dist/supabase.js +69 -5
- package/dist/sync.js +59 -6
- package/dist/tools.js +65 -1
- package/package.json +1 -1
- package/server.json +2 -2
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';
|
|
@@ -64,7 +64,21 @@ export class Supabase {
|
|
|
64
64
|
// True when the tokens came from ~/.changebook/credentials.json: rotated
|
|
65
65
|
// refresh tokens must be written back there or the stored one goes stale.
|
|
66
66
|
persistRotation = false;
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Whether this process is allowed to SPEND the rotating refresh token.
|
|
69
|
+
*
|
|
70
|
+
* False for callers that run under a hard deadline and end in process.exit()
|
|
71
|
+
* — today, the pre-commit guard. Supabase rotates the refresh token the
|
|
72
|
+
* moment it RECEIVES the request, not when we read the reply, so a process
|
|
73
|
+
* that is killed mid-refresh burns the stored token without ever persisting
|
|
74
|
+
* the new one. The next process replays a spent token, Supabase's reuse
|
|
75
|
+
* detection fires, and the whole session dies. That is a silent logout
|
|
76
|
+
* caused by an optional warning — a terrible trade (diagnosed 2026-07-25:
|
|
77
|
+
* the session died ~30 min after every login, always on a commit).
|
|
78
|
+
*/
|
|
79
|
+
allowRefresh = true;
|
|
80
|
+
constructor(env = process.env, opts = {}) {
|
|
81
|
+
this.allowRefresh = opts.allowRefresh ?? true;
|
|
68
82
|
this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
|
|
69
83
|
this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
|
|
70
84
|
this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
|
|
@@ -288,6 +302,12 @@ export class Supabase {
|
|
|
288
302
|
return this.refreshing;
|
|
289
303
|
}
|
|
290
304
|
doRefresh() {
|
|
305
|
+
// Refusing BEFORE the request is the whole point: once it leaves, the token
|
|
306
|
+
// is spent whether or not we survive to store the replacement. Callers that
|
|
307
|
+
// opt out get a plain 401 they can treat as "no atlas this time".
|
|
308
|
+
if (!this.allowRefresh) {
|
|
309
|
+
return Promise.reject(new SupabaseError("The stored session needs renewing and this process is not allowed to spend it (it runs under a deadline). Skipping.", 401));
|
|
310
|
+
}
|
|
291
311
|
// Env-var sessions aren't shared through the credentials file, so there is
|
|
292
312
|
// nothing to coordinate between processes: refresh in place.
|
|
293
313
|
if (!this.persistRotation)
|
|
@@ -315,10 +335,30 @@ export class Supabase {
|
|
|
315
335
|
return;
|
|
316
336
|
}
|
|
317
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();
|
|
318
348
|
const res = await this.refreshOnce(this.refreshToken);
|
|
319
349
|
if (!res.ok) {
|
|
320
350
|
const body = (await res.text()).slice(0, 300);
|
|
321
|
-
|
|
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);
|
|
322
362
|
}
|
|
323
363
|
const data = (await res.json());
|
|
324
364
|
this.accessToken = data.access_token;
|
|
@@ -332,9 +372,33 @@ export class Supabase {
|
|
|
332
372
|
refresh_token: this.refreshToken,
|
|
333
373
|
access_token: this.accessToken,
|
|
334
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();
|
|
335
380
|
}
|
|
336
|
-
catch {
|
|
337
|
-
//
|
|
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`);
|
|
338
402
|
}
|
|
339
403
|
}
|
|
340
404
|
}
|
package/dist/sync.js
CHANGED
|
@@ -50,6 +50,27 @@ const SYNC_BUDGET_CHARS = 2_000;
|
|
|
50
50
|
// shared analyses and a ≥60% rate before we call it a dependency.
|
|
51
51
|
const MIN_PAIR_COUNT = 3;
|
|
52
52
|
const MIN_PAIR_RATE = 0.6;
|
|
53
|
+
/**
|
|
54
|
+
* Espejo de supabase/functions/mcp/scope.ts::summarizeHealth — el paquete npm
|
|
55
|
+
* es autocontenido. Paridad fijada en test/saludEnBrief.
|
|
56
|
+
*/
|
|
57
|
+
export function summarizeHealth(rows) {
|
|
58
|
+
let passed = 0;
|
|
59
|
+
const at_risk = [];
|
|
60
|
+
for (const r of rows) {
|
|
61
|
+
if (r.status === 'passed') {
|
|
62
|
+
passed += 1;
|
|
63
|
+
}
|
|
64
|
+
else if (r.status === 'at_risk') {
|
|
65
|
+
at_risk.push({
|
|
66
|
+
check: r.check_id,
|
|
67
|
+
evidence: (r.evidence ?? '').trim(),
|
|
68
|
+
since: r.updated_at ? r.updated_at.slice(0, 10) : null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { passed, at_risk };
|
|
73
|
+
}
|
|
53
74
|
// Same window/limit the web uses for the signals strip.
|
|
54
75
|
const ALERT_WINDOW_DAYS = 14;
|
|
55
76
|
const MAX_ALERTS = 3;
|
|
@@ -76,7 +97,7 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
76
97
|
}
|
|
77
98
|
const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
78
99
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
79
|
-
const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
|
|
100
|
+
const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
|
|
80
101
|
db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
|
|
81
102
|
projectFilter),
|
|
82
103
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
@@ -93,8 +114,14 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
93
114
|
.then((rows) => rows.filter((r) => r.status === 'pending'))
|
|
94
115
|
.catch(() => [])
|
|
95
116
|
: Promise.resolve([]),
|
|
117
|
+
// Salud (project_checks): tabla diminuta (≤8 filas/proyecto), en paralelo.
|
|
118
|
+
db
|
|
119
|
+
.rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
|
|
120
|
+
projectFilter)
|
|
121
|
+
.catch(() => []),
|
|
96
122
|
]);
|
|
97
|
-
const
|
|
123
|
+
const health = summarizeHealth(healthRows);
|
|
124
|
+
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk);
|
|
98
125
|
return { section, projectId, projectResolved };
|
|
99
126
|
}
|
|
100
127
|
export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
@@ -119,7 +146,7 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
/** Exported for tests. */
|
|
122
|
-
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
|
|
149
|
+
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = []) {
|
|
123
150
|
// Newest-first rows: the first occurrence of a module is its latest state.
|
|
124
151
|
const seen = new Map();
|
|
125
152
|
for (const row of rows) {
|
|
@@ -173,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
173
200
|
// la tool solo cubre definiciones y el agente acababa pagando atlas Y
|
|
174
201
|
// grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
|
|
175
202
|
// hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
|
|
176
|
-
|
|
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).',
|
|
177
212
|
'',
|
|
178
213
|
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
|
|
179
214
|
'',
|
|
180
215
|
// Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
|
|
181
|
-
|
|
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.`,
|
|
182
224
|
'',
|
|
183
225
|
];
|
|
184
226
|
if (modules.length === 0) {
|
|
@@ -225,6 +267,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
225
267
|
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
|
|
226
268
|
});
|
|
227
269
|
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
|
|
270
|
+
// Salud en riesgo: los controles (auth, secretos, validación, límites…) que
|
|
271
|
+
// el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
|
|
272
|
+
// es seguridad. Solo los at_risk (lo accionable); el texto entero va a
|
|
273
|
+
// `atlas_project_brief`. Cap a 130 chars como las alertas.
|
|
274
|
+
const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
|
|
228
275
|
// Auto-remediación fase 2: la cola entra en cada sesión para que el agente
|
|
229
276
|
// se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
|
|
230
277
|
// nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
|
|
@@ -234,7 +281,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
234
281
|
].slice(0, 3);
|
|
235
282
|
const taskLines = taskTitles.length > 0
|
|
236
283
|
? [
|
|
237
|
-
`- 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\`.`,
|
|
238
285
|
...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
|
|
239
286
|
]
|
|
240
287
|
: [];
|
|
@@ -261,6 +308,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
261
308
|
title: '### Regresiones detectadas (resolver o verificar YA)',
|
|
262
309
|
lines: alertLines,
|
|
263
310
|
},
|
|
311
|
+
{
|
|
312
|
+
key: 'health',
|
|
313
|
+
priority: 1,
|
|
314
|
+
title: '### Salud en riesgo (verifica antes de tocar)',
|
|
315
|
+
lines: healthLines,
|
|
316
|
+
},
|
|
264
317
|
{
|
|
265
318
|
key: 'hotspots',
|
|
266
319
|
priority: 4,
|
package/dist/tools.js
CHANGED
|
@@ -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) {
|
|
@@ -787,6 +838,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
787
838
|
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
|
788
839
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
789
840
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
841
|
+
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
790
842
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
791
843
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
792
844
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -829,6 +881,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
829
881
|
for (const plain of alertsByModule.get(m.module) ?? []) {
|
|
830
882
|
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
831
883
|
}
|
|
884
|
+
const dependents = dependentsByModule.get(m.module);
|
|
885
|
+
if (dependents?.length) {
|
|
886
|
+
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
887
|
+
}
|
|
832
888
|
}
|
|
833
889
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
834
890
|
lines.push(`- Current value: ${w.name} = ${w.value}` +
|
|
@@ -864,6 +920,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
864
920
|
prior_regressions: recidivismByModule.get(m.module) ?? 0,
|
|
865
921
|
}))
|
|
866
922
|
.filter((x) => x.prior_regressions >= 2),
|
|
923
|
+
// Espejo del hospedado: el structuredContent no puede prometer
|
|
924
|
+
// menos que la prosa.
|
|
925
|
+
dependents: f.modules
|
|
926
|
+
.map((m) => ({
|
|
927
|
+
module: m.module,
|
|
928
|
+
dependents: dependentsByModule.get(m.module) ?? [],
|
|
929
|
+
}))
|
|
930
|
+
.filter((x) => x.dependents.length > 0),
|
|
867
931
|
watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
|
|
868
932
|
name: w.name,
|
|
869
933
|
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.9",
|
|
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.9",
|
|
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.9",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|