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 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
- ["log", "-1", "--pretty=format:%H|%cI", "--end-of-options", ref], { cwd, encoding: "utf8" });
98
- const [hash, committedAt] = stdout.trim().split("|");
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 { hash, committedAt };
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
- function printFindings(findings, block) {
179
- console.error(`\n⚠ ChangeBook: ${findings.length === 1 ? "an open alert" : `${findings.length} open alerts`} on what you're about to commit:\n`);
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
- console.error(` • ${f.module} — ${f.plain}`);
182
- console.error(` staged: ${f.staged.slice(0, 5).join(", ")}`);
192
+ lines.push(` • ${f.module} — ${f.plain}`);
193
+ lines.push(` staged: ${f.staged.slice(0, 5).join(", ")}`);
183
194
  }
184
- console.error(block
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
- const block = mode === "block";
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
- const { stdout } = await execFileAsync("git", ["log", "-n", String(count), "--no-merges", "--pretty=format:%H|%cI"], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
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("\n")
153
+ .split("\0")
140
154
  .filter(Boolean)
141
- .map((line) => {
142
- const [hash, date] = line.split("|");
143
- return { hash, date };
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.0",
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.0",
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.0",
18
+ "version": "0.4.1",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }