changebook 0.7.0 → 0.7.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 +40 -2
- package/dist/git.js +42 -2
- package/dist/sync.js +36 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/analyze.js
CHANGED
|
@@ -6,9 +6,37 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, esFicheroGenerado, execFileAsync, FICHEROS_GENERADOS, ficherosDelCommit, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
|
+
/**
|
|
13
|
+
* Por qué se salta un commit sin diff analizable. Tres causas, tres frases.
|
|
14
|
+
*
|
|
15
|
+
* HASTA EL 04/08 ERA UNA SOLA, Y ADIVINABA: «has no analyzable diff (merge?)».
|
|
16
|
+
* `commitDiff` excluye los ficheros que ChangeBook genera —`CLAUDE.md`,
|
|
17
|
+
* `AGENTS.md`— para no registrar sus propias regeneraciones en bucle, así que un
|
|
18
|
+
* commit normal que solo toque esos devuelve diff vacío igual que un merge. El
|
|
19
|
+
* `0bac769` de este repo era exactamente eso y el mensaje mandaba a buscar un
|
|
20
|
+
* merge que no existía.
|
|
21
|
+
*
|
|
22
|
+
* Saltar sigue siendo lo correcto en los tres casos: lo que cambia es que el
|
|
23
|
+
* instrumento sepa distinguirlos en vez de elegir uno (invariante 17 del repo).
|
|
24
|
+
*
|
|
25
|
+
* Es una función aparte y pura para que se pueda medir la ELECCIÓN sin montar
|
|
26
|
+
* un repo ni capturar `console.error`.
|
|
27
|
+
*/
|
|
28
|
+
export function motivoDelSalto(hash, ficheros) {
|
|
29
|
+
const corto = hash.slice(0, 8);
|
|
30
|
+
if (!ficheros.length) {
|
|
31
|
+
return `Commit ${corto} no toca ningun fichero (merge o commit vacio). Skipped.`;
|
|
32
|
+
}
|
|
33
|
+
if (ficheros.every(esFicheroGenerado)) {
|
|
34
|
+
return `Commit ${corto} solo toca ficheros que genera ChangeBook (${ficheros.join(", ")}). Saltado a proposito: regenerar el mapa no es un cambio de producto.`;
|
|
35
|
+
}
|
|
36
|
+
// Ni vacío ni todo generado: aquí no hay explicación conocida, y decirlo así
|
|
37
|
+
// es el punto. Inventar una tercera causa plausible sería repetir el bug.
|
|
38
|
+
return `Commit ${corto} toca ${ficheros.length} fichero(s) y aun asi no deja diff que analizar. Skipped. Ficheros: ${ficheros.join(", ")}`;
|
|
39
|
+
}
|
|
12
40
|
export async function analyze(db, options = {}) {
|
|
13
41
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
14
42
|
const projectName = projectNameFor(cwd);
|
|
@@ -27,7 +55,17 @@ export async function analyze(db, options = {}) {
|
|
|
27
55
|
agentSummary = usableSummary(meta.message) ?? undefined;
|
|
28
56
|
rawDiff = await commitDiff(cwd, meta.hash);
|
|
29
57
|
if (!rawDiff.trim()) {
|
|
30
|
-
|
|
58
|
+
// Un diff vacío tiene DOS causas y hasta el 04/08 se contaban como una:
|
|
59
|
+
// el commit no toca nada (un merge), o toca solo ficheros que ChangeBook
|
|
60
|
+
// genera y que `commitDiff` excluye a propósito. Decir «(merge?)» de lo
|
|
61
|
+
// segundo es adivinar, y adivinar mal: `0bac769` era un commit normal de
|
|
62
|
+
// CLAUDE.md y AGENTS.md, y el mensaje mandaba a buscar un merge que no
|
|
63
|
+
// existía.
|
|
64
|
+
//
|
|
65
|
+
// Saltarlo sigue siendo lo correcto en los dos casos. Lo que cambia es
|
|
66
|
+
// que ahora el instrumento sabe por qué está saltando (invariante 17).
|
|
67
|
+
const ficheros = await ficherosDelCommit(cwd, meta.hash);
|
|
68
|
+
console.error(motivoDelSalto(meta.hash, ficheros));
|
|
31
69
|
return;
|
|
32
70
|
}
|
|
33
71
|
}
|
package/dist/git.js
CHANGED
|
@@ -75,8 +75,9 @@ export function usableSummary(message) {
|
|
|
75
75
|
* exactamente eso.
|
|
76
76
|
*
|
|
77
77
|
* Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
|
|
78
|
-
* salta
|
|
79
|
-
*
|
|
78
|
+
* salta, que es la respuesta correcta: regenerar el mapa no es un cambio de
|
|
79
|
+
* producto. Lo que NO era correcto era cómo lo contaba — ver
|
|
80
|
+
* `ficherosDelCommit` aquí abajo y el mensaje en `analyze.ts`.
|
|
80
81
|
*/
|
|
81
82
|
export const FICHEROS_GENERADOS = [
|
|
82
83
|
":(exclude)CLAUDE.md",
|
|
@@ -104,6 +105,45 @@ export async function commitDiff(cwd, hash) {
|
|
|
104
105
|
throw new Error(`Could not read the diff of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Los ficheros que toca un commit, SIN excluir los generados.
|
|
110
|
+
*
|
|
111
|
+
* POR QUÉ EXISTE. `commitDiff` excluye `CLAUDE.md`/`AGENTS.md`, así que un
|
|
112
|
+
* commit que solo toca esos devuelve diff vacío — igual que un merge, que no
|
|
113
|
+
* tiene diff propio. Dos situaciones distintas, una sola señal, y `analyze`
|
|
114
|
+
* elegía la equivocada: decía «has no analyzable diff (merge?)» de un commit
|
|
115
|
+
* normal que ni siquiera era un merge (medido el 04/08 con `0bac769`).
|
|
116
|
+
*
|
|
117
|
+
* Es la invariante 17 del repo dentro del producto: la ausencia de diff no
|
|
118
|
+
* prueba la ausencia de cambio mientras el instrumento no sepa distinguir por
|
|
119
|
+
* qué está ausente. Esta función es lo que le da esa segunda pregunta.
|
|
120
|
+
*
|
|
121
|
+
* Devuelve `[]` si el commit no toca ningún fichero — el caso del merge de
|
|
122
|
+
* verdad, y también el de un commit vacío.
|
|
123
|
+
*/
|
|
124
|
+
export async function ficherosDelCommit(cwd, hash) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await execFileAsync("git",
|
|
127
|
+
// Mismo `--end-of-options` que commitDiff, por el mismo motivo.
|
|
128
|
+
["show", "--pretty=format:", "--name-only", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
129
|
+
return stdout
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((l) => l.trim())
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new Error(`Could not read the files of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* ¿Es un fichero de los que ChangeBook genera? Se decide con las MISMAS rutas
|
|
140
|
+
* que excluye `commitDiff`, derivadas de ella y no reescritas: dos listas que
|
|
141
|
+
* pudieran separarse harían que el mensaje explicara un salto que no ocurrió.
|
|
142
|
+
*/
|
|
143
|
+
export function esFicheroGenerado(ruta) {
|
|
144
|
+
const nombre = ruta.split("/").pop() ?? "";
|
|
145
|
+
return FICHEROS_GENERADOS.some((patron) => patron.replace(/^:\(exclude\)(\*\*\/)?/, "") === nombre);
|
|
146
|
+
}
|
|
107
147
|
/** Pulls git's stderr out of an execFile rejection for a readable message. */
|
|
108
148
|
export function gitErrorMessage(error) {
|
|
109
149
|
if (typeof error === "object" &&
|
package/dist/sync.js
CHANGED
|
@@ -54,6 +54,37 @@ const FIRMA = '_Generado por ChangeBook · changebook.app_';
|
|
|
54
54
|
function sanitizeCell(text) {
|
|
55
55
|
return text.replace(/<!--/g, '<!- -');
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Recorta a `tope` caracteres sin partir palabras y DICIENDO que ha recortado.
|
|
59
|
+
*
|
|
60
|
+
* POR QUÉ EXISTE. Los topes de este bloque son deliberados —entra en cada
|
|
61
|
+
* sesión de agente y se reenvía en cada petición, así que es coste FIJO— pero
|
|
62
|
+
* se aplicaban con un `.slice(0, n)` pelado. El 04/08 el CLAUDE.md de este
|
|
63
|
+
* mismo repo decía «permitiendo validar q»: cortado a media palabra y sin
|
|
64
|
+
* ninguna marca. Eso no se lee como un extracto, se lee como salida rota, y
|
|
65
|
+
* este bloque es el artefacto más leído del producto.
|
|
66
|
+
*
|
|
67
|
+
* DOS COSAS, Y LAS DOS IMPORTAN:
|
|
68
|
+
*
|
|
69
|
+
* · La elipsis SOLO aparece si de verdad se recortó. Ponerla siempre haría
|
|
70
|
+
* que toda nota pareciera truncada, que es el error simétrico y igual de
|
|
71
|
+
* malo: dejaría de distinguir lo completo de lo cortado.
|
|
72
|
+
* · El hueco de la elipsis sale DE DENTRO del tope. El presupuesto está en
|
|
73
|
+
* caracteres; un tope que se desborda por la marca que anuncia el tope es
|
|
74
|
+
* un tope de mentira.
|
|
75
|
+
*
|
|
76
|
+
* Una palabra sola más larga que el tope se corta a lo bruto: retroceder al
|
|
77
|
+
* espacio anterior devolvería la cadena vacía, y perder la nota entera es peor
|
|
78
|
+
* que un corte feo. Ese caso está en el corpus del test.
|
|
79
|
+
*/
|
|
80
|
+
export function recorta(texto, tope) {
|
|
81
|
+
if (texto.length <= tope)
|
|
82
|
+
return texto;
|
|
83
|
+
const cortado = texto.slice(0, tope - 1);
|
|
84
|
+
const ultimoEspacio = cortado.lastIndexOf(' ');
|
|
85
|
+
const base = ultimoEspacio > 0 ? cortado.slice(0, ultimoEspacio) : cortado;
|
|
86
|
+
return `${base.replace(/[\s.,;:]+$/, '')}…`;
|
|
87
|
+
}
|
|
57
88
|
const MAX_MODULES = 15;
|
|
58
89
|
const MAX_CHANGES = 5;
|
|
59
90
|
const MAX_COUPLINGS = 5;
|
|
@@ -464,21 +495,21 @@ maxCitas = CITAS_GRATIS) {
|
|
|
464
495
|
// un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
|
|
465
496
|
// y expulsaban el mapa. El texto entero sigue a una llamada de
|
|
466
497
|
// `atlas_project_brief`, donde se paga solo si alguien pregunta.
|
|
467
|
-
return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? ''
|
|
498
|
+
return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell(recorta(a.plain ?? '', 130))}`;
|
|
468
499
|
});
|
|
469
500
|
const hotspotLines = modules
|
|
470
501
|
.filter((m) => m.risk === 'hotspot')
|
|
471
502
|
.slice(0, 5)
|
|
472
503
|
.map((m) => {
|
|
473
|
-
const note = sanitizeCell((m.note ?? ''
|
|
504
|
+
const note = sanitizeCell(recorta(m.note ?? '', 110));
|
|
474
505
|
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
|
|
475
506
|
});
|
|
476
|
-
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? ''
|
|
507
|
+
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell(recorta(c.business_impact ?? '', 140))}`);
|
|
477
508
|
// Salud en riesgo: los controles (auth, secretos, validación, límites…) que
|
|
478
509
|
// el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
|
|
479
510
|
// es seguridad. Solo los at_risk (lo accionable); el texto entero va a
|
|
480
511
|
// `atlas_project_brief`. Cap a 130 chars como las alertas.
|
|
481
|
-
const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence
|
|
512
|
+
const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(recorta(h.evidence, 130))}`);
|
|
482
513
|
// Auto-remediación fase 2: la cola entra en cada sesión para que el agente
|
|
483
514
|
// se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
|
|
484
515
|
// nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
|
|
@@ -489,7 +520,7 @@ maxCitas = CITAS_GRATIS) {
|
|
|
489
520
|
const taskLines = taskTitles.length > 0
|
|
490
521
|
? [
|
|
491
522
|
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
|
|
492
|
-
...taskTitles.map((t) => `- ${sanitizeCell(t
|
|
523
|
+
...taskTitles.map((t) => `- ${sanitizeCell(recorta(t, 120))}`),
|
|
493
524
|
]
|
|
494
525
|
: [];
|
|
495
526
|
// El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "Your agent already broke this three times. ChangeBook tells it before the fourth. MCP server + CLI: the history of what broke in your repo, served to Claude Code, Cursor or Codex before they edit.",
|
|
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": "Your agent already broke this three times. ChangeBook tells it before the fourth.",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.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.7.
|
|
18
|
+
"version": "0.7.1",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|