changebook 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guard.js +109 -5
- package/dist/supabase.js +48 -21
- package/dist/sync.js +117 -93
- package/dist/tools.js +149 -31
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/guard.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
17
18
|
import { execFileAsync } from "./git.js";
|
|
18
19
|
/** Exit code that asks the pre-commit hook to abort the commit. */
|
|
19
20
|
export const EXIT_BLOCK = 3;
|
|
@@ -71,8 +72,61 @@ export function moduleFilesUnion(rows) {
|
|
|
71
72
|
}
|
|
72
73
|
return new Map([...map.entries()].map(([k, v]) => [k, [...v]]));
|
|
73
74
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
75
|
+
/** Rutas propias del aviso, si las trae. */
|
|
76
|
+
function alertFiles(alert) {
|
|
77
|
+
return Array.isArray(alert.files)
|
|
78
|
+
? alert.files.filter((f) => typeof f === "string")
|
|
79
|
+
: [];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* ¿Se contradice el aviso con el codigo que hay delante?
|
|
83
|
+
*
|
|
84
|
+
* Devuelve `true` SOLO cuando la evidencia lo tumba de forma inequivoca. Sin
|
|
85
|
+
* evidencia, con el simbolo vacio o si la busqueda falla, devuelve `false`: la
|
|
86
|
+
* duda deja pasar el aviso. Un guardian que se calla por un error de disco es
|
|
87
|
+
* peor que uno ruidoso — misma leccion que el limitador que fallaba abierto y
|
|
88
|
+
* nadie noto en nueve dias.
|
|
89
|
+
*
|
|
90
|
+
* `buscar` devuelve cuantas veces aparece el simbolo, o null si no se pudo
|
|
91
|
+
* mirar.
|
|
92
|
+
*/
|
|
93
|
+
export function avisoRefutado(alert, buscar) {
|
|
94
|
+
const simbolo = (alert.evidence_symbol ?? "").trim();
|
|
95
|
+
const espera = alert.evidence_expect;
|
|
96
|
+
if (!simbolo || (espera !== "present" && espera !== "absent"))
|
|
97
|
+
return false;
|
|
98
|
+
let apariciones;
|
|
99
|
+
try {
|
|
100
|
+
apariciones = buscar(simbolo);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (apariciones === null)
|
|
106
|
+
return false;
|
|
107
|
+
// "present": el aviso vive de que el simbolo siga ahi. Si ya no esta, el
|
|
108
|
+
// conflicto que describia no puede darse — es el caso de las 3 alertas del
|
|
109
|
+
// renombrado latestFilesByModule -> moduleFilesUnion: cero referencias.
|
|
110
|
+
if (espera === "present")
|
|
111
|
+
return apariciones === 0;
|
|
112
|
+
// "absent": el aviso vive de que algo FALTE. Si aparece, ya esta hecho.
|
|
113
|
+
return apariciones > 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Open alerts × staged files → warnings, deduped by (module, message).
|
|
117
|
+
*
|
|
118
|
+
* Dos filtros, los dos nacidos de medir los avisos reales del 2026-07-19.
|
|
119
|
+
*
|
|
120
|
+
* 1. A QUIEN se avisa. Antes se cruzaba contra `filesByModule`: TODOS los
|
|
121
|
+
* ficheros que alguna vez tocaron ese modulo. Por eso un aviso sobre
|
|
122
|
+
* `i18n.ts` saltaba al preparar `mcp/index.ts` — comparten modulo. Si el
|
|
123
|
+
* aviso trae sus propias rutas mandan esas; si no, se cae al modulo entero
|
|
124
|
+
* como antes, porque los avisos anteriores no las tienen.
|
|
125
|
+
*
|
|
126
|
+
* 2. SI SIGUE EN PIE. `refutado` lo decide quien llama, que es quien tiene el
|
|
127
|
+
* arbol de trabajo. De 7 avisos abiertos, 4 se caian con un grep.
|
|
128
|
+
*/
|
|
129
|
+
export function guardFindings(staged, alerts, filesByModule, refutado) {
|
|
76
130
|
const stagedSet = new Set(staged);
|
|
77
131
|
const seen = new Set();
|
|
78
132
|
const findings = [];
|
|
@@ -81,9 +135,17 @@ export function guardFindings(staged, alerts, filesByModule) {
|
|
|
81
135
|
const plain = (alert.plain ?? "").trim();
|
|
82
136
|
if (!module || !plain)
|
|
83
137
|
continue;
|
|
84
|
-
|
|
138
|
+
// Las rutas del aviso mandan sobre las del modulo: dicen de QUE va, no solo
|
|
139
|
+
// a que cajon pertenece.
|
|
140
|
+
const propias = alertFiles(alert);
|
|
141
|
+
const ambito = propias.length > 0 ? propias : (filesByModule.get(module) ?? []);
|
|
142
|
+
const touched = ambito.filter((f) => stagedSet.has(f));
|
|
85
143
|
if (touched.length === 0)
|
|
86
144
|
continue;
|
|
145
|
+
// Se refuta DESPUES de acotar: si el aviso no te toca, no hay por que
|
|
146
|
+
// gastar una lectura de disco en tumbarlo.
|
|
147
|
+
if (refutado?.(alert))
|
|
148
|
+
continue;
|
|
87
149
|
const key = module + "\u0000" + plain;
|
|
88
150
|
if (seen.has(key))
|
|
89
151
|
continue;
|
|
@@ -92,6 +154,48 @@ export function guardFindings(staged, alerts, filesByModule) {
|
|
|
92
154
|
}
|
|
93
155
|
return findings;
|
|
94
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Cuantas veces aparece un simbolo en el codigo versionado. `null` si no se
|
|
159
|
+
* pudo mirar — y ese `null` importa: hace que el aviso pase, no que se calle.
|
|
160
|
+
*
|
|
161
|
+
* `git grep` y no un recorrido propio: respeta .gitignore, no entra en
|
|
162
|
+
* node_modules y esta escrito en C. Sobre este repo tarda ~30 ms, asi que cabe
|
|
163
|
+
* de sobra en el presupuesto de 3,5 s del guardian.
|
|
164
|
+
*
|
|
165
|
+
* `--fixed-strings` es obligatorio: el simbolo viene de un modelo y un `$` o un
|
|
166
|
+
* `.` sueltos lo convertirian en otra expresion regular.
|
|
167
|
+
*/
|
|
168
|
+
export function contarEnRepo(dir, simbolo) {
|
|
169
|
+
if (!/^[A-Za-z_$][\w$.]{1,118}$/.test(simbolo))
|
|
170
|
+
return null;
|
|
171
|
+
try {
|
|
172
|
+
const out = execFileSync("git", [
|
|
173
|
+
"grep",
|
|
174
|
+
"--fixed-strings",
|
|
175
|
+
"--count",
|
|
176
|
+
"--",
|
|
177
|
+
simbolo,
|
|
178
|
+
// Se busca en CODIGO, nunca en prosa. Sin esto el mecanismo nace
|
|
179
|
+
// inutil: el propio `sync` escribe el texto de las alertas en
|
|
180
|
+
// CLAUDE.md y AGENTS.md, y ese texto CONTIENE el simbolo. Cualquier
|
|
181
|
+
// aviso de tipo "esto sigue usandose" encontraria su propia cita y no
|
|
182
|
+
// podria refutarse jamas. Lo cazo el test, no el diseno.
|
|
183
|
+
":!*.md",
|
|
184
|
+
":!docs/",
|
|
185
|
+
], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
|
|
186
|
+
// Una linea "fichero:N" por fichero con coincidencias.
|
|
187
|
+
return out
|
|
188
|
+
.split("\n")
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
// git grep sale con 1 cuando NO hay coincidencias: eso es un cero real, no
|
|
194
|
+
// un fallo. Cualquier otro codigo si es "no he podido mirar".
|
|
195
|
+
const code = e.status;
|
|
196
|
+
return code === 1 ? 0 : null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
95
199
|
async function gitPath(dir, name) {
|
|
96
200
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
|
|
97
201
|
return path.resolve(dir, stdout.trim());
|
|
@@ -140,7 +244,7 @@ async function fetchSignals(db, dir, env) {
|
|
|
140
244
|
let filesByModule = new Map();
|
|
141
245
|
const projectId = projects[0]?.id ?? null;
|
|
142
246
|
if (projectId) {
|
|
143
|
-
alerts = await db.rest(`regression_alerts?select=module,plain,created_at&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
|
|
247
|
+
alerts = await db.rest(`regression_alerts?select=module,plain,created_at,evidence_symbol,evidence_expect,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
|
|
144
248
|
const modules = [
|
|
145
249
|
...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
|
|
146
250
|
];
|
|
@@ -236,7 +340,7 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
236
340
|
await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
|
|
237
341
|
return 0;
|
|
238
342
|
}
|
|
239
|
-
const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
|
|
343
|
+
const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)));
|
|
240
344
|
const block = mode === "block";
|
|
241
345
|
const message = findingsMessage(findings, block);
|
|
242
346
|
// La consulta del guardián también es una consulta del atlas (QA
|
package/dist/supabase.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* every query to the signed-in user. Refreshes the access token with the
|
|
6
6
|
* refresh token when needed (refresh does not require a captcha).
|
|
7
7
|
*/
|
|
8
|
-
import { loadCredentials, saveCredentials } from
|
|
9
|
-
import { slugifyProject } from
|
|
8
|
+
import { loadCredentials, saveCredentials } from './credentials.js';
|
|
9
|
+
import { slugifyProject } from './guard.js';
|
|
10
10
|
// Public defaults — the anon key is the same public key the web app ships.
|
|
11
|
-
const DEFAULT_URL =
|
|
12
|
-
const DEFAULT_ANON_KEY =
|
|
11
|
+
const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
|
|
12
|
+
const DEFAULT_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE';
|
|
13
13
|
export const AUTH_HELP = `No ChangeBook session configured. Run:
|
|
14
14
|
|
|
15
15
|
changebook login
|
|
@@ -42,7 +42,7 @@ export class Supabase {
|
|
|
42
42
|
// refresh tokens must be written back there or the stored one goes stale.
|
|
43
43
|
persistRotation = false;
|
|
44
44
|
constructor(env = process.env) {
|
|
45
|
-
this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/,
|
|
45
|
+
this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
|
|
46
46
|
this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
|
|
47
47
|
this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
|
|
48
48
|
this.refreshToken = env.CHANGEBOOK_REFRESH_TOKEN?.trim() || undefined;
|
|
@@ -75,11 +75,11 @@ export class Supabase {
|
|
|
75
75
|
// mezcla — la frontera por proyecto que el MCP hospedado ya aplica
|
|
76
76
|
// (auditoría M3: el stdio devolvía "" = todos los proyectos y mezclaba
|
|
77
77
|
// historia/módulos/contexto). Con 0 o 1 proyecto no hay ambigüedad.
|
|
78
|
-
const some = await this.rest(
|
|
78
|
+
const some = await this.rest('projects?select=id&limit=2');
|
|
79
79
|
if (some.length > 1) {
|
|
80
|
-
throw new SupabaseError(
|
|
80
|
+
throw new SupabaseError('Esta cuenta tiene varios proyectos. Define CHANGEBOOK_PROJECT (nombre o slug del repo) o pasa `project` en la tool para no mezclar sus datos.', 400);
|
|
81
81
|
}
|
|
82
|
-
this.projectFilterCache =
|
|
82
|
+
this.projectFilterCache = '';
|
|
83
83
|
return this.projectFilterCache;
|
|
84
84
|
}
|
|
85
85
|
const v = encodeURIComponent(this.projectEnv);
|
|
@@ -99,7 +99,7 @@ export class Supabase {
|
|
|
99
99
|
* then exact name; falls back to the env-based filter when absent. The
|
|
100
100
|
* atlas is per-project, so tools pass what the agent gave them here.
|
|
101
101
|
*/
|
|
102
|
-
async projectFilterFor(project) {
|
|
102
|
+
async projectFilterFor(project, opts) {
|
|
103
103
|
const wanted = project?.trim();
|
|
104
104
|
if (!wanted)
|
|
105
105
|
return this.projectFilter();
|
|
@@ -111,7 +111,34 @@ export class Supabase {
|
|
|
111
111
|
rows = await this.rest(`projects?select=id&name=eq.${encodeURIComponent(wanted)}&limit=1`);
|
|
112
112
|
}
|
|
113
113
|
if (rows.length === 0) {
|
|
114
|
-
|
|
114
|
+
// Con UN solo proyecto no hay frontera que proteger: el nombre sirve
|
|
115
|
+
// para elegir y no hay entre qué elegir. Fallar aquí gastaba una llamada
|
|
116
|
+
// entera para decir "no lo encuentro" cuando la respuesta era obvia — y
|
|
117
|
+
// pasa de verdad, porque el nombre que se manda es el basename del
|
|
118
|
+
// directorio y basta clonar el repo en una carpeta distinta.
|
|
119
|
+
//
|
|
120
|
+
// Espejo de decideScope en supabase/functions/mcp/scope.ts. Es lo que
|
|
121
|
+
// permite exigir `project` en el esquema sin quitarle nada a nadie.
|
|
122
|
+
//
|
|
123
|
+
// `strict` desactiva ese respaldo. La diferencia es QUIÉN puede
|
|
124
|
+
// corregir la adivinanza: una tool responde a un agente que ve el
|
|
125
|
+
// nombre del proyecto en la respuesta y rectifica; sync escribe
|
|
126
|
+
// CLAUDE.md en silencio, y si adivina mal, un repo sin atlas hereda el
|
|
127
|
+
// mapa de OTRO proyecto — el invariante que la frontera por proyecto
|
|
128
|
+
// prohíbe ("el mapa debe salir VACÍO, jamás el de otro").
|
|
129
|
+
if (opts?.strict) {
|
|
130
|
+
throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
|
|
131
|
+
}
|
|
132
|
+
const todos = await this.rest(`projects?select=id,name,slug&order=created_at.asc&limit=20`);
|
|
133
|
+
if (todos.length === 1)
|
|
134
|
+
return `&project_id=eq.${todos[0].id}`;
|
|
135
|
+
// Espejo del scopeNotice hospedado (mcp/scope.ts): el error LISTA los
|
|
136
|
+
// proyectos válidos. Sin la lista, el agente que recibe este 404 tiene
|
|
137
|
+
// que gastar otra petición entera (o preguntar al usuario) solo para
|
|
138
|
+
// saber qué nombres existen — la ronda perdida que 0.4.2 eliminaba.
|
|
139
|
+
const names = todos.map((p) => p.name ?? p.slug ?? p.id).join(', ');
|
|
140
|
+
throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name). ` +
|
|
141
|
+
`Projects: ${names}. Pass project with the name or slug of the repo you are working in.`, 404);
|
|
115
142
|
}
|
|
116
143
|
return `&project_id=eq.${rows[0].id}`;
|
|
117
144
|
}
|
|
@@ -151,11 +178,11 @@ export class Supabase {
|
|
|
151
178
|
}
|
|
152
179
|
rpcOnce(fn, args) {
|
|
153
180
|
return fetch(`${this.url}/rest/v1/rpc/${fn}`, {
|
|
154
|
-
method:
|
|
181
|
+
method: 'POST',
|
|
155
182
|
headers: {
|
|
156
183
|
apikey: this.anonKey,
|
|
157
184
|
Authorization: `Bearer ${this.accessToken}`,
|
|
158
|
-
|
|
185
|
+
'Content-Type': 'application/json',
|
|
159
186
|
},
|
|
160
187
|
body: JSON.stringify(args),
|
|
161
188
|
signal: AbortSignal.timeout(15_000),
|
|
@@ -163,12 +190,12 @@ export class Supabase {
|
|
|
163
190
|
}
|
|
164
191
|
insertOnce(table, row) {
|
|
165
192
|
return fetch(`${this.url}/rest/v1/${table}`, {
|
|
166
|
-
method:
|
|
193
|
+
method: 'POST',
|
|
167
194
|
headers: {
|
|
168
195
|
apikey: this.anonKey,
|
|
169
196
|
Authorization: `Bearer ${this.accessToken}`,
|
|
170
|
-
|
|
171
|
-
Prefer:
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
Prefer: 'return=minimal',
|
|
172
199
|
},
|
|
173
200
|
body: JSON.stringify(row),
|
|
174
201
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -190,7 +217,7 @@ export class Supabase {
|
|
|
190
217
|
}
|
|
191
218
|
}
|
|
192
219
|
await fetch(`${this.url}/auth/v1/logout?scope=global`, {
|
|
193
|
-
method:
|
|
220
|
+
method: 'POST',
|
|
194
221
|
headers: {
|
|
195
222
|
apikey: this.anonKey,
|
|
196
223
|
Authorization: `Bearer ${this.accessToken}`,
|
|
@@ -223,7 +250,7 @@ export class Supabase {
|
|
|
223
250
|
headers: {
|
|
224
251
|
apikey: this.anonKey,
|
|
225
252
|
Authorization: `Bearer ${this.accessToken}`,
|
|
226
|
-
Accept:
|
|
253
|
+
Accept: 'application/json',
|
|
227
254
|
},
|
|
228
255
|
signal: AbortSignal.timeout(30_000),
|
|
229
256
|
});
|
|
@@ -285,8 +312,8 @@ export class Supabase {
|
|
|
285
312
|
}
|
|
286
313
|
refreshOnce(refreshToken) {
|
|
287
314
|
return fetch(`${this.url}/auth/v1/token?grant_type=refresh_token`, {
|
|
288
|
-
method:
|
|
289
|
-
headers: { apikey: this.anonKey,
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: { apikey: this.anonKey, 'Content-Type': 'application/json' },
|
|
290
317
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
291
318
|
signal: AbortSignal.timeout(30_000),
|
|
292
319
|
});
|
|
@@ -318,11 +345,11 @@ export class Supabase {
|
|
|
318
345
|
}
|
|
319
346
|
invokeOnce(name, payload) {
|
|
320
347
|
return fetch(`${this.url}/functions/v1/${name}`, {
|
|
321
|
-
method:
|
|
348
|
+
method: 'POST',
|
|
322
349
|
headers: {
|
|
323
350
|
apikey: this.anonKey,
|
|
324
351
|
Authorization: `Bearer ${this.accessToken}`,
|
|
325
|
-
|
|
352
|
+
'Content-Type': 'application/json',
|
|
326
353
|
},
|
|
327
354
|
body: JSON.stringify(payload),
|
|
328
355
|
signal: AbortSignal.timeout(120_000),
|
package/dist/sync.js
CHANGED
|
@@ -10,33 +10,42 @@
|
|
|
10
10
|
* The section lives between markers and is replaced in place; everything
|
|
11
11
|
* outside the markers is never touched.
|
|
12
12
|
*/
|
|
13
|
-
import { readFile, writeFile } from
|
|
14
|
-
import path from
|
|
13
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
15
|
/** The project identity of the synced repo (same rule as analyze/guard). */
|
|
16
16
|
function projectNameFor(targetDir) {
|
|
17
17
|
return (process.env.CHANGEBOOK_PROJECT?.trim() ||
|
|
18
18
|
path.basename(path.resolve(targetDir)));
|
|
19
19
|
}
|
|
20
|
-
const START =
|
|
21
|
-
const END =
|
|
20
|
+
const START = '<!-- changebook:start -->';
|
|
21
|
+
const END = '<!-- changebook:end -->';
|
|
22
22
|
// DB text (notes, alert bodies, business impact) is AI-written and gets embedded
|
|
23
23
|
// between the markers that upsertSection splices on. If any of it contained a
|
|
24
24
|
// literal marker, the next sync would splice at the wrong offset and corrupt the
|
|
25
25
|
// file injected into every agent session. Break the HTML-comment opener so no
|
|
26
26
|
// exact marker can be forged; both markers begin with "<!--".
|
|
27
27
|
function sanitizeCell(text) {
|
|
28
|
-
return text.replace(/<!--/g,
|
|
28
|
+
return text.replace(/<!--/g, '<!- -');
|
|
29
29
|
}
|
|
30
30
|
const MAX_MODULES = 15;
|
|
31
31
|
const MAX_CHANGES = 5;
|
|
32
32
|
const MAX_COUPLINGS = 5;
|
|
33
|
-
// El bloque entra en CADA sesión de agente del usuario,
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
33
|
+
// El bloque entra en CADA sesión de agente del usuario, y ademas se reenvia al
|
|
34
|
+
// modelo en CADA peticion de esa sesion: es coste FIJO, se consulte el atlas o
|
|
35
|
+
// no. Por eso tiene presupuesto y nunca crece sin control.
|
|
36
|
+
//
|
|
37
|
+
// Historia de este numero, que es una leccion:
|
|
38
|
+
// 2.000 -> 3.000 el 2026-07-18, porque la cabecera habia engordado y
|
|
39
|
+
// expulsaba Módulos y Encargos. Se subio el techo en vez de
|
|
40
|
+
// adelgazar la prosa.
|
|
41
|
+
// 3.000 -> 2.000 el 2026-07-19, al medir que NO funciono: la cabecera crecio
|
|
42
|
+
// hasta 1.109 chars (46% del presupuesto) y la seccion "### Módulos"
|
|
43
|
+
// seguia expulsada. El bloque se llamaba "Mapa del producto" y salia
|
|
44
|
+
// sin mapa, en el commit mismo que subio el techo para evitarlo.
|
|
45
|
+
//
|
|
46
|
+
// Subir el techo alimenta al que se lo come. Adelgazada la cabecera a 271
|
|
47
|
+
// chars, con 2.000 cabe mas contenido REAL que antes con 3.000.
|
|
48
|
+
const SYNC_BUDGET_CHARS = 2_000;
|
|
40
49
|
// Co-change pair thresholds — same spirit as the web's signals: at least 3
|
|
41
50
|
// shared analyses and a ≥60% rate before we call it a dependency.
|
|
42
51
|
const MIN_PAIR_COUNT = 3;
|
|
@@ -53,16 +62,19 @@ export async function syncContextFiles(db, targetDir) {
|
|
|
53
62
|
let projectFilter;
|
|
54
63
|
let projectResolved = true;
|
|
55
64
|
try {
|
|
56
|
-
|
|
65
|
+
// strict: el respaldo de proyecto único (0.4.2) es para tools que
|
|
66
|
+
// conversan con un agente; sync escribe este archivo en silencio y no
|
|
67
|
+
// puede adivinar — si el nombre no casa, mapa vacío y punto.
|
|
68
|
+
projectFilter = await db.projectFilterFor(projectName, { strict: true });
|
|
57
69
|
}
|
|
58
70
|
catch {
|
|
59
|
-
projectFilter =
|
|
71
|
+
projectFilter = '&project_id=eq.00000000-0000-0000-0000-000000000000';
|
|
60
72
|
projectResolved = false;
|
|
61
73
|
}
|
|
62
74
|
const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
63
75
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
64
76
|
const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
|
|
65
|
-
db.rest(
|
|
77
|
+
db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
|
|
66
78
|
projectFilter),
|
|
67
79
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
68
80
|
projectFilter),
|
|
@@ -81,15 +93,15 @@ export async function syncContextFiles(db, targetDir) {
|
|
|
81
93
|
// Best-effort como las alertas.
|
|
82
94
|
projectResolved && projectId
|
|
83
95
|
? db
|
|
84
|
-
.callRpc(
|
|
96
|
+
.callRpc('list_agent_tasks', {
|
|
85
97
|
p_project_id: projectId,
|
|
86
98
|
})
|
|
87
|
-
.then((rows) => rows.filter((r) => r.status ===
|
|
99
|
+
.then((rows) => rows.filter((r) => r.status === 'pending'))
|
|
88
100
|
.catch(() => [])
|
|
89
101
|
: Promise.resolve([]),
|
|
90
102
|
]);
|
|
91
103
|
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
|
|
92
|
-
for (const name of [
|
|
104
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
93
105
|
const file = path.join(targetDir, name);
|
|
94
106
|
const updated = await upsertSection(file, section);
|
|
95
107
|
console.error(`${updated} ${name}`);
|
|
@@ -99,10 +111,10 @@ export async function syncContextFiles(db, targetDir) {
|
|
|
99
111
|
// pagar tool calls. Cuenta como lectura (best-effort).
|
|
100
112
|
if (projectResolved && projectId) {
|
|
101
113
|
await db
|
|
102
|
-
.insertRow(
|
|
114
|
+
.insertRow('atlas_reads', {
|
|
103
115
|
project_id: projectId,
|
|
104
|
-
tool:
|
|
105
|
-
source:
|
|
116
|
+
tool: 'sync_context_files',
|
|
117
|
+
source: 'sync',
|
|
106
118
|
chars_served: section.length,
|
|
107
119
|
})
|
|
108
120
|
.catch(() => { });
|
|
@@ -126,51 +138,55 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
126
138
|
// del usuario y una fecha que cambia a diario invalidaría el prefijo de
|
|
127
139
|
// prompt-cache de TODO el archivo en cada sesión del agente. Las fechas
|
|
128
140
|
// útiles ya van por entrada (regresiones, últimos cambios).
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
""
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
//
|
|
141
|
+
'## Mapa del producto (ChangeBook · auto-generado)',
|
|
142
|
+
'',
|
|
143
|
+
// Esta cabecera se paga en CADA peticion de CADA sesion, se consulte el
|
|
144
|
+
// atlas o no, y ademas se reenvia al modelo cada vez. Medido el
|
|
145
|
+
// 2026-07-19: ocupaba 1.109 chars de instrucciones sobre un presupuesto de
|
|
146
|
+
// 3.000, o sea el 46%, y por eso la seccion "### Módulos" quedaba
|
|
147
|
+
// EXPULSADA: el bloque se llamaba "Mapa del producto" y salia sin mapa.
|
|
148
|
+
//
|
|
149
|
+
// Las seis reglas siguen estando —cada una se gano con un incidente— pero
|
|
150
|
+
// dichas una vez y en corto. Lo que se quita es la explicacion, no la
|
|
151
|
+
// instruccion: un agente no necesita que le argumenten por que.
|
|
152
|
+
// · orientarse por el brief en una llamada
|
|
153
|
+
// · file_context antes de tocar un archivo
|
|
154
|
+
// · registrar tras cada commit, con summary propio
|
|
155
|
+
// · frontera por proyecto (QA 2026-07-18: se mezclaban proyectos)
|
|
156
|
+
// · anunciar antes de atacar un encargo
|
|
157
|
+
// · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
|
|
158
|
+
// agente lo uso y siguio callado; desde fuera eso es no hacer nada)
|
|
159
|
+
'Memoria del proyecto en ChangeBook. Oriéntate con UNA llamada a `atlas_project_brief`. Antes de tocar un archivo: `atlas_file_context` con su ruta. Tras cada commit: `atlas_record_change` con el diff, `commit_hash`, `committed_at` y un `summary` tuyo (abarata el análisis; reenviar lo ya registrado es gratis).',
|
|
160
|
+
'',
|
|
145
161
|
...(projectName
|
|
146
162
|
? [
|
|
147
|
-
`
|
|
148
|
-
|
|
163
|
+
`Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
|
|
164
|
+
'',
|
|
149
165
|
]
|
|
150
166
|
: []),
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// disponible insinúa (lección 2026-07-17: un agente con la tool conectada
|
|
154
|
-
// hizo 3 PRs y no registró ninguno hasta que se lo pidieron).
|
|
155
|
-
"Tras cada commit, registra el cambio con la tool MCP `atlas_record_change`: pásale el diff (`git show HEAD`), `commit_hash`, `committed_at` y SIEMPRE `summary` (2-5 frases tuyas sobre qué cambió y por qué — abarata mucho el análisis). Reenviar un commit ya registrado es un no-op gratuito.",
|
|
156
|
-
"",
|
|
167
|
+
'Habla: anuncia en 2-3 líneas qué vas a hacer antes de atacar un encargo, y cuéntale al usuario cualquier riesgo que el atlas te enseñe — él no ve esos avisos.',
|
|
168
|
+
'',
|
|
157
169
|
];
|
|
158
170
|
if (modules.length === 0) {
|
|
159
171
|
return [
|
|
160
172
|
...head,
|
|
161
|
-
|
|
173
|
+
'_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
|
|
162
174
|
END,
|
|
163
|
-
].join(
|
|
175
|
+
].join('\n');
|
|
164
176
|
}
|
|
177
|
+
// Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
|
|
178
|
+
// nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
|
|
179
|
+
// DOS. Un mapa de dos modulos sobre 46 no es un mapa, es una anecdota.
|
|
180
|
+
//
|
|
181
|
+
// Aqui el mapa solo tiene que decir QUE existe y QUE quema; el detalle
|
|
182
|
+
// (ficheros, notas, historia) esta a una llamada de `atlas_module_detail` y
|
|
183
|
+
// ahi se paga solo cuando hace falta, en vez de en cada peticion de todas
|
|
184
|
+
// las sesiones. El riesgo se marca solo cuando NO es bajo: "riesgo low"
|
|
185
|
+
// repetido cuarenta veces es ruido que se paga igual que la senal.
|
|
165
186
|
const moduleLines = modules.map((m) => {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const meta = [m.domain, m.risk && `riesgo ${m.risk}`]
|
|
170
|
-
.filter(Boolean)
|
|
171
|
-
.join(" · ");
|
|
172
|
-
const note = sanitizeCell((m.note ?? "").slice(0, 110));
|
|
173
|
-
return `- **${m.module}**${meta ? ` (${meta})` : ""}${files ? ` — ${files}` : ""}${note ? ` — ${note}` : ""}`;
|
|
187
|
+
const riesgo = m.risk && m.risk !== 'low' ? ` ⚠ ${m.risk}` : '';
|
|
188
|
+
const area = m.domain ? ` · ${m.domain}` : '';
|
|
189
|
+
return `- **${m.module}**${area}${riesgo}`;
|
|
174
190
|
});
|
|
175
191
|
// Prevention beats detection: give the agent the co-change dependencies
|
|
176
192
|
// and open warnings BEFORE it edits, not after something breaks.
|
|
@@ -178,25 +194,29 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
178
194
|
// AI-detected regressions from the latest analyses: the most urgent thing
|
|
179
195
|
// an agent can know before editing — a change already broke a coupling.
|
|
180
196
|
const alertLines = alerts
|
|
181
|
-
.filter((a) => (a.plain ??
|
|
197
|
+
.filter((a) => (a.plain ?? '').trim())
|
|
182
198
|
.map((a) => {
|
|
183
|
-
const mod = (a.module ??
|
|
184
|
-
|
|
199
|
+
const mod = (a.module ?? '').trim();
|
|
200
|
+
// 130 y no 200: en un bloque de coste FIJO el aviso es un titular, no
|
|
201
|
+
// un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
|
|
202
|
+
// y expulsaban el mapa. El texto entero sigue a una llamada de
|
|
203
|
+
// `atlas_project_brief`, donde se paga solo si alguien pregunta.
|
|
204
|
+
return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? '').slice(0, 130))}`;
|
|
185
205
|
});
|
|
186
206
|
const hotspotLines = modules
|
|
187
|
-
.filter((m) => m.risk ===
|
|
207
|
+
.filter((m) => m.risk === 'hotspot')
|
|
188
208
|
.slice(0, 5)
|
|
189
209
|
.map((m) => {
|
|
190
|
-
const note = sanitizeCell((m.note ??
|
|
191
|
-
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` :
|
|
210
|
+
const note = sanitizeCell((m.note ?? '').slice(0, 110));
|
|
211
|
+
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
|
|
192
212
|
});
|
|
193
|
-
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ??
|
|
213
|
+
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
|
|
194
214
|
// Auto-remediación fase 2: la cola entra en cada sesión para que el agente
|
|
195
215
|
// se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
|
|
196
216
|
// nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
|
|
197
217
|
// llevar la misma señal encolada varias veces).
|
|
198
218
|
const taskTitles = [
|
|
199
|
-
...new Set(pendingTasks.map((t) => (t.title ??
|
|
219
|
+
...new Set(pendingTasks.map((t) => (t.title ?? '').trim()).filter(Boolean)),
|
|
200
220
|
].slice(0, 3);
|
|
201
221
|
const taskLines = taskTitles.length > 0
|
|
202
222
|
? [
|
|
@@ -208,39 +228,39 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
208
228
|
// (utilidad para el agente) son independientes: si no cabe todo, caen
|
|
209
229
|
// primero los últimos cambios y los módulos, nunca las regresiones.
|
|
210
230
|
const sections = [
|
|
211
|
-
{ key:
|
|
231
|
+
{ key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
|
|
212
232
|
{
|
|
213
|
-
key:
|
|
233
|
+
key: 'tasks',
|
|
214
234
|
priority: 1,
|
|
215
|
-
title:
|
|
235
|
+
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
216
236
|
lines: taskLines,
|
|
217
237
|
},
|
|
218
238
|
{
|
|
219
|
-
key:
|
|
220
|
-
priority:
|
|
221
|
-
title:
|
|
239
|
+
key: 'couplings',
|
|
240
|
+
priority: 3,
|
|
241
|
+
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
222
242
|
lines: couplingLines,
|
|
223
243
|
},
|
|
224
244
|
{
|
|
225
|
-
key:
|
|
245
|
+
key: 'alerts',
|
|
226
246
|
priority: 0,
|
|
227
|
-
title:
|
|
247
|
+
title: '### Regresiones detectadas (resolver o verificar YA)',
|
|
228
248
|
lines: alertLines,
|
|
229
249
|
},
|
|
230
250
|
{
|
|
231
|
-
key:
|
|
232
|
-
priority:
|
|
233
|
-
title:
|
|
251
|
+
key: 'hotspots',
|
|
252
|
+
priority: 4,
|
|
253
|
+
title: '### Avisos abiertos (revisar antes de modificar)',
|
|
234
254
|
lines: hotspotLines,
|
|
235
255
|
},
|
|
236
256
|
{
|
|
237
|
-
key:
|
|
257
|
+
key: 'changes',
|
|
238
258
|
priority: 5,
|
|
239
|
-
title:
|
|
259
|
+
title: '### Últimos cambios',
|
|
240
260
|
lines: changeLines,
|
|
241
261
|
},
|
|
242
262
|
];
|
|
243
|
-
let budget = SYNC_BUDGET_CHARS - head.join(
|
|
263
|
+
let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
|
|
244
264
|
const includedCount = new Map();
|
|
245
265
|
for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
|
|
246
266
|
if (s.lines.length === 0)
|
|
@@ -265,12 +285,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
265
285
|
if (count === 0)
|
|
266
286
|
continue;
|
|
267
287
|
if (!first)
|
|
268
|
-
lines.push(
|
|
288
|
+
lines.push('');
|
|
269
289
|
lines.push(s.title, ...s.lines.slice(0, count));
|
|
270
290
|
first = false;
|
|
271
291
|
}
|
|
272
292
|
lines.push(END);
|
|
273
|
-
return lines.join(
|
|
293
|
+
return lines.join('\n');
|
|
274
294
|
}
|
|
275
295
|
/**
|
|
276
296
|
* Symmetric co-change pairs: modules that appear in the same analyses often
|
|
@@ -279,11 +299,15 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
279
299
|
*/
|
|
280
300
|
// Module labels contain spaces, so pair keys use a separator that cannot
|
|
281
301
|
// appear in a label.
|
|
282
|
-
const PAIR_SEP =
|
|
283
|
-
|
|
302
|
+
const PAIR_SEP = '\u0000';
|
|
303
|
+
// Exportada para el test de paridad con la copia del brief hospedado
|
|
304
|
+
// (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
|
|
305
|
+
// mismo repo enseñaría acoplamientos distintos según por dónde entre el
|
|
306
|
+
// agente — y la deriva sería silenciosa.
|
|
307
|
+
export function coChangePairs(rows) {
|
|
284
308
|
const byAnalysis = new Map();
|
|
285
309
|
for (const r of rows) {
|
|
286
|
-
const label = (r.module ??
|
|
310
|
+
const label = (r.module ?? '').trim();
|
|
287
311
|
if (!label)
|
|
288
312
|
continue;
|
|
289
313
|
let set = byAnalysis.get(r.changelog_id);
|
|
@@ -321,25 +345,25 @@ function coChangePairs(rows) {
|
|
|
321
345
|
export async function upsertSection(file, section) {
|
|
322
346
|
let content = null;
|
|
323
347
|
try {
|
|
324
|
-
content = await readFile(file,
|
|
348
|
+
content = await readFile(file, 'utf8');
|
|
325
349
|
}
|
|
326
350
|
catch {
|
|
327
351
|
content = null;
|
|
328
352
|
}
|
|
329
353
|
if (content === null) {
|
|
330
|
-
await writeFile(file, section +
|
|
331
|
-
return
|
|
354
|
+
await writeFile(file, section + '\n');
|
|
355
|
+
return 'created';
|
|
332
356
|
}
|
|
333
357
|
// Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
|
|
334
358
|
// el bloque con los marcadores antiguos, elimínalo antes de upsertar el
|
|
335
359
|
// nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
|
|
336
|
-
const LEGACY_START =
|
|
337
|
-
const LEGACY_END =
|
|
360
|
+
const LEGACY_START = '<!-- appatlas:start -->';
|
|
361
|
+
const LEGACY_END = '<!-- appatlas:end -->';
|
|
338
362
|
const legacyStart = content.indexOf(LEGACY_START);
|
|
339
363
|
const legacyEnd = content.indexOf(LEGACY_END);
|
|
340
364
|
if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
|
|
341
365
|
content = (content.slice(0, legacyStart) +
|
|
342
|
-
content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g,
|
|
366
|
+
content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, '\n\n');
|
|
343
367
|
await writeFile(file, content);
|
|
344
368
|
}
|
|
345
369
|
const start = content.indexOf(START);
|
|
@@ -349,12 +373,12 @@ export async function upsertSection(file, section) {
|
|
|
349
373
|
// Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
|
|
350
374
|
// actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
|
|
351
375
|
if (next === content)
|
|
352
|
-
return
|
|
376
|
+
return 'unchanged';
|
|
353
377
|
await writeFile(file, next);
|
|
354
|
-
return
|
|
378
|
+
return 'updated';
|
|
355
379
|
}
|
|
356
|
-
const separator = content.endsWith(
|
|
357
|
-
await writeFile(file, content + separator + section +
|
|
358
|
-
return
|
|
380
|
+
const separator = content.endsWith('\n') ? '\n' : '\n\n';
|
|
381
|
+
await writeFile(file, content + separator + section + '\n');
|
|
382
|
+
return 'appended';
|
|
359
383
|
}
|
|
360
384
|
//# sourceMappingURL=sync.js.map
|
package/dist/tools.js
CHANGED
|
@@ -4,10 +4,35 @@
|
|
|
4
4
|
* All tools are read-only queries over the user's own ChangeBook data
|
|
5
5
|
* (Row Level Security scopes every request to the signed-in user).
|
|
6
6
|
*/
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import { z } from "zod";
|
|
9
|
+
import { execFileAsync } from "./git.js";
|
|
10
|
+
import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
|
|
8
11
|
import { SupabaseError } from "./supabase.js";
|
|
9
12
|
const CHARACTER_LIMIT = 25_000;
|
|
13
|
+
/**
|
|
14
|
+
* El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
|
|
15
|
+
* agente afirmó un valor revertido porque la nota hablaba en presente). El
|
|
16
|
+
* ancla de deriva de derivaContraHead lo dice con precisión cuando hay árbol
|
|
17
|
+
* y hash; esta línea fija cubre el resto de los casos — nunca las dos a la
|
|
18
|
+
* vez.
|
|
19
|
+
*/
|
|
20
|
+
const TEMPORAL_CONTRACT = "Notes describe the code AS OF their date — concrete values (numbers, limits, names) may have changed since. Verify in the code before asserting them.";
|
|
10
21
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Lo que de VERDAD llega al modelo. Espejo de servedCharsOf en
|
|
24
|
+
* supabase/functions/mcp/index.ts.
|
|
25
|
+
*
|
|
26
|
+
* `chars_served` contaba el markdown, y el markdown casi nunca se usa: los
|
|
27
|
+
* tool_result llegan al modelo como el JSON del structuredContent. Solo cuando
|
|
28
|
+
* la respuesta revienta CHARACTER_LIMIT deja de haber JSON y queda el texto.
|
|
29
|
+
* Contar el markdown infravaloraba el gasto un 35%.
|
|
30
|
+
*/
|
|
31
|
+
function servedCharsOf(result) {
|
|
32
|
+
return "structuredContent" in result && result.structuredContent
|
|
33
|
+
? JSON.stringify(result.structuredContent).length
|
|
34
|
+
: (result.content?.[0]?.text?.length ?? 0);
|
|
35
|
+
}
|
|
11
36
|
function errorResult(error) {
|
|
12
37
|
const message = error instanceof SupabaseError || error instanceof Error
|
|
13
38
|
? error.message
|
|
@@ -89,6 +114,47 @@ function recordRead(db, tool, projectFilter, charsServed) {
|
|
|
89
114
|
function ilikePattern(search) {
|
|
90
115
|
return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
|
|
91
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* ¿El árbol de trabajo donde corre este servidor ES el proyecto consultado?
|
|
119
|
+
*
|
|
120
|
+
* Solo entonces un grep local puede refutar una alerta: la tool recibe
|
|
121
|
+
* `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
|
|
122
|
+
* En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
|
|
123
|
+
* avisoRefutado con expect='present' lo tomaría como refutación — silenciar
|
|
124
|
+
* una alerta verdadera por mirar donde no era es peor que el ruido. Misma
|
|
125
|
+
* identidad que usa el guardián: CHANGEBOOK_PROJECT o el basename del
|
|
126
|
+
* directorio, pasados por la slugify del servidor.
|
|
127
|
+
*/
|
|
128
|
+
function cwdEsElProyecto(project) {
|
|
129
|
+
const candidato = process.env.CHANGEBOOK_PROJECT?.trim() ||
|
|
130
|
+
path.basename(path.resolve(process.cwd()));
|
|
131
|
+
return slugifyProject(candidato) === slugifyProject(project.trim());
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* "Atlas current up to commit X — your HEAD is N ahead": la señal MECÁNICA de
|
|
135
|
+
* que la respuesta es memoria, no estado (benchmark 2026-07-20: el agente
|
|
136
|
+
* repitió un valor revertido porque nada le decía hasta qué commit llegaba lo
|
|
137
|
+
* que leía). Fail-open total: sin hash, con hash desconocido (clon shallow,
|
|
138
|
+
* historia sin fetch) o sin repo, no hay línea — el ancla jamás rompe la
|
|
139
|
+
* lectura que ancla.
|
|
140
|
+
*/
|
|
141
|
+
export async function derivaContraHead(dir, hash) {
|
|
142
|
+
if (!hash || !/^[0-9a-f]{7,64}$/i.test(hash))
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const { stdout } = await execFileAsync("git", ["rev-list", "--count", `${hash}..HEAD`], { cwd: dir, timeout: 2_000 });
|
|
146
|
+
const n = Number(stdout.trim());
|
|
147
|
+
if (!Number.isFinite(n))
|
|
148
|
+
return null;
|
|
149
|
+
const corto = hash.slice(0, 7);
|
|
150
|
+
return n === 0
|
|
151
|
+
? `Atlas current up to commit ${corto} — matches your HEAD.`
|
|
152
|
+
: `Atlas current up to commit ${corto} — your HEAD is ${n} commit(s) ahead; later changes are NOT reflected here. Verify current values in the code before asserting them.`;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
92
158
|
// ── Registration ──────────────────────────────────────────────────────────────
|
|
93
159
|
export function registerTools(server, db) {
|
|
94
160
|
server.registerTool("atlas_recent_changes", {
|
|
@@ -103,7 +169,7 @@ Args:
|
|
|
103
169
|
- search (optional): case-insensitive text filter over the business and technical summaries.
|
|
104
170
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
105
171
|
|
|
106
|
-
Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact,
|
|
172
|
+
Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
|
|
107
173
|
|
|
108
174
|
Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
109
175
|
inputSchema: {
|
|
@@ -113,8 +179,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
113
179
|
.describe("Pagination offset"),
|
|
114
180
|
search: z.string().min(2).max(120).optional()
|
|
115
181
|
.describe("Case-insensitive filter over summaries"),
|
|
116
|
-
project: z.string().min(1).max(120)
|
|
182
|
+
project: z.string().min(1).max(120)
|
|
117
183
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
184
|
+
include_tech: z.boolean().default(false),
|
|
118
185
|
},
|
|
119
186
|
annotations: {
|
|
120
187
|
readOnlyHint: true,
|
|
@@ -122,7 +189,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
122
189
|
idempotentHint: true,
|
|
123
190
|
openWorldHint: true,
|
|
124
191
|
},
|
|
125
|
-
}, async ({ limit, offset, search, project }) => {
|
|
192
|
+
}, async ({ limit, offset, search, project, include_tech }) => {
|
|
126
193
|
try {
|
|
127
194
|
const pf = await db.projectFilterFor(project);
|
|
128
195
|
let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
|
|
@@ -152,7 +219,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
152
219
|
id: r.id,
|
|
153
220
|
date: day(r.created_at),
|
|
154
221
|
business_impact: r.business_impact ?? "",
|
|
155
|
-
|
|
222
|
+
// Espejo de mcp/index.ts: `summary_tech` es el campo mas pesado y
|
|
223
|
+
// el que menos usa un agente que se esta orientando. Fuera por
|
|
224
|
+
// defecto; dentro si lo pide o si hay `search`, porque la busqueda
|
|
225
|
+
// mira ese campo en el servidor y sin verlo el agente no sabria por
|
|
226
|
+
// que casaron los resultados.
|
|
227
|
+
...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
|
|
156
228
|
diff_chars: r.diff_character_count ?? null,
|
|
157
229
|
modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
|
|
158
230
|
module: m.module,
|
|
@@ -171,7 +243,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
171
243
|
.map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
|
|
172
244
|
.join(", ");
|
|
173
245
|
lines.push(`## ${c.date} — ${c.business_impact}`);
|
|
174
|
-
if (c.summary_tech)
|
|
246
|
+
if ((include_tech || search) && c.summary_tech)
|
|
175
247
|
lines.push(`- Tech: ${c.summary_tech}`);
|
|
176
248
|
if (mods)
|
|
177
249
|
lines.push(`- Modules: ${mods}`);
|
|
@@ -183,8 +255,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
183
255
|
: "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
|
|
184
256
|
}
|
|
185
257
|
const changesText = lines.join("\n");
|
|
186
|
-
|
|
187
|
-
|
|
258
|
+
const salida = toolResult(changesText, output);
|
|
259
|
+
recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
|
|
260
|
+
return salida;
|
|
188
261
|
}
|
|
189
262
|
catch (error) {
|
|
190
263
|
return errorResult(error);
|
|
@@ -194,17 +267,17 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
194
267
|
title: "ChangeBook module map",
|
|
195
268
|
description: `List the modules of the product as known by ChangeBook, aggregated from the change history.
|
|
196
269
|
|
|
197
|
-
For each module: domain,
|
|
270
|
+
For each module: domain, latest risk level, number of analyzed changes and last-change date. Files, notes and diffs live in atlas_module_detail — this is the map, not the terrain.
|
|
198
271
|
|
|
199
272
|
Args:
|
|
200
273
|
- domain (optional): filter by domain (e.g. "billing").
|
|
201
274
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
202
275
|
|
|
203
|
-
Returns (structured): { count, modules: [{ module, domain,
|
|
276
|
+
Returns (structured): { count, modules: [{ module, domain, risk, changes, last_changed }] }`,
|
|
204
277
|
inputSchema: {
|
|
205
278
|
domain: z.string().min(1).max(80).optional()
|
|
206
279
|
.describe("Only modules in this domain"),
|
|
207
|
-
project: z.string().min(1).max(120)
|
|
280
|
+
project: z.string().min(1).max(120)
|
|
208
281
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
209
282
|
},
|
|
210
283
|
annotations: {
|
|
@@ -219,7 +292,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
219
292
|
let query =
|
|
220
293
|
// The aggregation below uses only these columns; note/tech/excerpt
|
|
221
294
|
// (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
|
|
222
|
-
`change_module?select=module,domain,
|
|
295
|
+
`change_module?select=module,domain,risk,created_at` +
|
|
223
296
|
`&order=created_at.desc&limit=1000` +
|
|
224
297
|
pf;
|
|
225
298
|
if (domain)
|
|
@@ -238,11 +311,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
238
311
|
return {
|
|
239
312
|
module: name,
|
|
240
313
|
domain: latest.domain,
|
|
241
|
-
category: latest.category,
|
|
242
314
|
risk: latest.risk,
|
|
243
315
|
changes: list.length,
|
|
244
316
|
last_changed: day(latest.created_at),
|
|
245
|
-
files: fileList(latest.files),
|
|
246
317
|
};
|
|
247
318
|
});
|
|
248
319
|
modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
|
|
@@ -251,8 +322,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
251
322
|
for (const m of modules) {
|
|
252
323
|
lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
|
|
253
324
|
`${m.changes} change(s), last ${m.last_changed}` +
|
|
254
|
-
(m.risk ? `, risk: ${m.risk}` : "")
|
|
255
|
-
(m.files.length ? ` — files: ${m.files.join(", ")}` : ""));
|
|
325
|
+
(m.risk ? `, risk: ${m.risk}` : ""));
|
|
256
326
|
}
|
|
257
327
|
if (modules.length === 0) {
|
|
258
328
|
lines.push(domain
|
|
@@ -260,8 +330,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
260
330
|
: "No modules yet. Analyze a diff from the ChangeBook extension first.");
|
|
261
331
|
}
|
|
262
332
|
const modulesText = lines.join("\n");
|
|
263
|
-
|
|
264
|
-
|
|
333
|
+
const salida = toolResult(modulesText, output);
|
|
334
|
+
recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
|
|
335
|
+
return salida;
|
|
265
336
|
}
|
|
266
337
|
catch (error) {
|
|
267
338
|
return errorResult(error);
|
|
@@ -280,7 +351,7 @@ Args:
|
|
|
280
351
|
- full (default false): return the diff excerpts verbatim instead of the token-saving previews. Only pass it when you actually need the code lines.
|
|
281
352
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
282
353
|
|
|
283
|
-
Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }
|
|
354
|
+
Returns (structured): { module, count, changes: [{ date, commit, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }. Notes describe the code AS OF their commit/date — verify current values in the code before asserting them.`,
|
|
284
355
|
inputSchema: {
|
|
285
356
|
module: z.string().min(1).max(120)
|
|
286
357
|
.describe("Exact module name (see atlas_modules)"),
|
|
@@ -290,7 +361,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
290
361
|
.describe("Include diff excerpts (previews unless full)"),
|
|
291
362
|
full: z.boolean().default(false)
|
|
292
363
|
.describe("Verbatim excerpts instead of short previews"),
|
|
293
|
-
project: z.string().min(1).max(120)
|
|
364
|
+
project: z.string().min(1).max(120)
|
|
294
365
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
295
366
|
},
|
|
296
367
|
annotations: {
|
|
@@ -316,7 +387,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
316
387
|
};
|
|
317
388
|
}
|
|
318
389
|
const ids = [...new Set(rows.map((r) => r.changelog_id))].join(",");
|
|
319
|
-
const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count&id=in.(${ids})`);
|
|
390
|
+
const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash&id=in.(${ids})`);
|
|
320
391
|
const logById = new Map(logs.map((l) => [l.id, l]));
|
|
321
392
|
const changes = rows.map((r) => {
|
|
322
393
|
let excerpt = null;
|
|
@@ -333,6 +404,9 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
333
404
|
}
|
|
334
405
|
return {
|
|
335
406
|
date: day(r.created_at),
|
|
407
|
+
// El commit del que salió cada entrada: la nota deja de flotar en
|
|
408
|
+
// el tiempo y se puede cuadrar contra git.
|
|
409
|
+
commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
|
|
336
410
|
risk: r.risk,
|
|
337
411
|
note: r.note,
|
|
338
412
|
tech: r.tech,
|
|
@@ -342,6 +416,14 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
342
416
|
excerpt_truncated: excerptTruncated,
|
|
343
417
|
};
|
|
344
418
|
});
|
|
419
|
+
// Ancla temporal de la respuesta entera: el commit más nuevo servido,
|
|
420
|
+
// comparado con el HEAD del árbol — solo si este árbol ES el proyecto
|
|
421
|
+
// (misma puerta que la refutación de alertas).
|
|
422
|
+
const ancla = cwdEsElProyecto(project)
|
|
423
|
+
? await derivaContraHead(process.cwd(), rows
|
|
424
|
+
.map((r) => logById.get(r.changelog_id)?.commit_hash)
|
|
425
|
+
.find(Boolean) ?? null)
|
|
426
|
+
: null;
|
|
345
427
|
const latest = rows[0];
|
|
346
428
|
const output = {
|
|
347
429
|
module,
|
|
@@ -354,10 +436,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
354
436
|
`# Module: ${module}` +
|
|
355
437
|
(latest.domain ? ` (${latest.domain}` +
|
|
356
438
|
(latest.category ? ` / ${latest.category}` : "") + ")" : ""),
|
|
357
|
-
"",
|
|
358
439
|
];
|
|
440
|
+
if (ancla)
|
|
441
|
+
lines.push(ancla);
|
|
442
|
+
lines.push("");
|
|
359
443
|
for (const c of changes) {
|
|
360
|
-
lines.push(`## ${c.date}${c.risk ? ` — risk: ${c.risk}` : ""}`);
|
|
444
|
+
lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
|
|
361
445
|
if (c.business_impact)
|
|
362
446
|
lines.push(`- Impact: ${c.business_impact}`);
|
|
363
447
|
if (c.note)
|
|
@@ -374,9 +458,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
374
458
|
}
|
|
375
459
|
lines.push("");
|
|
376
460
|
}
|
|
461
|
+
if (!ancla)
|
|
462
|
+
lines.push(TEMPORAL_CONTRACT, "");
|
|
377
463
|
const detailText = lines.join("\n");
|
|
378
|
-
|
|
379
|
-
|
|
464
|
+
const salida = toolResult(detailText, output);
|
|
465
|
+
recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
|
|
466
|
+
return salida;
|
|
380
467
|
}
|
|
381
468
|
catch (error) {
|
|
382
469
|
return errorResult(error);
|
|
@@ -396,7 +483,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
396
483
|
inputSchema: {
|
|
397
484
|
files: z.array(z.string().min(1).max(300)).min(1).max(8)
|
|
398
485
|
.describe("Repo-relative paths you are about to edit"),
|
|
399
|
-
project: z.string().min(1).max(120)
|
|
486
|
+
project: z.string().min(1).max(120)
|
|
400
487
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
401
488
|
},
|
|
402
489
|
annotations: {
|
|
@@ -436,17 +523,43 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
436
523
|
...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
|
|
437
524
|
];
|
|
438
525
|
const alerts = moduleNames.length
|
|
439
|
-
? await db.rest(`regression_alerts?select=module,plain&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
526
|
+
? await db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
440
527
|
pf)
|
|
441
528
|
: [];
|
|
529
|
+
// Refutación al servir (benchmark 2026-07-20): el mismo grep que el
|
|
530
|
+
// guardián corre en el pre-commit, pero aquí, en la consulta que el
|
|
531
|
+
// agente hace ANTES de editar — 4 de 7 alertas abiertas se caían con
|
|
532
|
+
// un grep (migración alert_evidence). Fail-open en todo: sin
|
|
533
|
+
// evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
|
|
534
|
+
// cwd no es el proyecto consultado, no se refuta nada (ver
|
|
535
|
+
// cwdEsElProyecto).
|
|
536
|
+
const refutada = cwdEsElProyecto(project)
|
|
537
|
+
? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
|
|
538
|
+
: () => false;
|
|
442
539
|
const alertsByModule = new Map();
|
|
443
540
|
for (const a of alerts) {
|
|
444
541
|
const m = (a.module ?? "").trim();
|
|
445
542
|
if (!m || !a.plain)
|
|
446
543
|
continue;
|
|
544
|
+
if (refutada(a))
|
|
545
|
+
continue;
|
|
447
546
|
alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
|
|
448
547
|
}
|
|
449
|
-
|
|
548
|
+
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
549
|
+
// de este árbol (misma puerta de proyecto que la refutación).
|
|
550
|
+
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
551
|
+
let ancla = null;
|
|
552
|
+
if (cwdEsElProyecto(project)) {
|
|
553
|
+
const ultimo = await db
|
|
554
|
+
.rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
|
|
555
|
+
pf)
|
|
556
|
+
.catch(() => []);
|
|
557
|
+
ancla = await derivaContraHead(process.cwd(), ultimo[0]?.commit_hash ?? null);
|
|
558
|
+
}
|
|
559
|
+
const lines = [`# File context (${paths.length} file(s))`];
|
|
560
|
+
if (ancla)
|
|
561
|
+
lines.push(ancla);
|
|
562
|
+
lines.push("");
|
|
450
563
|
for (const f of perFile) {
|
|
451
564
|
lines.push(`## ${f.file}`);
|
|
452
565
|
if (f.modules.length === 0) {
|
|
@@ -455,17 +568,22 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
455
568
|
for (const m of f.modules) {
|
|
456
569
|
lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
|
|
457
570
|
(m.risk ? `, risk: ${m.risk}` : ""));
|
|
458
|
-
if (m.last_note)
|
|
459
|
-
|
|
571
|
+
if (m.last_note) {
|
|
572
|
+
// Con fecha: una nota es una observación fechada, no estado.
|
|
573
|
+
lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
|
|
574
|
+
}
|
|
460
575
|
for (const plain of alertsByModule.get(m.module) ?? []) {
|
|
461
576
|
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
462
577
|
}
|
|
463
578
|
}
|
|
464
579
|
lines.push("");
|
|
465
580
|
}
|
|
581
|
+
if (!ancla)
|
|
582
|
+
lines.push(TEMPORAL_CONTRACT, "");
|
|
466
583
|
const contextText = lines.join("\n");
|
|
467
|
-
|
|
468
|
-
|
|
584
|
+
const salida = toolResult(contextText, { files: perFile });
|
|
585
|
+
recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
|
|
586
|
+
return salida;
|
|
469
587
|
}
|
|
470
588
|
catch (error) {
|
|
471
589
|
return errorResult(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.3",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.4.
|
|
18
|
+
"version": "0.4.3",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|