changebook 0.6.0 → 0.7.0

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/sync.js CHANGED
@@ -12,6 +12,28 @@
12
12
  */
13
13
  import { readFile, writeFile } from 'node:fs/promises';
14
14
  import path from 'node:path';
15
+ import { aliasesFor, canonicalizeModuleRows, etiquetaPorSlug, projectIdOf, slugModule, } from './aliasDeModulo.js';
16
+ /**
17
+ * Cuántas citas de historial sirve el bloque, según el plan (B5, opción A).
18
+ *
19
+ * FALLA HACIA EL PLAN GRATUITO, SIEMPRE. Un error de red, una tabla que no
20
+ * responde o una fila que no existe devuelven el gratuito. Al revés —regalar el
21
+ * de pago cuando algo falla— es un muro que se cae solo el día que hay una
22
+ * incidencia, y nadie se entera de que se cayó.
23
+ *
24
+ * `user_plans` está acotada por RLS al usuario de la sesión, así que no hace
25
+ * falta filtrar por id: si hay fila, es la suya.
26
+ */
27
+ export async function citasSegunPlan(db) {
28
+ try {
29
+ const filas = await db.rest('user_plans?select=plan&limit=1');
30
+ const plan = (filas[0]?.plan ?? 'free').trim().toLowerCase();
31
+ return plan && plan !== 'free' ? CITAS_PRO : CITAS_GRATIS;
32
+ }
33
+ catch {
34
+ return CITAS_GRATIS;
35
+ }
36
+ }
15
37
  /** The project identity of the synced repo (same rule as analyze/guard). */
16
38
  function projectNameFor(targetDir) {
17
39
  return (process.env.CHANGEBOOK_PROJECT?.trim() ||
@@ -19,6 +41,11 @@ function projectNameFor(targetDir) {
19
41
  }
20
42
  const START = '<!-- changebook:start -->';
21
43
  const END = '<!-- changebook:end -->';
44
+ // La firma viaja con cada clone y cada PR: el bloque vive en el CLAUDE.md del
45
+ // usuario y todo colaborador (o su agente) que lo abra la ve. Una línea, sin
46
+ // emoji, sin mayúsculas, sin enlace markdown. Se RESERVA del presupuesto antes
47
+ // de repartirlo, así que si algo se recorta es el contenido, nunca la firma.
48
+ const FIRMA = '_Generado por ChangeBook · changebook.app_';
22
49
  // DB text (notes, alert bodies, business impact) is AI-written and gets embedded
23
50
  // between the markers that upsertSection splices on. If any of it contained a
24
51
  // literal marker, the next sync would splice at the wrong offset and corrupt the
@@ -95,9 +122,9 @@ export async function fetchBriefSection(db, targetDir) {
95
122
  projectFilter = '&project_id=eq.00000000-0000-0000-0000-000000000000';
96
123
  projectResolved = false;
97
124
  }
98
- const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
125
+ const projectId = projectIdOf(projectFilter);
99
126
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
100
- const [moduleRows, changes, alerts, historial, pendingTasks, healthRows] = await Promise.all([
127
+ const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases },] = await Promise.all([
101
128
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
102
129
  projectFilter),
103
130
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -109,7 +136,7 @@ export async function fetchBriefSection(db, targetDir) {
109
136
  // HISTORIA de regresiones: todas, sin ventana y sin filtrar por abiertas.
110
137
  // Es lo que sustituye al mapa de modulos en el bloque — ver `hechosCaros`.
111
138
  db
112
- .rest('regression_alerts?select=module,plain,created_at,changelog_id&order=created_at.desc&limit=500' +
139
+ .rest('regression_alerts?select=module,plain,resolution,created_at,changelog_id&order=created_at.desc&limit=500' +
113
140
  projectFilter)
114
141
  .catch(() => []),
115
142
  projectResolved && projectId
@@ -125,7 +152,19 @@ export async function fetchBriefSection(db, targetDir) {
125
152
  .rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
126
153
  projectFilter)
127
154
  .catch(() => []),
155
+ // Los alias entran en el MISMO Promise.all que ya se hacía: no cuesta ronda.
156
+ aliasesFor(db, projectId),
128
157
  ]);
158
+ // El punto de estrangulamiento del bloque: se canonicaliza AQUÍ, nada más
159
+ // traer las filas, y todo lo que hay debajo (el mapa, `hechosCaros`,
160
+ // `coChangePairs`, los avisos) sigue siendo puro y agrupa por nombre sin
161
+ // enterarse. Las TRES tablas, no solo el mapa: el bloque de CLAUDE.md cuenta
162
+ // regresiones por módulo, así que una fusión que resolviera el mapa y no el
163
+ // historial diría «3 regresiones» de un módulo y las listaría bajo otro
164
+ // nombre, en la misma pantalla.
165
+ const moduleRows = canonicalizeModuleRows(moduleRowsCrudas, aliases);
166
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
167
+ const historial = canonicalizeModuleRows(historialCrudo, aliases);
129
168
  const health = summarizeHealth(healthRows);
130
169
  // El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
131
170
  // por los analisis que de verdad rompieron algo: es la diferencia entre «este
@@ -147,9 +186,47 @@ export async function fetchBriefSection(db, targetDir) {
147
186
  commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
148
187
  }
149
188
  }
150
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis);
189
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db));
151
190
  return { section, projectId, projectResolved };
152
191
  }
192
+ /**
193
+ * Lo que va a cambiar, en corto y para una persona.
194
+ *
195
+ * Pura a propósito: la decisión de qué enseñar se prueba sin terminal y sin
196
+ * escribir en disco. El diff entero se deja para quien lo pida — el objetivo de
197
+ * B7 no es volcar un diff, es que quien instala vea que su fichero se respeta.
198
+ */
199
+ export function resumenDelCambio(previo, siguiente, fichero) {
200
+ const nombre = fichero.split('/').pop() ?? fichero;
201
+ if (previo === null) {
202
+ return `Voy a crear ${nombre} (${siguiente.split('\n').length} líneas).`;
203
+ }
204
+ const antes = previo.split('\n');
205
+ const despues = siguiente.split('\n');
206
+ const comunes = new Set(antes);
207
+ const anadidas = despues.filter((l) => l.trim() && !comunes.has(l));
208
+ const vivas = new Set(despues);
209
+ const quitadas = antes.filter((l) => l.trim() && !vivas.has(l));
210
+ // LO QUE MÁS IMPORTA DE ESTE MENSAJE es la primera línea: que el fichero de
211
+ // quien lo lee NO se toca fuera del bloque. Es lo que el producto ya hacía
212
+ // bien y no contaba — y la desconfianza no viene de lo que haces, viene de lo
213
+ // que no se ve.
214
+ const fuera = antes.filter((l) => vivas.has(l)).length;
215
+ const l = [
216
+ `He encontrado tu ${nombre} (${antes.length} líneas). Conservo ${fuera} tal cual`,
217
+ `y solo reescribo el bloque entre los marcadores de ChangeBook:`,
218
+ '',
219
+ ];
220
+ for (const linea of anadidas.slice(0, 4))
221
+ l.push(` + ${linea.slice(0, 90)}`);
222
+ if (anadidas.length > 4)
223
+ l.push(` + … y ${anadidas.length - 4} líneas más`);
224
+ for (const linea of quitadas.slice(0, 2))
225
+ l.push(` - ${linea.slice(0, 90)}`);
226
+ if (quitadas.length > 2)
227
+ l.push(` - … y ${quitadas.length - 2} líneas más`);
228
+ return l.join('\n');
229
+ }
153
230
  export async function syncContextFiles(db, targetDir, opts = {}) {
154
231
  const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
155
232
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
@@ -199,9 +276,31 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
199
276
  * `computeRecidivism`: la misma regresion re-levantada tres veces es UNA, y
200
277
  * contarla tres veces convertiria el churn del generador en falsa gravedad.
201
278
  */
202
- export function hechosCaros(historial, commitPorAnalisis, tope = 6) {
279
+ /**
280
+ * Citas por módulo en el plan gratuito. La línea es una pista para ir a mirar,
281
+ * no un informe.
282
+ */
283
+ export const CITAS_GRATIS = 3;
284
+ /**
285
+ * Y en el de pago (B5, opción A: nadie pierde nada, el que paga gana).
286
+ *
287
+ * ACOTADO, NO ILIMITADO, y el motivo está escrito dos veces en este fichero: el
288
+ * bloque viaja en CADA petición de CADA sesión del usuario, así que es coste
289
+ * FIJO, y `SYNC_BUDGET_CHARS` existe porque ya se intentó subir el techo en vez
290
+ * de adelgazar el contenido y NO funcionó — la sección «Módulos» se quedó fuera
291
+ * igual. «El que paga lo ve todo» sonaría mejor y le costaría tokens en cada
292
+ * turno para leer diez fechas que no va a mirar.
293
+ */
294
+ export const CITAS_PRO = 8;
295
+ export function hechosCaros(historial, commitPorAnalisis, tope = 6, maxCitas = CITAS_GRATIS) {
203
296
  const porModulo = new Map();
204
297
  for (const h of historial) {
298
+ // SOLO LAS CONFIRMADAS, igual que `computeRecidivism`. Esta es la sección
299
+ // que el dueño ve en su CLAUDE.md, así que dejarla contando avisos mientras
300
+ // la tool cuenta regresiones daría DOS cifras distintas para la misma
301
+ // pregunta — que es peor que una cifra mala.
302
+ if ((h.resolution ?? '').trim().toLowerCase() !== 'fixed')
303
+ continue;
205
304
  const modulo = (h.module ?? '').trim();
206
305
  const texto = (h.plain ?? '').trim();
207
306
  if (!modulo || !texto)
@@ -223,24 +322,37 @@ export function hechosCaros(historial, commitPorAnalisis, tope = 6) {
223
322
  .slice(0, tope)
224
323
  .map(([modulo, { textos, citas }]) => {
225
324
  const n = textos.size;
226
- // Tres citas como mucho: la linea es una pista para ir a mirar, no un
227
- // informe. Con mas, la seccion se come el presupuesto del bloque.
325
+ // Cuántas citas caben: ver CITAS_GRATIS / CITAS_PRO.
228
326
  const cuando = citas
229
- .slice(0, 3)
327
+ .slice(0, maxCitas)
230
328
  .map((c) => (c.commit ? `${c.fecha} \`${c.commit}\`` : c.fecha))
231
329
  .join(', ');
232
330
  return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
233
331
  });
234
332
  }
235
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map()) {
333
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
334
+ // B5 · el que paga ve más historial de regresiones. Por defecto, el gratuito:
335
+ // un fallo al leer el plan tiene que degradar HACIA el plan libre, nunca
336
+ // regalar el de pago por un error de red.
337
+ maxCitas = CITAS_GRATIS) {
236
338
  // Newest-first rows: the first occurrence of a module is its latest state.
339
+ //
340
+ // POR SLUG (03/08). Agrupaba por `row.module` crudo, asi que un modulo
341
+ // escrito de dos maneras salia DOS VECES en el bloque que se escribe en
342
+ // CLAUDE.md/AGENTS.md — y ademas gastaba dos de las MAX_MODULES plazas, o
343
+ // sea que expulsaba a un modulo real. Este fichero ya agrupaba por slug en
344
+ // otros dos sitios; esta agregacion se habia quedado fuera, que es la forma
345
+ // exacta de la invariante 16: media normalizacion se lee igual que una
346
+ // entera.
347
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
237
348
  const seen = new Map();
238
349
  for (const row of rows) {
239
- const existing = seen.get(row.module);
350
+ const clave = slugModule((row.module ?? '').trim());
351
+ const existing = seen.get(clave);
240
352
  if (existing)
241
353
  existing.count += 1;
242
354
  else
243
- seen.set(row.module, { ...row, count: 1 });
355
+ seen.set(clave, { ...row, module: etiqueta.get(clave) ?? row.module, count: 1 });
244
356
  }
245
357
  const modules = [...seen.values()].slice(0, MAX_MODULES);
246
358
  const head = [
@@ -294,7 +406,16 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
294
406
  // tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
295
407
  // habla quien programa con un agente. Quien no pregunta nada no reconoce un
296
408
  // disparador abstracto: hay que nombrar sus verbos.
297
- 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
409
+ //
410
+ // Y el 2026-08-02 se vio la MITAD QUE FALTABA. Habia disparador para
411
+ // registrar cambios y ninguno para juzgar avisos, y el resultado esta
412
+ // medido: de 119 alertas, 79 sin motivo ninguno. No es que nadie las
413
+ // mirara — mirar y APUNTAR son dos actos distintos y solo uno era gratis.
414
+ // Ese dia yo mismo verifique un aviso, lo arregle y no lo cerre hasta que
415
+ // el dueño me pregunto por que seguia abierto. `atlas_resolve_alert` entra
416
+ // con sus verbos («comprobar», «arreglar») por la misma ley de arriba, y
417
+ // pagando su precio: cada char aqui le quita sitio al mapa.
418
+ 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis). Al comprobar o arreglar un aviso: `atlas_resolve_alert` (si era falso, `verdict:"dismissed"`).',
298
419
  '',
299
420
  'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context`.',
300
421
  '',
@@ -424,7 +545,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
424
545
  key: 'costoso',
425
546
  priority: 1,
426
547
  title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
427
- lines: hechosCaros(historial, commitPorAnalisis),
548
+ lines: hechosCaros(historial, commitPorAnalisis, 6, maxCitas),
428
549
  },
429
550
  // El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
430
551
  // Claude Code recorta por su cuenta «architecture overviews» de un
@@ -439,7 +560,10 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
439
560
  lines: changeLines,
440
561
  },
441
562
  ];
442
- let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
563
+ // La firma se aparta del fondo ANTES del reparto (línea en blanco separadora
564
+ // + la propia línea): lo que compita por presupuesto lo hace sobre lo que
565
+ // sobra, de modo que el recorte cae siempre en el contenido, nunca en la firma.
566
+ let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length - (FIRMA.length + 2);
443
567
  // ── El suelo del mapa ──────────────────────────────────────────────────────
444
568
  //
445
569
  // El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
@@ -524,6 +648,9 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
524
648
  lines.push(s.title, ...s.lines.slice(0, count));
525
649
  first = false;
526
650
  }
651
+ // La firma, al final del bloque y antes del marcador de cierre. Su hueco ya
652
+ // se apartó del presupuesto arriba, así que entra siempre.
653
+ lines.push('', FIRMA);
527
654
  lines.push(END);
528
655
  return lines.join('\n');
529
656
  }
@@ -571,11 +698,17 @@ const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A
571
698
  */
572
699
  const RATIO_MODULO_DE_PRUEBAS = 0.8;
573
700
  export function modulosDePruebas(rows) {
701
+ // Por slug: si el módulo va partido en dos etiquetas, cada mitad se evaluaría
702
+ // contra el umbral por su cuenta y una podría pasar por «de pruebas» mientras
703
+ // la otra no. El Set devuelto lleva la etiqueta representante, que es la
704
+ // misma que usa coChangePairs, para que el `dePruebas.has(...)` de allí case.
705
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
574
706
  const porModulo = new Map();
575
707
  for (const r of rows) {
576
- const label = (r.module ?? '').trim();
577
- if (!label || !Array.isArray(r.files))
708
+ const crudo = (r.module ?? '').trim();
709
+ if (!crudo || !Array.isArray(r.files))
578
710
  continue;
711
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
579
712
  const set = porModulo.get(label) ?? new Set();
580
713
  for (const f of r.files) {
581
714
  if (typeof f === 'string' && f.trim())
@@ -597,11 +730,19 @@ export function modulosDePruebas(rows) {
597
730
  return fuera;
598
731
  }
599
732
  export function coChangePairs(rows) {
733
+ // LA ARISTA QUE FALTA NO LA VE NADIE. Un módulo partido en dos etiquetas
734
+ // reparte sus apariciones entre las dos mitades: con 13+1 se pierde poco,
735
+ // pero con un reparto parejo —8 y 6 contra un umbral de 10— las DOS mitades
736
+ // caen por debajo de MIN_PAIR_COUNT y el acoplamiento real desaparece del
737
+ // grafo. El fantasma del brief se ve y se denuncia; esto no lo nota nadie
738
+ // nunca, y es justo lo que el producto promete enseñar.
739
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
600
740
  const byAnalysis = new Map();
601
741
  for (const r of rows) {
602
- const label = (r.module ?? '').trim();
603
- if (!label)
742
+ const crudo = (r.module ?? '').trim();
743
+ if (!crudo)
604
744
  continue;
745
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
605
746
  let set = byAnalysis.get(r.changelog_id);
606
747
  if (!set) {
607
748
  set = new Set();
@@ -647,11 +788,25 @@ export async function upsertSection(file, section, opts = {}) {
647
788
  catch {
648
789
  content = null;
649
790
  }
791
+ /** Escribe, salvo que quien mira diga que no. */
792
+ const escribir = async (siguiente, resultado) => {
793
+ if (opts.confirmar) {
794
+ const ok = await opts.confirmar({
795
+ fichero: file,
796
+ previo: content,
797
+ siguiente,
798
+ resumen: resumenDelCambio(content, siguiente, file),
799
+ });
800
+ if (!ok)
801
+ return 'skipped';
802
+ }
803
+ await writeFile(file, siguiente);
804
+ return resultado;
805
+ };
650
806
  if (content === null) {
651
807
  if (opts.refreshOnly)
652
808
  return 'skipped';
653
- await writeFile(file, section + '\n');
654
- return 'created';
809
+ return escribir(section + '\n', 'created');
655
810
  }
656
811
  // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
657
812
  // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
@@ -679,13 +834,13 @@ export async function upsertSection(file, section, opts = {}) {
679
834
  const next = content.slice(0, start) + section + content.slice(end + END.length);
680
835
  // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
681
836
  // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
837
+ // Y NO SE PREGUNTA cuando no hay nada que cambiar: una pregunta cuya única
838
+ // respuesta útil es «sí, no hagas nada» es ruido que enseña a decir que sí.
682
839
  if (next === content)
683
840
  return 'unchanged';
684
- await writeFile(file, next);
685
- return 'updated';
841
+ return escribir(next, 'updated');
686
842
  }
687
843
  const separator = content.endsWith('\n') ? '\n' : '\n\n';
688
- await writeFile(file, content + separator + section + '\n');
689
- return 'appended';
844
+ return escribir(content + separator + section + '\n', 'appended');
690
845
  }
691
846
  //# sourceMappingURL=sync.js.map