changebook 0.4.0 → 0.4.1
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 +29 -7
- package/dist/import.js +30 -7
- package/dist/sync.js +6 -0
- 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
|
@@ -175,15 +175,27 @@ async function logRun(dir, message) {
|
|
|
175
175
|
// Best-effort only.
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
/**
|
|
179
|
+
* El aviso que ve el humano, como texto.
|
|
180
|
+
*
|
|
181
|
+
* Se construye aparte de imprimirlo para poder MEDIRLO: es lo que el guardián
|
|
182
|
+
* sirve de verdad, y su longitud es lo que se registra como chars_served
|
|
183
|
+
* (QA 2026-07-19). Cadena vacía cuando no hay nada que avisar.
|
|
184
|
+
*/
|
|
185
|
+
export function findingsMessage(findings, block) {
|
|
186
|
+
if (findings.length === 0)
|
|
187
|
+
return "";
|
|
188
|
+
const lines = [
|
|
189
|
+
`\n⚠ ChangeBook: ${findings.length === 1 ? "an open alert" : `${findings.length} open alerts`} on what you're about to commit:\n`,
|
|
190
|
+
];
|
|
180
191
|
for (const f of findings) {
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
lines.push(` • ${f.module} — ${f.plain}`);
|
|
193
|
+
lines.push(` staged: ${f.staged.slice(0, 5).join(", ")}`);
|
|
183
194
|
}
|
|
184
|
-
|
|
195
|
+
lines.push(block
|
|
185
196
|
? "\nCommit blocked (CHANGEBOOK_GUARD=block). Review the alert in your atlas (changebook open) or bypass once with: git commit --no-verify\n"
|
|
186
197
|
: "\nReview or dismiss the alert in your atlas: changebook open\n");
|
|
198
|
+
return lines.join("\n");
|
|
187
199
|
}
|
|
188
200
|
/**
|
|
189
201
|
* Returns the process exit code. Everything that can go wrong resolves to 0
|
|
@@ -225,11 +237,21 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
225
237
|
return 0;
|
|
226
238
|
}
|
|
227
239
|
const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
|
|
240
|
+
const block = mode === "block";
|
|
241
|
+
const message = findingsMessage(findings, block);
|
|
228
242
|
// La consulta del guardián también es una consulta del atlas (QA
|
|
229
243
|
// 2026-07-18: el contador solo veía las tools MCP y el trabajo más
|
|
230
244
|
// constante del atlas era invisible). Solo las frescas — un rebase servido
|
|
231
245
|
// de caché no re-consulta nada. Best-effort y ACOTADO: jamás puede
|
|
232
246
|
// convertir un commit rápido en uno lento.
|
|
247
|
+
//
|
|
248
|
+
// chars_served = el aviso que el humano acaba viendo, 0 cuando todo está
|
|
249
|
+
// limpio (QA 2026-07-19). Antes se registraba siempre 0 y el guardián, que es
|
|
250
|
+
// el consumidor MÁS frecuente del atlas, no se distinguía de una consulta que
|
|
251
|
+
// no encontró nada. Con esto "revisó" y "avisó" son dos cosas separables en el
|
|
252
|
+
// dato, sin inventar ninguna valoración: esos caracteres se sirvieron de
|
|
253
|
+
// verdad. Lo que el guardián evita de verdad, un error, no se mide en
|
|
254
|
+
// caracteres y no se intenta.
|
|
233
255
|
if (!signals.fromCache && signals.projectId) {
|
|
234
256
|
await Promise.race([
|
|
235
257
|
db
|
|
@@ -237,6 +259,7 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
237
259
|
project_id: signals.projectId,
|
|
238
260
|
tool: "guard_precommit",
|
|
239
261
|
source: "guard",
|
|
262
|
+
chars_served: message.length,
|
|
240
263
|
})
|
|
241
264
|
.catch(() => { }),
|
|
242
265
|
new Promise((resolve) => {
|
|
@@ -247,8 +270,7 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
247
270
|
await logRun(dir, `staged=${staged.length} openAlerts=${signals.alerts.length} findings=${findings.length}`);
|
|
248
271
|
if (findings.length === 0)
|
|
249
272
|
return 0;
|
|
250
|
-
|
|
251
|
-
printFindings(findings, block);
|
|
273
|
+
console.error(message);
|
|
252
274
|
return block ? EXIT_BLOCK : 0;
|
|
253
275
|
}
|
|
254
276
|
//# 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/sync.js
CHANGED
|
@@ -132,6 +132,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
132
132
|
"",
|
|
133
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
134
|
"",
|
|
135
|
+
// QA de Raúl 2026-07-19: el atlas avisó de un riesgo real, el agente lo
|
|
136
|
+
// usó y siguió trabajando sin decir nada. Desde fuera, un guardián que
|
|
137
|
+
// trabaja callado y un producto que no hace nada son la misma cosa. El
|
|
138
|
+
// dueño paga esto: tiene que verlo ocurrir.
|
|
139
|
+
"Cuando el atlas te avise de un riesgo (alerta abierta, `atlas_file_context` o el guardián), DÍSELO al usuario en 1-2 líneas antes de seguir, aunque lo resuelvas tú: él no ve esos avisos.",
|
|
140
|
+
"",
|
|
135
141
|
"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
142
|
"",
|
|
137
143
|
// Frontera por proyecto (QA 2026-07-18): el atlas y la cola de encargos
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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.1",
|
|
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.1",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|