changebook 0.8.0 → 0.9.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/README.md CHANGED
@@ -57,6 +57,7 @@ your other servers.
57
57
  | `changebook scan [dir] [--json\|--card\|--badge]` | Coupling report for **any** repo from its git history alone: no account, no network, writes nothing — run it on something you just cloned. `--card` renders a shareable SVG, `--badge` publishes four numbers and prints the README snippet (needs an account; `--badge --off` turns it off). The badge exposes those four numbers and nothing else — not your code, modules or change summaries. |
58
58
  | `changebook silence [dir]` | How often the `PreToolUse` hook stays quiet, with both raw numbers. Local by design: it answers the day you install it, not two days later. |
59
59
  | `changebook friction [dir]` | Where the agent's work gets redone in this repo, read from the **local** Claude Code transcripts — no prose leaves the machine, only paths, modules, dates and a session hash. Says **MUERTO** if the repo has edits and it read nothing, and reports its own blind spot: edits made through the shell (`sed -i`, heredocs) leave no before/after, so ~25% of writes are invisible to it and it says so. |
60
+ | `changebook audit [dir]` | Static check of your agent setup — no network, no credentials, nothing written. Flags rules in `CLAUDE.md`/`AGENTS.md` that cite files which no longer exist (with a «did you mean…»), how much context you pay every session, and whether the impact hook is actually installed. Every check here was written after it found something real, not from a best-practices list. |
60
61
  | `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
61
62
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
62
63
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
package/dist/audit.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `changebook audit` — la pieza `[5]`: auditoría ESTÁTICA del setup.
3
+ *
4
+ * QUÉ LA DISTINGUE DE UNA LISTA DE BUENAS PRÁCTICAS. Cada comprobación de aquí
5
+ * se escribió DESPUÉS de encontrar algo real en un setup real (este repo, el
6
+ * 24/08), no antes. Una lista de comprobaciones inventadas produce avisos que
7
+ * nadie ha visto fallar nunca, y eso entrena a ignorar la herramienta.
8
+ *
9
+ * SIN RED Y SIN CREDENCIALES, como `silence` y `scan`: todo lo que dice sale de
10
+ * ficheros del repo. Así contesta el mismo día en que alguien lo instala, antes
11
+ * de tener cuenta, y no puede quedarse callada por un fallo de red — que es la
12
+ * forma en que una auditoría miente.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ /** Los ficheros de contexto que un agente lee al abrir, por convención. */
17
+ export const FICHEROS_DE_CONTEXTO = ['CLAUDE.md', 'AGENTS.md'];
18
+ const INICIO = '<!-- changebook:start -->';
19
+ const FIN = '<!-- changebook:end -->';
20
+ /**
21
+ * Las rutas citadas entre backticks que parecen ficheros.
22
+ *
23
+ * Se exige extensión conocida a propósito: sin ella, cualquier `foo.bar` de la
24
+ * prosa entraría como ruta y la auditoría publicaría falsos positivos — que en
25
+ * una herramienta que se corre a mano es peor que no publicar nada.
26
+ */
27
+ export function rutasCitadas(texto) {
28
+ const re = /`([A-Za-z0-9_./-]+\.(?:ts|tsx|js|mjs|cjs|sql|yml|yaml|json|sh))`/g;
29
+ return [...new Set([...texto.matchAll(re)].map((m) => m[1]))];
30
+ }
31
+ /** El bloque auto-generado, si lo hay. */
32
+ export function partirContexto(texto) {
33
+ const i = texto.indexOf(INICIO);
34
+ const j = texto.indexOf(FIN);
35
+ if (i === -1 || j === -1 || j < i)
36
+ return { auto: '', aMano: texto };
37
+ const auto = texto.slice(i, j + FIN.length);
38
+ return { auto, aMano: texto.replace(auto, '') };
39
+ }
40
+ /**
41
+ * ¿Existe? Y si no, ¿hay algo que se le parezca?
42
+ *
43
+ * El «quizá quisiste decir» no es adorno: los dos casos reales que encontró
44
+ * esto el 24/08 —`test/aliasEnLasToolsDelCli.ts` y `test/sinRayasEnLaWeb.ts`—
45
+ * eran el mismo fichero con `.test.ts`. Un aviso que sólo dice «no existe»
46
+ * manda a buscar; uno que dice el nombre bueno se arregla en diez segundos.
47
+ */
48
+ export function resolverCita(raiz, cita) {
49
+ if (fs.existsSync(path.join(raiz, cita)))
50
+ return { existe: true };
51
+ // UN NOMBRE A SECAS NO ES UNA RUTA ROTA. La primera versión avisaba de
52
+ // `publish-extension.yml` —que existe, en `.github/workflows/`— porque se
53
+ // cita por su nombre. Ese falso positivo enseña a ignorar los avisos buenos,
54
+ // que era justo el riesgo escrito en la cabecera de este fichero. Cazado el
55
+ // 24/08 corriendo la auditoría contra su propio repo.
56
+ if (!cita.includes('/')) {
57
+ return { existe: buscarPorNombre(raiz, cita) };
58
+ }
59
+ const dir = path.join(raiz, path.dirname(cita));
60
+ const base = path.basename(cita, path.extname(cita));
61
+ let vecinos;
62
+ try {
63
+ vecinos = fs.readdirSync(dir);
64
+ }
65
+ catch {
66
+ return { existe: false };
67
+ }
68
+ const parecido = vecinos.find((v) => v.startsWith(`${base}.`) && v !== path.basename(cita));
69
+ return parecido
70
+ ? { existe: false, parecido: path.join(path.dirname(cita), parecido) }
71
+ : { existe: false };
72
+ }
73
+ /**
74
+ * ¿Existe algún fichero con ese nombre, en cualquier parte del repo?
75
+ *
76
+ * Con tope de profundidad y saltándose lo que no se versiona: sin eso, un
77
+ * `node_modules` convierte una orden instantánea en una que se piensa.
78
+ */
79
+ function buscarPorNombre(raiz, nombre, profundidad = 6) {
80
+ const saltar = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
81
+ const pila = [[raiz, 0]];
82
+ while (pila.length > 0) {
83
+ const [dir, nivel] = pila.pop();
84
+ let entradas;
85
+ try {
86
+ entradas = fs.readdirSync(dir, { withFileTypes: true });
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ for (const e of entradas) {
92
+ if (e.isFile() && e.name === nombre)
93
+ return true;
94
+ if (e.isDirectory() && !saltar.has(e.name) && nivel < profundidad) {
95
+ pila.push([path.join(dir, e.name), nivel + 1]);
96
+ }
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Citas rotas en los ficheros de contexto.
103
+ *
104
+ * DE DÓNDE SALE: 2 de 9 rutas citadas en el `CLAUDE.md` de este repo no
105
+ * existían. Una regla que apunta a código que no está es peor que no tener la
106
+ * regla — le enseña al agente a desconfiar del documento entero, incluidas las
107
+ * partes que sí valen.
108
+ */
109
+ export function citasRotas(raiz) {
110
+ const out = [];
111
+ for (const nombre of FICHEROS_DE_CONTEXTO) {
112
+ let texto;
113
+ try {
114
+ texto = fs.readFileSync(path.join(raiz, nombre), 'utf8');
115
+ }
116
+ catch {
117
+ continue; // No tenerlo no es un fallo: no todos los repos los tienen.
118
+ }
119
+ // Sólo lo escrito A MANO: el bloque auto-generado se regenera solo y sus
120
+ // rutas salen de la base de datos, no de la prosa de nadie.
121
+ const { aMano } = partirContexto(texto);
122
+ const rotas = rutasCitadas(aMano)
123
+ .map((c) => ({ cita: c, ...resolverCita(raiz, c) }))
124
+ .filter((c) => !c.existe);
125
+ if (rotas.length === 0)
126
+ continue;
127
+ out.push({
128
+ nivel: 'aviso',
129
+ titulo: `${nombre}: ${rotas.length} cita(s) apuntan a ficheros que no existen`,
130
+ detalle: rotas.map((r) => r.parecido ? `${r.cita} → ¿quisiste decir ${r.parecido}?` : r.cita),
131
+ });
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * El peso de contexto que se paga en CADA sesión.
137
+ *
138
+ * DE DÓNDE SALE, medido el 24/08 en este repo: el bloque auto-generado se
139
+ * raciona a 2.000 chars —hay una PR entera dedicada a que sea honesto cuando
140
+ * recorta— y al lado había **12.407 chars escritos a mano, seis veces más**,
141
+ * sin tope y sin que nadie los midiera. No se juzga si sobran: se ponen delante.
142
+ */
143
+ export function pesoDeContexto(raiz, topeAuto = 2000) {
144
+ const out = [];
145
+ for (const nombre of FICHEROS_DE_CONTEXTO) {
146
+ let texto;
147
+ try {
148
+ texto = fs.readFileSync(path.join(raiz, nombre), 'utf8');
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ const { auto, aMano } = partirContexto(texto);
154
+ const detalle = [
155
+ `total ${texto.length} chars`,
156
+ `auto-generado ${auto.length} (tope ${topeAuto})`,
157
+ `escrito a mano ${aMano.length}, sin tope`,
158
+ ];
159
+ // El aviso salta cuando lo de a mano PASA del bloque racionado: hasta ahí,
160
+ // la comparación no dice nada que preocupe.
161
+ out.push({
162
+ nivel: aMano.length > topeAuto ? 'aviso' : 'nota',
163
+ titulo: aMano.length > topeAuto
164
+ ? `${nombre}: lo escrito a mano pesa ${(aMano.length / topeAuto).toFixed(1)}× el bloque racionado`
165
+ : `${nombre}: ${texto.length} chars en cada sesión`,
166
+ detalle,
167
+ });
168
+ }
169
+ return out;
170
+ }
171
+ /**
172
+ * El hook de contexto: ¿está puesto, y su orden existe?
173
+ *
174
+ * Un hook cuyo comando no resuelve falla en silencio para siempre — la línea
175
+ * que instala el propio producto acaba en `|| true`, así que ni siquiera deja
176
+ * un exit distinto de cero. Es el Invariante 17 en la instalación.
177
+ */
178
+ export function hookDeImpacto(raiz) {
179
+ const p = path.join(raiz, '.claude', 'settings.json');
180
+ let crudo;
181
+ try {
182
+ crudo = fs.readFileSync(p, 'utf8');
183
+ }
184
+ catch {
185
+ return [
186
+ {
187
+ nivel: 'aviso',
188
+ titulo: 'No hay hook de impacto instalado',
189
+ detalle: [
190
+ 'Sin él, nadie te avisa de qué depende del fichero que vas a tocar.',
191
+ 'Se instala con `changebook init`.',
192
+ ],
193
+ },
194
+ ];
195
+ }
196
+ const puesto = /changebook\s+impact/.test(crudo);
197
+ return puesto
198
+ ? []
199
+ : [
200
+ {
201
+ nivel: 'aviso',
202
+ titulo: '.claude/settings.json existe pero no lanza `changebook impact`',
203
+ detalle: ['Se reinstala con `changebook init`.'],
204
+ },
205
+ ];
206
+ }
207
+ /** Todas las comprobaciones, en el orden en que conviene leerlas. */
208
+ export function auditarSetup(raiz) {
209
+ return [...citasRotas(raiz), ...hookDeImpacto(raiz), ...pesoDeContexto(raiz)];
210
+ }
211
+ export function informeDeAuditoria(hallazgos) {
212
+ const avisos = hallazgos.filter((h) => h.nivel === 'aviso');
213
+ if (hallazgos.length === 0)
214
+ return 'Setup sin nada que señalar.';
215
+ const cabecera = avisos.length === 0
216
+ ? 'Setup sin avisos.'
217
+ : `${avisos.length} aviso(s) sobre tu setup:`;
218
+ return [
219
+ cabecera,
220
+ ...hallazgos.map((h) => [
221
+ `${h.nivel === 'aviso' ? '⚠' : '·'} ${h.titulo}`,
222
+ ...h.detalle.map((d) => ` ${d}`),
223
+ ].join('\n')),
224
+ ].join('\n');
225
+ }
226
+ //# sourceMappingURL=audit.js.map
package/dist/friction.js CHANGED
@@ -289,24 +289,27 @@ export const MIN_CORRECCIONES_PARA_HABLAR = 2;
289
289
  * vez sobre el mismo fichero producirían "reediciones" cruzadas que son un
290
290
  * artefacto del orden en que se leyeron los ficheros, no un hecho.
291
291
  */
292
- export function correccionesDeEdiciones(ediciones) {
292
+ export function veredictosDeEdiciones(ediciones) {
293
293
  const ultima = new Map();
294
- const out = [];
294
+ const out = new Map();
295
295
  for (const e of ediciones) {
296
296
  const clave = `${e.sesion} ${e.ruta}`;
297
297
  const previa = ultima.get(clave);
298
298
  // `clasificarEdicion` y no `clasificarReedicion`: es el MISMO clasificador
299
299
  // que usa `procesarFriccion`. Con dos, el ranking del CLI y lo que se sube
300
300
  // dirían cosas distintas — ya pasó con el módulo, y van tres veces.
301
- if (previa &&
302
- e.turno > previa.turno &&
303
- clasificarEdicion(previa, e) === 'correccion') {
304
- out.push(e);
305
- }
301
+ out.set(e, previa ? clasificarEdicion(previa, e) : 'primera');
306
302
  ultima.set(clave, e);
307
303
  }
308
304
  return out;
309
305
  }
306
+ export function correccionesDeEdiciones(ediciones) {
307
+ const veredictos = veredictosDeEdiciones(ediciones);
308
+ // El filtro por turno vive en `clasificarEdicion`, que devuelve 'iteracion'
309
+ // para las del mismo turno. Repetirlo aquí sería la segunda copia de la
310
+ // regla, y este fichero ya lleva tres avisos de lo que eso cuesta.
311
+ return ediciones.filter((e) => veredictos.get(e) === 'correccion');
312
+ }
310
313
  /**
311
314
  * De correcciones a filas de fricción, por fichero: la vista de TASA.
312
315
  *
@@ -862,6 +865,105 @@ export function lineaDeSuceso(s) {
862
865
  ? `${cuando} · ${s.ruta} — ${s.modulo}`
863
866
  : `${cuando} · ${s.ruta}`;
864
867
  }
868
+ // ── Dónde el agente rehace SU PROPIO trabajo ─────────────────────────────────
869
+ //
870
+ // OTRA PREGUNTA, no la misma con otro umbral. La corrección mide «tú tuviste
871
+ // que arreglarlo»; esto mide «al agente le costó varios intentos acertar». La
872
+ // segunda no dice que el resultado esté mal — dice que ese fichero es caro.
873
+ //
874
+ // POR QUÉ ESTA SEÑAL SÍ SE PUEDE USAR Y LA OTRA NO, medido el 24/08 contra
875
+ // producción:
876
+ // · Las correcciones son **8**, repartidas en **7 sesiones distintas**, y
877
+ // sólo un fichero repite. No hay patrón del que generalizar nada.
878
+ // · Las iteraciones son **811** y se concentran: 18 ficheros con iteración en
879
+ // ≥3 sesiones distintas.
880
+ //
881
+ // Y LA COMPROBACIÓN QUE PODÍA TUMBARLA: si el ratio iteración/ediciones fuera
882
+ // plano, esto sería el conteo de cambios disfrazado — y el atlas ya lo da. No
883
+ // lo es: va del **37% al 95%**, mediana 63%.
884
+ //
885
+ // Tampoco es el tamaño disfrazado, ni el historial de regresiones disfrazado:
886
+ // `App.tsx` es el fichero MÁS GRANDE (2.604 líneas), está en el trinquete de
887
+ // módulos-dios, y tiene el ratio MÁS BAJO (37%). `impact.ts` encabeza con 85%
888
+ // y no está en esa lista. Son señales independientes.
889
+ /**
890
+ * Sesiones distintas mínimas para llamarlo patrón.
891
+ *
892
+ * TRES, y es la constante que hace el trabajo: con una sola sesión, un ratio
893
+ * del 95% es **una tarea grande** —`privacidad.html`, 19 iteraciones de 20 en
894
+ * una sentada—, no un fichero difícil. Lo que distingue «me costó aquel día» de
895
+ * «me cuesta siempre» es que vuelva a pasar en sesiones que no se conocen entre
896
+ * sí.
897
+ */
898
+ export const MIN_SESIONES_PARA_PATRON = 3;
899
+ /**
900
+ * Los ficheros donde el agente rehace su propio trabajo, una y otra vez, en
901
+ * sesiones distintas.
902
+ *
903
+ * NO SUGIERE QUÉ HACER, y eso es deliberado. Con 8 correcciones en 7 sesiones
904
+ * no hay base para que nada proponga una regla; lo que hay base para decir es
905
+ * el HECHO. El remedio lo elige quien conoce el código — igual que el atlas
906
+ * dice «esto rompió 8 veces» y no escribe el arreglo.
907
+ */
908
+ export function ficherosQueCuestan(ediciones, opts = {}) {
909
+ const suelo = opts.sueloDeExposicion ?? SUELO_DE_EXPOSICION;
910
+ const minSesiones = opts.minSesiones ?? MIN_SESIONES_PARA_PATRON;
911
+ // LA MISMA cadena que alimenta las correcciones. Dos pasadas distintas sobre
912
+ // la misma pregunta acaban discrepando, y en este fichero ya van tres avisos.
913
+ const veredictos = veredictosDeEdiciones(ediciones);
914
+ const acc = new Map();
915
+ for (const e of ediciones) {
916
+ const f = acc.get(e.ruta) ?? {
917
+ ediciones: 0,
918
+ iteraciones: 0,
919
+ sesiones: new Set(),
920
+ };
921
+ f.ediciones += 1;
922
+ f.sesiones.add(e.sesion);
923
+ if (veredictos.get(e) === 'iteracion')
924
+ f.iteraciones += 1;
925
+ acc.set(e.ruta, f);
926
+ }
927
+ return [...acc.entries()]
928
+ .map(([ruta, v]) => ({
929
+ ruta,
930
+ ediciones: v.ediciones,
931
+ iteraciones: v.iteraciones,
932
+ sesiones: v.sesiones.size,
933
+ ratio: v.iteraciones / v.ediciones,
934
+ }))
935
+ .filter((f) => f.ediciones >= suelo && f.sesiones >= minSesiones)
936
+ .sort((a, b) => b.ratio - a.ratio);
937
+ }
938
+ /** Una línea por fichero caro. */
939
+ export function lineaDeFicheroQueCuesta(f) {
940
+ return `${Math.round(f.ratio * 100)}% — ${f.iteraciones} de ${f.ediciones} ediciones fueron rehacer, en ${f.sesiones} sesiones distintas`;
941
+ }
942
+ /** Cuántos ficheros caros se enseñan. El resto se dice, no se calla. */
943
+ export const MAX_FICHEROS_CAROS = 5;
944
+ /**
945
+ * El bloque, o `[]` si no hay nada que decir.
946
+ *
947
+ * Vive fuera de `rankingDeFriccion` para tener contrato propio: hoy me han
948
+ * mordido DOS veces bloques que existían, estaban probados y no se pintaban
949
+ * —la fricción que no llegaba al brief del CLI, y la sección que el presupuesto
950
+ * tiraba en silencio—. Un armado sin test es la tercera.
951
+ *
952
+ * Las rutas llegan YA relativas: hacer `path.relative` aquí dentro ataría esta
953
+ * función al disco y dejaría de ser pura.
954
+ */
955
+ export function bloqueDeFicherosCaros(caros) {
956
+ if (caros.length === 0)
957
+ return [];
958
+ const mostrados = caros.slice(0, MAX_FICHEROS_CAROS);
959
+ return [
960
+ '',
961
+ // El total en la cabecera: recortar sin decirlo se lee como «esto es todo».
962
+ `Dónde el agente rehace su propio trabajo (${mostrados.length} de ${caros.length}):`,
963
+ ...mostrados.map((f) => ` ${f.ruta}\n ${lineaDeFicheroQueCuesta(f)}`),
964
+ ' (No dice que esté mal: dice que cuesta. El remedio lo eliges tú.)',
965
+ ];
966
+ }
865
967
  /**
866
968
  * El ranking local, para `changebook friction`.
867
969
  *
@@ -906,11 +1008,23 @@ export function rankingDeFriccion(dir, home, tope = 10, ahoraMs = Date.now()) {
906
1008
  modulo: null,
907
1009
  }))
908
1010
  .sort((a, b) => (b.fecha ?? '').localeCompare(a.fecha ?? ''));
1011
+ // La OTRA señal, calculada UNA vez: 811 iteraciones contra 8 correcciones.
1012
+ // Responde a otra pregunta —«¿qué fichero le cuesta al agente?»— y por eso
1013
+ // puede tener algo que decir justo cuando la primera calla.
1014
+ const caros = ficherosQueCuestan(ediciones).map((f) => ({
1015
+ ...f,
1016
+ ruta: path.relative(dir, f.ruta),
1017
+ }));
1018
+ const bloqueCaros = bloqueDeFicherosCaros(caros);
909
1019
  // VACÍO ES UN DATO, y se dice. Un «no hay nada» a secas se lee igual que un
910
1020
  // instrumento averiado —Invariante 17—, y aquí el caso normal es el vacío:
911
- // medido el 24/08, 8 correcciones en 30 días.
1021
+ // medido el 24/08, 8 correcciones en 30 días. Pero vacío de CORRECCIONES no
1022
+ // es vacío del informe: si hay ficheros caros, el comando sí tiene qué contar.
912
1023
  if (sucesos.length === 0) {
913
- return (`Sin correcciones en los últimos ${VENTANA_DIAS} días.\n` +
1024
+ const cabecera = `Sin correcciones en los últimos ${VENTANA_DIAS} días.`;
1025
+ if (bloqueCaros.length > 0)
1026
+ return [cabecera, ...bloqueCaros].join('\n');
1027
+ return (`${cabecera}\n` +
914
1028
  `(Vacío significa que no has tenido que rehacer trabajo del agente. ` +
915
1029
  `Si el lector estuviera parado lo diría el diagnóstico de aquí arriba.)`);
916
1030
  }
@@ -924,7 +1038,7 @@ export function rankingDeFriccion(dir, home, tope = 10, ahoraMs = Date.now()) {
924
1038
  if (sucesos.length > mostrados.length) {
925
1039
  lineas.push(` … y ${sucesos.length - mostrados.length} más (sube el tope para verlas).`);
926
1040
  }
927
- return lineas.join('\n');
1041
+ return [...lineas, ...bloqueCaros].join('\n');
928
1042
  }
929
1043
  // ── El punto ciego del shell, cerrado por el canal desacoplado ───────────────
930
1044
  //
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * The CLI subcommands feed and connect that memory without the VS Code
9
9
  * extension: login, analyze, sync, init, open.
10
10
  */
11
+ import { auditarSetup, informeDeAuditoria } from "./audit.js";
11
12
  import { readFileSync } from "node:fs";
12
13
  import * as path from "node:path";
13
14
  import { fileURLToPath } from "node:url";
@@ -70,6 +71,10 @@ Usage:
70
71
  changebook friction [dir] Where the agent's work gets redone in this repo,
71
72
  read from the local Claude Code transcripts. Says
72
73
  MUERTO if the repo has edits and it read nothing
74
+ changebook audit [dir] Static check of your agent setup: rules in CLAUDE.md
75
+ that cite files which no longer exist, how much
76
+ context you pay per session, whether the hook is on.
77
+ No account, no network, writes nothing
73
78
  changebook scan [dir] [--json|--card|--badge]
74
79
  Coupling report for any repo, from its git history
75
80
  alone. No account, no network, writes nothing — run it
@@ -363,6 +368,14 @@ async function main() {
363
368
  console.log(rankingDeFriccion(dir));
364
369
  return;
365
370
  }
371
+ case "audit": {
372
+ // Como `silence` y `scan`: sin credenciales, sin red, sin escribir. Todo
373
+ // lo que dice sale de ficheros del repo, así que contesta el mismo día en
374
+ // que alguien lo instala y no puede callarse por un fallo de red.
375
+ const dir = process.argv[3] ?? process.cwd();
376
+ console.log(informeDeAuditoria(auditarSetup(dir)));
377
+ return;
378
+ }
366
379
  case "silence": {
367
380
  // Sin credenciales y sin red: el contador es local por diseño (ver
368
381
  // tallyPath). Se lee del repo, no del servidor, y por eso contesta el
package/dist/sync.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { readFile, writeFile } from 'node:fs/promises';
14
14
  import path from 'node:path';
15
15
  import { aliasesFor, canonicalizeModuleRows, etiquetaPorSlug, projectIdOf, slugModule, } from './aliasDeModulo.js';
16
+ import { consultaDeSucesos } from './friccionDelBrief.js';
16
17
  /**
17
18
  * Cuántas citas de historial sirve el bloque, según el plan (B5, opción A).
18
19
  *
@@ -103,7 +104,7 @@ const MAX_COUPLINGS = 5;
103
104
  //
104
105
  // Subir el techo alimenta al que se lo come. Adelgazada la cabecera a 271
105
106
  // chars, con 2.000 cabe mas contenido REAL que antes con 3.000.
106
- const SYNC_BUDGET_CHARS = 2_000;
107
+ export const SYNC_BUDGET_CHARS = 2_000;
107
108
  // Co-change pair thresholds — same spirit as the web's signals: at least 3
108
109
  // shared analyses and a ≥60% rate before we call it a dependency.
109
110
  const MIN_PAIR_COUNT = 3;
@@ -155,7 +156,7 @@ export async function fetchBriefSection(db, targetDir) {
155
156
  }
156
157
  const projectId = projectIdOf(projectFilter);
157
158
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
158
- const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases },] = await Promise.all([
159
+ const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases }, friccionCruda,] = await Promise.all([
159
160
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
160
161
  projectFilter),
161
162
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -185,6 +186,12 @@ export async function fetchBriefSection(db, targetDir) {
185
186
  .catch(() => []),
186
187
  // Los alias entran en el MISMO Promise.all que ya se hacía: no cuesta ronda.
187
188
  aliasesFor(db, projectId),
189
+ // FRICCIÓN: dónde hubo que rehacer el trabajo. Es la misma consulta que
190
+ // sirve el brief —filtrada por veredicto en el servidor—, así que son 8
191
+ // filas en 30 días y no cuesta ronda tampoco.
192
+ db
193
+ .rest(consultaDeSucesos(projectFilter, Date.now()))
194
+ .catch(() => []),
188
195
  ]);
189
196
  // El punto de estrangulamiento del bloque: se canonicaliza AQUÍ, nada más
190
197
  // traer las filas, y todo lo que hay debajo (el mapa, `hechosCaros`,
@@ -196,6 +203,10 @@ export async function fetchBriefSection(db, targetDir) {
196
203
  const moduleRows = canonicalizeModuleRows(moduleRowsCrudas, aliases);
197
204
  const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
198
205
  const historial = canonicalizeModuleRows(historialCrudo, aliases);
206
+ // LA CUARTA TABLA. La fricción también trae nombre de módulo, así que sin
207
+ // esto una fusión resolvería tres secciones y dejaría la fricción citando el
208
+ // nombre viejo — en la misma pantalla y sin que nada fallara.
209
+ const friccion = canonicalizeModuleRows(friccionCruda, aliases);
199
210
  const health = summarizeHealth(healthRows);
200
211
  // El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
201
212
  // por los analisis que de verdad rompieron algo: es la diferencia entre «este
@@ -217,8 +228,8 @@ export async function fetchBriefSection(db, targetDir) {
217
228
  commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
218
229
  }
219
230
  }
220
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db));
221
- return { section, projectId, projectResolved };
231
+ const { section, omitidas } = buildSectionConInforme(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db), friccion);
232
+ return { section, projectId, projectResolved, omitidas };
222
233
  }
223
234
  /**
224
235
  * Lo que va a cambiar, en corto y para una persona.
@@ -259,12 +270,23 @@ export function resumenDelCambio(previo, siguiente, fichero) {
259
270
  return l.join('\n');
260
271
  }
261
272
  export async function syncContextFiles(db, targetDir, opts = {}) {
262
- const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
273
+ const { section, projectId, projectResolved, omitidas } = await fetchBriefSection(db, targetDir);
263
274
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
264
275
  const file = path.join(targetDir, name);
265
276
  const updated = await upsertSection(file, section, opts);
266
277
  console.error(`${updated} ${name}`);
267
278
  }
279
+ // LO QUE NO CUPO, dicho a la persona. El bloque tiene presupuesto fijo —es
280
+ // coste que se paga en CADA sesión de CADA agente— y hasta hoy una sección
281
+ // que no cabía desaparecía sin dejar rastro en ningún sitio. Se dice aquí y
282
+ // no dentro del bloque: al agente no le falta (la cabecera ya le manda a
283
+ // `atlas_project_brief`, que no tiene tope), y meterlo dentro gastaría
284
+ // presupuesto de todas las sesiones para avisar de que falta presupuesto.
285
+ if (omitidas.length > 0) {
286
+ console.error(`⚠ No cupo todo en ${SYNC_BUDGET_CHARS} chars — pídelo con \`atlas_project_brief\`:`);
287
+ for (const o of omitidas)
288
+ console.error(` · ${o}`);
289
+ }
268
290
  // El sync ES una consulta del atlas — la más apalancada: el mapa que
269
291
  // destila entra en CADA sesión de agente vía CLAUDE.md/AGENTS.md sin
270
292
  // pagar tool calls. Cuenta como lectura (best-effort).
@@ -361,11 +383,31 @@ export function hechosCaros(historial, commitPorAnalisis, tope = 6, maxCitas = C
361
383
  return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
362
384
  });
363
385
  }
364
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
386
+ /**
387
+ * El bloque, y **qué se quedó fuera por tamaño**.
388
+ *
389
+ * POR QUÉ EXISTE ESTA SEGUNDA SALIDA, 24/08: el presupuesto es de suma cero y
390
+ * una sección entera que no cabe desaparecía **en silencio**. `recorta` avisa
391
+ * cuando corta una línea —lleva su elipsis desde el 04/08— pero perder la
392
+ * sección completa no dejaba rastro en ningún sitio.
393
+ *
394
+ * El informe NO va dentro del bloque. Al agente no le falta: la cabecera ya le
395
+ * dice que pida `atlas_project_brief`, que no tiene este tope. A quien le falta
396
+ * es a la PERSONA que corre `changebook sync`, y a ésa se le dice por stderr
397
+ * —una vez, cuando puede hacer algo— en vez de gastar presupuesto de cada
398
+ * sesión para siempre.
399
+ */
400
+ export function buildSectionConInforme(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
365
401
  // B5 · el que paga ve más historial de regresiones. Por defecto, el gratuito:
366
402
  // un fallo al leer el plan tiene que degradar HACIA el plan libre, nunca
367
403
  // regalar el de pago por un error de red.
368
- maxCitas = CITAS_GRATIS) {
404
+ maxCitas = CITAS_GRATIS,
405
+ // Décimo posicional, y no me gusta: esta firma pide ya un objeto de opciones.
406
+ // Se deja posicional a propósito porque cambiarla toca todos los sitios de
407
+ // llamada y todos los contratos que la prueban, y eso no es parte de esta
408
+ // pieza. Por defecto `[]`: un proyecto sin fricción —o una consulta que
409
+ // falló— produce la sección vacía, que desaparece sola.
410
+ friccion = []) {
369
411
  // Newest-first rows: the first occurrence of a module is its latest state.
370
412
  //
371
413
  // POR SLUG (03/08). Agrupaba por `row.module` crudo, asi que un modulo
@@ -462,11 +504,17 @@ maxCitas = CITAS_GRATIS) {
462
504
  '',
463
505
  ];
464
506
  if (modules.length === 0) {
465
- return [
466
- ...head,
467
- '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
468
- END,
469
- ].join('\n');
507
+ // Sin módulos no hay reparto, así que no hay nada omitido — y decir `[]` no
508
+ // es lo mismo que no decir nada: quien lea el informe sabe que aquí no se
509
+ // perdió nada por tamaño, sino que no había datos.
510
+ return {
511
+ section: [
512
+ ...head,
513
+ '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
514
+ END,
515
+ ].join('\n'),
516
+ omitidas: [],
517
+ };
470
518
  }
471
519
  // Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
472
520
  // nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
@@ -523,6 +571,41 @@ maxCitas = CITAS_GRATIS) {
523
571
  ...taskTitles.map((t) => `- ${sanitizeCell(recorta(t, 120))}`),
524
572
  ]
525
573
  : [];
574
+ // ── Fricción: dónde hubo que rehacer el trabajo ────────────────────────────
575
+ //
576
+ // POR QUÉ ENTRA, medido el 24/08 contra producción antes de escribirlo: de los
577
+ // 4 módulos con fricción en 30 días, **3 no aparecen en ninguna otra sección**
578
+ // (`Badge del README`, `Analítica de producto`, `Consolidación de módulos`).
579
+ // El único que se solapa es `Reportes de regresiones`, que ya sale en «costó
580
+ // caro» con 8. Si el solape hubiera sido total, esta sección sería decoración
581
+ // compitiendo por un presupuesto de suma cero contra señales validadas.
582
+ //
583
+ // TRES Y NO CINCO como el brief: esto entra en el contexto de CADA sesión, no
584
+ // se pide. El listado entero se sirve con `changebook friction` y por
585
+ // `atlas_project_brief`.
586
+ //
587
+ // ⚠ LO QUE ESTA SEÑAL NO ES: una regresión confirmada. Dice «aquí hubo que
588
+ // rehacer trabajo», con una precisión que NO está validada (n=15, un repo, y
589
+ // etiquetado por quien escribió el clasificador). Por eso su prioridad de
590
+ // presupuesto es 2 y no 1: cae antes que los avisos abiertos, la salud, los
591
+ // módulos críticos y el historial de regresiones, que sí están medidos.
592
+ //
593
+ // Y ESO TIENE UNA CONSECUENCIA MEDIDA, dicha aquí para que nadie la
594
+ // redescubra: en ESTE repo la sección NO SE PINTA. El bloque sale a 1.998 de
595
+ // 2.000 chars —saturado por tres módulos críticos y cinco entradas de
596
+ // historial— y la sección cuesta 271. Que pierda esa puja es lo correcto:
597
+ // enfrente hay regresiones confirmadas. En un repo con menos historial sí
598
+ // entra (comprobado con `buildSection` sobre un repo nuevo y sobre uno con
599
+ // una regresión: sale en los dos).
600
+ //
601
+ // ⚠ Y el hueco que esto destapa, PREVIO a esta sección y común a todas: una
602
+ // sección descartada por presupuesto es MUDA. `recorta` avisa cuando corta
603
+ // una línea, pero nada avisa cuando cae una sección entera. Mientras siga
604
+ // así, el listado completo se pide con `changebook friction` o por
605
+ // `atlas_project_brief`, que no tienen este tope.
606
+ const friccionLines = friccion
607
+ .slice(0, 3)
608
+ .map((f) => `- ${(f.occurred_at ?? '').slice(8, 10)}/${(f.occurred_at ?? '').slice(5, 7)} · \`${sanitizeCell(recorta(f.path, 80))}\`${f.module ? ` — **${sanitizeCell(f.module)}**` : ''}`);
526
609
  // El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
527
610
  // recorta primero) son independientes. Los dos importan, y hasta el 2026-07-26
528
611
  // solo uno estaba bien.
@@ -578,6 +661,16 @@ maxCitas = CITAS_GRATIS) {
578
661
  title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
579
662
  lines: hechosCaros(historial, commitPorAnalisis, 6, maxCitas),
580
663
  },
664
+ {
665
+ key: 'friccion',
666
+ priority: 2,
667
+ // EL TOTAL VA EN EL TÍTULO. Se enseñan 3 de las que haya, y un recorte
668
+ // mudo se lee como «esto es todo». Meterlo en el título cuesta CERO
669
+ // líneas de presupuesto, que es lo que impide que la honestidad compita
670
+ // con el contenido. El listado entero: `changebook friction`.
671
+ title: `### Dónde hubo que rehacer el trabajo (${friccionLines.length} de ${friccion.length}, últimos 30 días)`,
672
+ lines: friccionLines,
673
+ },
581
674
  // El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
582
675
  // Claude Code recorta por su cuenta «architecture overviews» de un
583
676
  // CLAUDE.md porque el agente los deriva del repo. Lo que no puede derivar
@@ -683,7 +776,28 @@ maxCitas = CITAS_GRATIS) {
683
776
  // se apartó del presupuesto arriba, así que entra siempre.
684
777
  lines.push('', FIRMA);
685
778
  lines.push(END);
686
- return lines.join('\n');
779
+ // LO QUE NO CABIÓ. Una sección con líneas que se quedó con `count` 0 —o con
780
+ // menos líneas de las que traía— no entró entera. Se compara contra lo que la
781
+ // sección TENÍA, no contra un tope fijo: así también se ve la que entró a
782
+ // medias, que es igual de muda.
783
+ const omitidas = sections
784
+ .filter((x) => x.lines.length > 0)
785
+ .filter((x) => (includedCount.get(x.key) ?? 0) < x.lines.length)
786
+ .map((x) => {
787
+ const dentro = includedCount.get(x.key) ?? 0;
788
+ const nombre = x.title.replace(/^#+\s*/, '').replace(/\s*\(.*\)\s*$/, '');
789
+ return dentro === 0
790
+ ? `${nombre} (entera, ${x.lines.length} línea(s))`
791
+ : `${nombre} (${x.lines.length - dentro} de ${x.lines.length} línea(s))`;
792
+ });
793
+ return { section: lines.join('\n'), omitidas };
794
+ }
795
+ /**
796
+ * El bloque, a secas. La forma que usan los diez sitios de llamada que no
797
+ * necesitan el informe.
798
+ */
799
+ export function buildSection(...args) {
800
+ return buildSectionConInforme(...args).section;
687
801
  }
688
802
  /**
689
803
  * Symmetric co-change pairs: modules that appear in the same analyses often
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.8.0",
5
+ "version": "0.9.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.8.0",
18
+ "version": "0.9.1",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }