changebook 0.4.0 → 0.4.2
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/analyze.js +22 -4
- package/dist/git.js +34 -0
- package/dist/guard.js +138 -12
- package/dist/import.js +30 -7
- package/dist/supabase.js +11 -0
- package/dist/sync.js +55 -32
- package/dist/tools.js +44 -23
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/analyze.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
|
|
9
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
12
|
export async function analyze(db, options = {}) {
|
|
@@ -15,10 +15,16 @@ export async function analyze(db, options = {}) {
|
|
|
15
15
|
let rawDiff;
|
|
16
16
|
let commitHash;
|
|
17
17
|
let committedAt;
|
|
18
|
+
// El mensaje del commit como resumen: enruta al modelo pequeño en el servidor
|
|
19
|
+
// (~3 veces más barato). Este es el camino del hook post-commit, o sea el que
|
|
20
|
+
// dispara en CADA commit, así que es donde más veces se cobra la diferencia.
|
|
21
|
+
// Analizando cambios sin commitear no hay mensaje todavía: va sin resumen.
|
|
22
|
+
let agentSummary;
|
|
18
23
|
if (options.commit) {
|
|
19
24
|
const meta = await commitMeta(cwd, options.commit);
|
|
20
25
|
commitHash = meta.hash;
|
|
21
26
|
committedAt = meta.committedAt;
|
|
27
|
+
agentSummary = usableSummary(meta.message) ?? undefined;
|
|
22
28
|
rawDiff = await commitDiff(cwd, meta.hash);
|
|
23
29
|
if (!rawDiff.trim()) {
|
|
24
30
|
console.error(`Commit ${meta.hash.slice(0, 8)} has no analyzable diff (merge?). Skipped.`);
|
|
@@ -53,6 +59,7 @@ export async function analyze(db, options = {}) {
|
|
|
53
59
|
projectName,
|
|
54
60
|
commitHash,
|
|
55
61
|
committedAt,
|
|
62
|
+
agentSummary,
|
|
56
63
|
});
|
|
57
64
|
if (status !== 200 || body.error) {
|
|
58
65
|
// The server's messages are user-facing (quota, waitlist…): pass through.
|
|
@@ -94,11 +101,22 @@ async function commitMeta(cwd, ref) {
|
|
|
94
101
|
const { stdout } = await execFileAsync("git",
|
|
95
102
|
// --end-of-options so a user-supplied ref beginning with "-" is treated as
|
|
96
103
|
// a revision, not as a git option.
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
// %B (mensaje entero) va detrás de la fecha a propósito: es el único
|
|
105
|
+
// campo que puede llevar "|" y saltos de línea, así que se corta por los
|
|
106
|
+
// DOS primeros separadores y el resto se queda entero. Con -1 no hace
|
|
107
|
+
// falta -z: solo hay un registro.
|
|
108
|
+
["log", "-1", "--pretty=format:%H|%cI|%B", "--end-of-options", ref], { cwd, encoding: "utf8" });
|
|
109
|
+
const raw = stdout.trim();
|
|
110
|
+
const firstPipe = raw.indexOf("|");
|
|
111
|
+
const secondPipe = raw.indexOf("|", firstPipe + 1);
|
|
112
|
+
const hash = firstPipe < 0 ? raw : raw.slice(0, firstPipe);
|
|
99
113
|
if (!hash)
|
|
100
114
|
throw new Error("empty git log output");
|
|
101
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
hash,
|
|
117
|
+
committedAt: secondPipe < 0 ? raw.slice(firstPipe + 1) : raw.slice(firstPipe + 1, secondPipe),
|
|
118
|
+
message: secondPipe < 0 ? "" : raw.slice(secondPipe + 1),
|
|
119
|
+
};
|
|
102
120
|
}
|
|
103
121
|
catch (error) {
|
|
104
122
|
throw new Error(`Could not resolve commit "${ref}" in "${cwd}": ${gitErrorMessage(error)}`);
|
package/dist/git.js
CHANGED
|
@@ -11,6 +11,40 @@ export const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
|
11
11
|
// Mirror of the extension's default changebook.maxDiffCharacters (the server
|
|
12
12
|
// rejects anything above 60k anyway).
|
|
13
13
|
export const MAX_DIFF_CHARACTERS = 25_000;
|
|
14
|
+
// ── El mensaje de commit como resumen ────────────────────────────────────────
|
|
15
|
+
// El mensaje, cuando dice algo, ES el resumen del cambio: lo escribió quien lo
|
|
16
|
+
// hizo y explica la intención, que es la mitad cara del análisis y la única que
|
|
17
|
+
// el servidor no puede reconstruir del diff. Mandarlo hace que el servidor
|
|
18
|
+
// enrute al modelo pequeño: medido, $0,063 contra $0,021 = 3 veces menos.
|
|
19
|
+
//
|
|
20
|
+
// Vive aquí por lo mismo que commitDiff: lo usan los DOS subcomandos (`analyze`,
|
|
21
|
+
// que es el hook post-commit, e `import`, que es la historia), y tenerlo en uno
|
|
22
|
+
// solo es exactamente el fallo que dejó al otro pagando el modelo caro.
|
|
23
|
+
//
|
|
24
|
+
// Copia deliberada de la del servidor (_shared/github.ts) y de la de la
|
|
25
|
+
// extensión (src/util/summary.ts): los tres artefactos se publican por separado
|
|
26
|
+
// y tienen que ser autocontenidos, igual que canonicalDiffHash.
|
|
27
|
+
// test/summaryParity.test.ts fija que no se separen — si lo hacen no falla
|
|
28
|
+
// nada, solo cambia la factura.
|
|
29
|
+
//
|
|
30
|
+
// Los dos filtros: un mensaje pobre ("fix", "wip") NO se manda, porque el
|
|
31
|
+
// modelo pequeño se APOYA en el resumen y alimentarlo con ruido daría un
|
|
32
|
+
// análisis peor Y más barato; y el largo se recorta por línea entera, porque el
|
|
33
|
+
// servidor RECHAZA los que se pasan y un 400 no encarecería el análisis: lo
|
|
34
|
+
// dejaría sin hacer.
|
|
35
|
+
const MIN_USEFUL_SUMMARY_CHARS = 40;
|
|
36
|
+
const MAX_AGENT_SUMMARY_CHARS = 2_400;
|
|
37
|
+
/** El mensaje si sirve como resumen, recortado por línea entera; si no, null. */
|
|
38
|
+
export function usableSummary(message) {
|
|
39
|
+
const text = message.trim();
|
|
40
|
+
if (text.length < MIN_USEFUL_SUMMARY_CHARS)
|
|
41
|
+
return null;
|
|
42
|
+
if (text.length <= MAX_AGENT_SUMMARY_CHARS)
|
|
43
|
+
return text;
|
|
44
|
+
const cut = text.slice(0, MAX_AGENT_SUMMARY_CHARS);
|
|
45
|
+
const lastBreak = cut.lastIndexOf("\n");
|
|
46
|
+
return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
|
|
47
|
+
}
|
|
14
48
|
/** `git show <hash> -U0` — the unified diff of a single commit, no context. */
|
|
15
49
|
export async function commitDiff(cwd, hash) {
|
|
16
50
|
try {
|
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
|
];
|
|
@@ -175,15 +279,27 @@ async function logRun(dir, message) {
|
|
|
175
279
|
// Best-effort only.
|
|
176
280
|
}
|
|
177
281
|
}
|
|
178
|
-
|
|
179
|
-
|
|
282
|
+
/**
|
|
283
|
+
* El aviso que ve el humano, como texto.
|
|
284
|
+
*
|
|
285
|
+
* Se construye aparte de imprimirlo para poder MEDIRLO: es lo que el guardián
|
|
286
|
+
* sirve de verdad, y su longitud es lo que se registra como chars_served
|
|
287
|
+
* (QA 2026-07-19). Cadena vacía cuando no hay nada que avisar.
|
|
288
|
+
*/
|
|
289
|
+
export function findingsMessage(findings, block) {
|
|
290
|
+
if (findings.length === 0)
|
|
291
|
+
return "";
|
|
292
|
+
const lines = [
|
|
293
|
+
`\n⚠ ChangeBook: ${findings.length === 1 ? "an open alert" : `${findings.length} open alerts`} on what you're about to commit:\n`,
|
|
294
|
+
];
|
|
180
295
|
for (const f of findings) {
|
|
181
|
-
|
|
182
|
-
|
|
296
|
+
lines.push(` • ${f.module} — ${f.plain}`);
|
|
297
|
+
lines.push(` staged: ${f.staged.slice(0, 5).join(", ")}`);
|
|
183
298
|
}
|
|
184
|
-
|
|
299
|
+
lines.push(block
|
|
185
300
|
? "\nCommit blocked (CHANGEBOOK_GUARD=block). Review the alert in your atlas (changebook open) or bypass once with: git commit --no-verify\n"
|
|
186
301
|
: "\nReview or dismiss the alert in your atlas: changebook open\n");
|
|
302
|
+
return lines.join("\n");
|
|
187
303
|
}
|
|
188
304
|
/**
|
|
189
305
|
* Returns the process exit code. Everything that can go wrong resolves to 0
|
|
@@ -224,12 +340,22 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
224
340
|
await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
|
|
225
341
|
return 0;
|
|
226
342
|
}
|
|
227
|
-
const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
|
|
343
|
+
const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)));
|
|
344
|
+
const block = mode === "block";
|
|
345
|
+
const message = findingsMessage(findings, block);
|
|
228
346
|
// La consulta del guardián también es una consulta del atlas (QA
|
|
229
347
|
// 2026-07-18: el contador solo veía las tools MCP y el trabajo más
|
|
230
348
|
// constante del atlas era invisible). Solo las frescas — un rebase servido
|
|
231
349
|
// de caché no re-consulta nada. Best-effort y ACOTADO: jamás puede
|
|
232
350
|
// convertir un commit rápido en uno lento.
|
|
351
|
+
//
|
|
352
|
+
// chars_served = el aviso que el humano acaba viendo, 0 cuando todo está
|
|
353
|
+
// limpio (QA 2026-07-19). Antes se registraba siempre 0 y el guardián, que es
|
|
354
|
+
// el consumidor MÁS frecuente del atlas, no se distinguía de una consulta que
|
|
355
|
+
// no encontró nada. Con esto "revisó" y "avisó" son dos cosas separables en el
|
|
356
|
+
// dato, sin inventar ninguna valoración: esos caracteres se sirvieron de
|
|
357
|
+
// verdad. Lo que el guardián evita de verdad, un error, no se mide en
|
|
358
|
+
// caracteres y no se intenta.
|
|
233
359
|
if (!signals.fromCache && signals.projectId) {
|
|
234
360
|
await Promise.race([
|
|
235
361
|
db
|
|
@@ -237,6 +363,7 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
237
363
|
project_id: signals.projectId,
|
|
238
364
|
tool: "guard_precommit",
|
|
239
365
|
source: "guard",
|
|
366
|
+
chars_served: message.length,
|
|
240
367
|
})
|
|
241
368
|
.catch(() => { }),
|
|
242
369
|
new Promise((resolve) => {
|
|
@@ -247,8 +374,7 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
247
374
|
await logRun(dir, `staged=${staged.length} openAlerts=${signals.alerts.length} findings=${findings.length}`);
|
|
248
375
|
if (findings.length === 0)
|
|
249
376
|
return 0;
|
|
250
|
-
|
|
251
|
-
printFindings(findings, block);
|
|
377
|
+
console.error(message);
|
|
252
378
|
return block ? EXIT_BLOCK : 0;
|
|
253
379
|
}
|
|
254
380
|
//# sourceMappingURL=guard.js.map
|
package/dist/import.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { atlasWebUrl } from "./browser.js";
|
|
13
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
|
|
13
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
14
14
|
import { canonicalDiffHash } from "./canonical.js";
|
|
15
15
|
import { optimizeTokensForAI } from "./optimize.js";
|
|
16
16
|
// Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
|
|
@@ -53,12 +53,16 @@ export async function importHistory(db, options = {}) {
|
|
|
53
53
|
trivial += 1;
|
|
54
54
|
continue;
|
|
55
55
|
}
|
|
56
|
+
// El servidor decide con esto si enruta al modelo pequeño. Va opcional: un
|
|
57
|
+
// servidor antiguo lo ignora sin romperse.
|
|
58
|
+
const summary = usableSummary(commit.message);
|
|
56
59
|
items.push({
|
|
57
60
|
compressedDiff: compressed,
|
|
58
61
|
rawDiffChars: diff.length,
|
|
59
62
|
rawContentHash: canonicalDiffHash(diff),
|
|
60
63
|
commitHash: commit.hash,
|
|
61
64
|
committedAt: commit.date,
|
|
65
|
+
...(summary ? { agentSummary: summary } : {}),
|
|
62
66
|
});
|
|
63
67
|
}
|
|
64
68
|
if (items.length === 0) {
|
|
@@ -134,15 +138,34 @@ function sleep(ms) {
|
|
|
134
138
|
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
135
139
|
async function listCommits(cwd, count) {
|
|
136
140
|
try {
|
|
137
|
-
|
|
141
|
+
// -z separa los commits por NUL en vez de por salto de línea. Es
|
|
142
|
+
// obligatorio desde que se pide %B: el mensaje tiene saltos dentro, así que
|
|
143
|
+
// partir por "\n" mezclaría el cuerpo de un commit con el siguiente.
|
|
144
|
+
const { stdout } = await execFileAsync("git", [
|
|
145
|
+
"log",
|
|
146
|
+
"-n",
|
|
147
|
+
String(count),
|
|
148
|
+
"--no-merges",
|
|
149
|
+
"-z",
|
|
150
|
+
"--pretty=format:%H|%cI|%B",
|
|
151
|
+
], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
138
152
|
return stdout
|
|
139
|
-
.split("\
|
|
153
|
+
.split("\0")
|
|
140
154
|
.filter(Boolean)
|
|
141
|
-
.map((
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
.map((record) => {
|
|
156
|
+
// Solo los DOS primeros "|" son separadores: el hash y la fecha ISO no
|
|
157
|
+
// pueden contener uno, pero el mensaje sí, y hay que dejarlo entero.
|
|
158
|
+
const firstPipe = record.indexOf("|");
|
|
159
|
+
const secondPipe = record.indexOf("|", firstPipe + 1);
|
|
160
|
+
if (firstPipe < 0 || secondPipe < 0)
|
|
161
|
+
return null;
|
|
162
|
+
return {
|
|
163
|
+
hash: record.slice(0, firstPipe),
|
|
164
|
+
date: record.slice(firstPipe + 1, secondPipe),
|
|
165
|
+
message: record.slice(secondPipe + 1),
|
|
166
|
+
};
|
|
144
167
|
})
|
|
145
|
-
.filter((c) => c.hash && c.date);
|
|
168
|
+
.filter((c) => c != null && !!c.hash && !!c.date);
|
|
146
169
|
}
|
|
147
170
|
catch (error) {
|
|
148
171
|
const message = gitErrorMessage(error);
|
package/dist/supabase.js
CHANGED
|
@@ -111,6 +111,17 @@ 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
|
+
// 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
|
+
const todos = await this.rest(`projects?select=id&limit=2`);
|
|
123
|
+
if (todos.length === 1)
|
|
124
|
+
return `&project_id=eq.${todos[0].id}`;
|
|
114
125
|
throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
|
|
115
126
|
}
|
|
116
127
|
return `&project_id=eq.${rows[0].id}`;
|
package/dist/sync.js
CHANGED
|
@@ -30,13 +30,22 @@ function sanitizeCell(text) {
|
|
|
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;
|
|
@@ -128,25 +137,31 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
128
137
|
// útiles ya van por entrada (regresiones, últimos cambios).
|
|
129
138
|
"## Mapa del producto (ChangeBook · auto-generado)",
|
|
130
139
|
"",
|
|
131
|
-
|
|
140
|
+
// Esta cabecera se paga en CADA peticion de CADA sesion, se consulte el
|
|
141
|
+
// atlas o no, y ademas se reenvia al modelo cada vez. Medido el
|
|
142
|
+
// 2026-07-19: ocupaba 1.109 chars de instrucciones sobre un presupuesto de
|
|
143
|
+
// 3.000, o sea el 46%, y por eso la seccion "### Módulos" quedaba
|
|
144
|
+
// EXPULSADA: el bloque se llamaba "Mapa del producto" y salia sin mapa.
|
|
145
|
+
//
|
|
146
|
+
// Las seis reglas siguen estando —cada una se gano con un incidente— pero
|
|
147
|
+
// dichas una vez y en corto. Lo que se quita es la explicacion, no la
|
|
148
|
+
// instruccion: un agente no necesita que le argumenten por que.
|
|
149
|
+
// · orientarse por el brief en una llamada
|
|
150
|
+
// · file_context antes de tocar un archivo
|
|
151
|
+
// · registrar tras cada commit, con summary propio
|
|
152
|
+
// · frontera por proyecto (QA 2026-07-18: se mezclaban proyectos)
|
|
153
|
+
// · anunciar antes de atacar un encargo
|
|
154
|
+
// · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
|
|
155
|
+
// agente lo uso y siguio callado; desde fuera eso es no hacer nada)
|
|
156
|
+
"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).",
|
|
132
157
|
"",
|
|
133
|
-
"Si vas a trabajar un encargo pendiente del atlas, anuncia primero al usuario en 2-3 líneas qué vas a hacer y por qué, antes de tocar código.",
|
|
134
|
-
"",
|
|
135
|
-
"Antes de tocar un archivo concreto, consulta `atlas_file_context` con sus rutas: te da su módulo, riesgo, alertas abiertas e historia por una llamada — más barato que releer el código y su git log.",
|
|
136
|
-
"",
|
|
137
|
-
// Frontera por proyecto (QA 2026-07-18): el atlas y la cola de encargos
|
|
138
|
-
// son POR PROYECTO; sin este dato el agente recibiría los de otros.
|
|
139
158
|
...(projectName
|
|
140
159
|
? [
|
|
141
|
-
`
|
|
160
|
+
`Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
|
|
142
161
|
"",
|
|
143
162
|
]
|
|
144
163
|
: []),
|
|
145
|
-
|
|
146
|
-
// agentes obedecen lo que el workspace les dice, no lo que una tool
|
|
147
|
-
// disponible insinúa (lección 2026-07-17: un agente con la tool conectada
|
|
148
|
-
// hizo 3 PRs y no registró ninguno hasta que se lo pidieron).
|
|
149
|
-
"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.",
|
|
164
|
+
"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.",
|
|
150
165
|
"",
|
|
151
166
|
];
|
|
152
167
|
if (modules.length === 0) {
|
|
@@ -156,15 +171,19 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
156
171
|
END,
|
|
157
172
|
].join("\n");
|
|
158
173
|
}
|
|
174
|
+
// Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
|
|
175
|
+
// nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
|
|
176
|
+
// DOS. Un mapa de dos modulos sobre 46 no es un mapa, es una anecdota.
|
|
177
|
+
//
|
|
178
|
+
// Aqui el mapa solo tiene que decir QUE existe y QUE quema; el detalle
|
|
179
|
+
// (ficheros, notas, historia) esta a una llamada de `atlas_module_detail` y
|
|
180
|
+
// ahi se paga solo cuando hace falta, en vez de en cada peticion de todas
|
|
181
|
+
// las sesiones. El riesgo se marca solo cuando NO es bajo: "riesgo low"
|
|
182
|
+
// repetido cuarenta veces es ruido que se paga igual que la senal.
|
|
159
183
|
const moduleLines = modules.map((m) => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const meta = [m.domain, m.risk && `riesgo ${m.risk}`]
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
.join(" · ");
|
|
166
|
-
const note = sanitizeCell((m.note ?? "").slice(0, 110));
|
|
167
|
-
return `- **${m.module}**${meta ? ` (${meta})` : ""}${files ? ` — ${files}` : ""}${note ? ` — ${note}` : ""}`;
|
|
184
|
+
const riesgo = m.risk && m.risk !== "low" ? ` ⚠ ${m.risk}` : "";
|
|
185
|
+
const area = m.domain ? ` · ${m.domain}` : "";
|
|
186
|
+
return `- **${m.module}**${area}${riesgo}`;
|
|
168
187
|
});
|
|
169
188
|
// Prevention beats detection: give the agent the co-change dependencies
|
|
170
189
|
// and open warnings BEFORE it edits, not after something breaks.
|
|
@@ -175,7 +194,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
175
194
|
.filter((a) => (a.plain ?? "").trim())
|
|
176
195
|
.map((a) => {
|
|
177
196
|
const mod = (a.module ?? "").trim();
|
|
178
|
-
|
|
197
|
+
// 130 y no 200: en un bloque de coste FIJO el aviso es un titular, no
|
|
198
|
+
// un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
|
|
199
|
+
// y expulsaban el mapa. El texto entero sigue a una llamada de
|
|
200
|
+
// `atlas_project_brief`, donde se paga solo si alguien pregunta.
|
|
201
|
+
return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ""} — ${sanitizeCell((a.plain ?? "").slice(0, 130))}`;
|
|
179
202
|
});
|
|
180
203
|
const hotspotLines = modules
|
|
181
204
|
.filter((m) => m.risk === "hotspot")
|
|
@@ -202,7 +225,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
202
225
|
// (utilidad para el agente) son independientes: si no cabe todo, caen
|
|
203
226
|
// primero los últimos cambios y los módulos, nunca las regresiones.
|
|
204
227
|
const sections = [
|
|
205
|
-
{ key: "modules", priority:
|
|
228
|
+
{ key: "modules", priority: 2, title: "### Módulos", lines: moduleLines },
|
|
206
229
|
{
|
|
207
230
|
key: "tasks",
|
|
208
231
|
priority: 1,
|
|
@@ -211,7 +234,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
211
234
|
},
|
|
212
235
|
{
|
|
213
236
|
key: "couplings",
|
|
214
|
-
priority:
|
|
237
|
+
priority: 3,
|
|
215
238
|
title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
|
|
216
239
|
lines: couplingLines,
|
|
217
240
|
},
|
|
@@ -223,7 +246,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
223
246
|
},
|
|
224
247
|
{
|
|
225
248
|
key: "hotspots",
|
|
226
|
-
priority:
|
|
249
|
+
priority: 4,
|
|
227
250
|
title: "### Avisos abiertos (revisar antes de modificar)",
|
|
228
251
|
lines: hotspotLines,
|
|
229
252
|
},
|
package/dist/tools.js
CHANGED
|
@@ -8,6 +8,20 @@ import { z } from "zod";
|
|
|
8
8
|
import { SupabaseError } from "./supabase.js";
|
|
9
9
|
const CHARACTER_LIMIT = 25_000;
|
|
10
10
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* Lo que de VERDAD llega al modelo. Espejo de servedCharsOf en
|
|
13
|
+
* supabase/functions/mcp/index.ts.
|
|
14
|
+
*
|
|
15
|
+
* `chars_served` contaba el markdown, y el markdown casi nunca se usa: los
|
|
16
|
+
* tool_result llegan al modelo como el JSON del structuredContent. Solo cuando
|
|
17
|
+
* la respuesta revienta CHARACTER_LIMIT deja de haber JSON y queda el texto.
|
|
18
|
+
* Contar el markdown infravaloraba el gasto un 35%.
|
|
19
|
+
*/
|
|
20
|
+
function servedCharsOf(result) {
|
|
21
|
+
return "structuredContent" in result && result.structuredContent
|
|
22
|
+
? JSON.stringify(result.structuredContent).length
|
|
23
|
+
: (result.content?.[0]?.text?.length ?? 0);
|
|
24
|
+
}
|
|
11
25
|
function errorResult(error) {
|
|
12
26
|
const message = error instanceof SupabaseError || error instanceof Error
|
|
13
27
|
? error.message
|
|
@@ -103,7 +117,7 @@ Args:
|
|
|
103
117
|
- search (optional): case-insensitive text filter over the business and technical summaries.
|
|
104
118
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
105
119
|
|
|
106
|
-
Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact,
|
|
120
|
+
Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
|
|
107
121
|
|
|
108
122
|
Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
109
123
|
inputSchema: {
|
|
@@ -113,8 +127,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
113
127
|
.describe("Pagination offset"),
|
|
114
128
|
search: z.string().min(2).max(120).optional()
|
|
115
129
|
.describe("Case-insensitive filter over summaries"),
|
|
116
|
-
project: z.string().min(1).max(120)
|
|
130
|
+
project: z.string().min(1).max(120)
|
|
117
131
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
132
|
+
include_tech: z.boolean().default(false),
|
|
118
133
|
},
|
|
119
134
|
annotations: {
|
|
120
135
|
readOnlyHint: true,
|
|
@@ -122,7 +137,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
122
137
|
idempotentHint: true,
|
|
123
138
|
openWorldHint: true,
|
|
124
139
|
},
|
|
125
|
-
}, async ({ limit, offset, search, project }) => {
|
|
140
|
+
}, async ({ limit, offset, search, project, include_tech }) => {
|
|
126
141
|
try {
|
|
127
142
|
const pf = await db.projectFilterFor(project);
|
|
128
143
|
let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
|
|
@@ -152,7 +167,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
152
167
|
id: r.id,
|
|
153
168
|
date: day(r.created_at),
|
|
154
169
|
business_impact: r.business_impact ?? "",
|
|
155
|
-
|
|
170
|
+
// Espejo de mcp/index.ts: `summary_tech` es el campo mas pesado y
|
|
171
|
+
// el que menos usa un agente que se esta orientando. Fuera por
|
|
172
|
+
// defecto; dentro si lo pide o si hay `search`, porque la busqueda
|
|
173
|
+
// mira ese campo en el servidor y sin verlo el agente no sabria por
|
|
174
|
+
// que casaron los resultados.
|
|
175
|
+
...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
|
|
156
176
|
diff_chars: r.diff_character_count ?? null,
|
|
157
177
|
modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
|
|
158
178
|
module: m.module,
|
|
@@ -171,7 +191,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
171
191
|
.map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
|
|
172
192
|
.join(", ");
|
|
173
193
|
lines.push(`## ${c.date} — ${c.business_impact}`);
|
|
174
|
-
if (c.summary_tech)
|
|
194
|
+
if ((include_tech || search) && c.summary_tech)
|
|
175
195
|
lines.push(`- Tech: ${c.summary_tech}`);
|
|
176
196
|
if (mods)
|
|
177
197
|
lines.push(`- Modules: ${mods}`);
|
|
@@ -183,8 +203,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
183
203
|
: "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
|
|
184
204
|
}
|
|
185
205
|
const changesText = lines.join("\n");
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
const salida = toolResult(changesText, output);
|
|
207
|
+
recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
|
|
208
|
+
return salida;
|
|
188
209
|
}
|
|
189
210
|
catch (error) {
|
|
190
211
|
return errorResult(error);
|
|
@@ -194,17 +215,17 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
194
215
|
title: "ChangeBook module map",
|
|
195
216
|
description: `List the modules of the product as known by ChangeBook, aggregated from the change history.
|
|
196
217
|
|
|
197
|
-
For each module: domain,
|
|
218
|
+
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
219
|
|
|
199
220
|
Args:
|
|
200
221
|
- domain (optional): filter by domain (e.g. "billing").
|
|
201
222
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
202
223
|
|
|
203
|
-
Returns (structured): { count, modules: [{ module, domain,
|
|
224
|
+
Returns (structured): { count, modules: [{ module, domain, risk, changes, last_changed }] }`,
|
|
204
225
|
inputSchema: {
|
|
205
226
|
domain: z.string().min(1).max(80).optional()
|
|
206
227
|
.describe("Only modules in this domain"),
|
|
207
|
-
project: z.string().min(1).max(120)
|
|
228
|
+
project: z.string().min(1).max(120)
|
|
208
229
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
209
230
|
},
|
|
210
231
|
annotations: {
|
|
@@ -219,7 +240,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
219
240
|
let query =
|
|
220
241
|
// The aggregation below uses only these columns; note/tech/excerpt
|
|
221
242
|
// (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
|
|
222
|
-
`change_module?select=module,domain,
|
|
243
|
+
`change_module?select=module,domain,risk,created_at` +
|
|
223
244
|
`&order=created_at.desc&limit=1000` +
|
|
224
245
|
pf;
|
|
225
246
|
if (domain)
|
|
@@ -238,11 +259,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
238
259
|
return {
|
|
239
260
|
module: name,
|
|
240
261
|
domain: latest.domain,
|
|
241
|
-
category: latest.category,
|
|
242
262
|
risk: latest.risk,
|
|
243
263
|
changes: list.length,
|
|
244
264
|
last_changed: day(latest.created_at),
|
|
245
|
-
files: fileList(latest.files),
|
|
246
265
|
};
|
|
247
266
|
});
|
|
248
267
|
modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
|
|
@@ -251,8 +270,7 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
251
270
|
for (const m of modules) {
|
|
252
271
|
lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
|
|
253
272
|
`${m.changes} change(s), last ${m.last_changed}` +
|
|
254
|
-
(m.risk ? `, risk: ${m.risk}` : "")
|
|
255
|
-
(m.files.length ? ` — files: ${m.files.join(", ")}` : ""));
|
|
273
|
+
(m.risk ? `, risk: ${m.risk}` : ""));
|
|
256
274
|
}
|
|
257
275
|
if (modules.length === 0) {
|
|
258
276
|
lines.push(domain
|
|
@@ -260,8 +278,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
|
|
|
260
278
|
: "No modules yet. Analyze a diff from the ChangeBook extension first.");
|
|
261
279
|
}
|
|
262
280
|
const modulesText = lines.join("\n");
|
|
263
|
-
|
|
264
|
-
|
|
281
|
+
const salida = toolResult(modulesText, output);
|
|
282
|
+
recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
|
|
283
|
+
return salida;
|
|
265
284
|
}
|
|
266
285
|
catch (error) {
|
|
267
286
|
return errorResult(error);
|
|
@@ -290,7 +309,7 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
290
309
|
.describe("Include diff excerpts (previews unless full)"),
|
|
291
310
|
full: z.boolean().default(false)
|
|
292
311
|
.describe("Verbatim excerpts instead of short previews"),
|
|
293
|
-
project: z.string().min(1).max(120)
|
|
312
|
+
project: z.string().min(1).max(120)
|
|
294
313
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
295
314
|
},
|
|
296
315
|
annotations: {
|
|
@@ -375,8 +394,9 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
|
|
|
375
394
|
lines.push("");
|
|
376
395
|
}
|
|
377
396
|
const detailText = lines.join("\n");
|
|
378
|
-
|
|
379
|
-
|
|
397
|
+
const salida = toolResult(detailText, output);
|
|
398
|
+
recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
|
|
399
|
+
return salida;
|
|
380
400
|
}
|
|
381
401
|
catch (error) {
|
|
382
402
|
return errorResult(error);
|
|
@@ -396,7 +416,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
396
416
|
inputSchema: {
|
|
397
417
|
files: z.array(z.string().min(1).max(300)).min(1).max(8)
|
|
398
418
|
.describe("Repo-relative paths you are about to edit"),
|
|
399
|
-
project: z.string().min(1).max(120)
|
|
419
|
+
project: z.string().min(1).max(120)
|
|
400
420
|
.describe("Project to scope to (repo folder name or slug)"),
|
|
401
421
|
},
|
|
402
422
|
annotations: {
|
|
@@ -464,8 +484,9 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
464
484
|
lines.push("");
|
|
465
485
|
}
|
|
466
486
|
const contextText = lines.join("\n");
|
|
467
|
-
|
|
468
|
-
|
|
487
|
+
const salida = toolResult(contextText, { files: perFile });
|
|
488
|
+
recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
|
|
489
|
+
return salida;
|
|
469
490
|
}
|
|
470
491
|
catch (error) {
|
|
471
492
|
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.2",
|
|
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.2",
|
|
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.2",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|