changebook 0.7.0 → 0.8.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/README.md CHANGED
@@ -53,6 +53,10 @@ your other servers.
53
53
  | `changebook hook-context install\|uninstall\|status [dir]` | Claude Code `SessionStart` hook: pushes the fresh atlas map into **every** session at turn 0 — no tool call to remember, and generated on the spot so it can't go stale. Writes to `.claude/settings.json`; running the command **is** the consent, and it refuses to touch a config it can't parse. |
54
54
  | `changebook hook-impact install\|uninstall\|status [dir]` | Claude Code `PreToolUse` hook: **before every edit**, tells the agent which modules depend on the file it is about to touch, plus any open alert and repeat-offender history. Never blocks an edit, never touches the network on the critical path (reads a short-lived cache in `.git/` and refreshes it out of band), warns once per file per session, and stays **silent** when there is nothing to say. |
55
55
  | `changebook impact` / `changebook context [dir]` | What those two hooks run. Both read from stdin/disk, print a JSON payload (or nothing) and always exit 0 — you don't call them by hand. |
56
+ | `changebook import [dir] [--commits N]` | Backfill the last N commits (default 25) through the Anthropic Batch API — 50% cheaper and non-interactive, for seeding the atlas on an existing repo. |
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
+ | `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
+ | `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. |
56
60
  | `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. |
57
61
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
58
62
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Los agregados del atlas: funciones puras sobre las filas que sirve el MCP.
3
+ *
4
+ * POR QUE EXISTE. Espejo de `supabase/functions/mcp/scope.ts`. Salen de
5
+ * `tools.ts` el 09/08 para que una herramienta pueda vivir en su propio fichero
6
+ * sin cerrar un ciclo: `toolProjectBrief.ts` necesita `briefModules` y
7
+ * `aggregateFileContext`, y si los pidiera a `tools.ts` -que a su vez lo importa
8
+ * para registrarlo- el modulo se importaria a si mismo por el camino largo.
9
+ *
10
+ * Aqui NO entra nada que sepa del repo local (git, process.cwd) ni de una tool
11
+ * concreta: eso se queda en `tools.ts`. `recordRead` es la unica excepcion y
12
+ * viaja porque la usan TODAS las herramientas y solo necesita el cliente.
13
+ *
14
+ * Varias de estas son espejos del hospedado y su paridad esta fijada por
15
+ * contratos que comparan SALIDAS, no menciones: ver mcpParity, espejoDeCommits,
16
+ * radioDeImpacto y elPaqueteNoPideToolsQueNoTiene.
17
+ */
18
+ import { etiquetaPorSlug, slugModule } from "./aliasDeModulo.js";
19
+ // Pre-edit lookup helpers (mirror of the hosted scope.ts — the npm package
20
+ // must stay self-contained, so these three stay tiny and duplicated).
21
+ // Exported so test/mcpParity.test.ts can pin them equal to the hosted copies:
22
+ // drift would break atlas_file_context on stdio silently (audit M7).
23
+ export function normalizeRepoPath(path) {
24
+ return path.trim().replace(/^\.\//, "").replace(/^\/+/, "");
25
+ }
26
+ export function fileContainsFilter(path) {
27
+ return `files=cs.${encodeURIComponent(JSON.stringify([path]))}`;
28
+ }
29
+ export function quotedInList(values) {
30
+ return values
31
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
32
+ .join(",");
33
+ }
34
+ /**
35
+ * Filtro PostgREST para resolver alertas de regresión. Espejo de
36
+ * supabase/functions/mcp/scope.ts::resolveAlertFilter — paridad de SALIDAS
37
+ * fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
38
+ *
39
+ * `userId` es OPCIONAL aquí y obligatorio allí, y la diferencia no es un
40
+ * descuido: el hospedado consulta con la service key, que se salta RLS, así que
41
+ * el aislamiento de cuenta tiene que escribirlo él a mano en el filtro. Este
42
+ * servidor consulta con el token del usuario, y RLS ya acota cada fila a su
43
+ * dueño. Añadir aquí un `user_id` que no tenemos —el cliente no conoce su
44
+ * propio uuid sin una llamada extra— sería pedirle a PostgREST que filtre por
45
+ * `undefined`, que es peor que no filtrar: casa con cero filas y se lee como
46
+ * «no había nada que cerrar».
47
+ *
48
+ * Se acepta el parámetro para poder comparar las dos implementaciones con la
49
+ * MISMA entrada en un test: sin él, la paridad solo podría afirmarse a medias,
50
+ * que es exactamente lo que la invariante 16 prohíbe.
51
+ */
52
+ export function resolveAlertFilter(input) {
53
+ return ((input.userId ? `user_id=eq.${input.userId}&` : "") +
54
+ `project_id=eq.${input.projectId}` +
55
+ `&module=eq.${encodeURIComponent(input.module)}` +
56
+ `&resolved_at=is.null` +
57
+ (input.symbol
58
+ ? `&evidence_symbol=eq.${encodeURIComponent(input.symbol)}`
59
+ : ""));
60
+ }
61
+ /**
62
+ * El contexto de UN fichero: qué módulos lo tocan, cuántas veces y con qué nota.
63
+ *
64
+ * Espejo de supabase/functions/mcp/scope.ts::aggregateFileContext — paridad de
65
+ * SALIDAS fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
66
+ *
67
+ * ⚠ SE EXTRAE Y ADEMÁS ARREGLA. Esto vivía escrito a mano dentro de
68
+ * `atlas_file_context` y agrupaba por NOMBRE CRUDO (`byModule.get(name)`),
69
+ * mientras el hospedado agrupaba por slug. O sea que en stdio un módulo escrito
70
+ * de dos formas —`Núcleo` y `Nucleo`— servía DOS bloques para el mismo fichero,
71
+ * cada uno con la mitad de los cambios, y el agente los leía como dos módulos
72
+ * que no se conocen. Es la invariante 16 exacta: media normalización se lee
73
+ * igual que una entera, y `canonicalizeModuleRows` resuelve ALIAS, no grafías.
74
+ *
75
+ * El alias se sigue resolviendo antes de llamar aquí; esto es la otra mitad.
76
+ */
77
+ export function aggregateFileContext(file, rows) {
78
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
79
+ const byModule = new Map();
80
+ const changelogIds = [];
81
+ for (const row of rows) {
82
+ if (row.changelog_id && !changelogIds.includes(row.changelog_id)) {
83
+ changelogIds.push(row.changelog_id);
84
+ }
85
+ const name = (row.module ?? "").trim();
86
+ if (!name)
87
+ continue;
88
+ const slug = slugModule(name);
89
+ const existing = byModule.get(slug);
90
+ if (existing) {
91
+ existing.changes += 1;
92
+ }
93
+ else {
94
+ byModule.set(slug, {
95
+ module: etiqueta.get(slug) ?? name,
96
+ risk: row.risk,
97
+ changes: 1,
98
+ last_changed: row.created_at.slice(0, 10),
99
+ last_note: row.note,
100
+ });
101
+ }
102
+ }
103
+ return { file, modules: [...byModule.values()], changelogIds };
104
+ }
105
+ /**
106
+ * El mapa de módulos del brief: los `max` más recientes, y CUÁNTOS hay.
107
+ *
108
+ * Espejo de supabase/functions/mcp/scope.ts::briefModules — paridad de SALIDAS
109
+ * fijada en test/elPaqueteNoPideToolsQueNoTiene.test.ts.
110
+ *
111
+ * AGRUPA POR SLUG, no por el nombre crudo, y ahí estaba el bug que más mentía:
112
+ * `total` contaba 80 módulos donde había 76, y cuatro de ellos eran la mitad
113
+ * pequeña de un módulo partido por una tilde o una mayúscula (invariante 16).
114
+ *
115
+ * Y `total` NO es «todos los módulos del proyecto»: es cuántos distintos hay en
116
+ * la VENTANA de filas que se le pasa. El brief lo dice con esas palabras porque
117
+ * medido el 03/08 la ventana estaba llena al ras, o sea que el número BAJA
118
+ * según se trabaja, y llamarlo «total» hacía leer eso como módulos que
119
+ * desaparecen.
120
+ */
121
+ export function briefModules(rows, max = 15) {
122
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
123
+ const byModule = new Map();
124
+ for (const row of rows) {
125
+ const name = (row.module ?? "").trim();
126
+ if (!name)
127
+ continue;
128
+ const slug = slugModule(name);
129
+ const existing = byModule.get(slug);
130
+ if (existing) {
131
+ existing.changes += 1;
132
+ }
133
+ else {
134
+ byModule.set(slug, {
135
+ module: etiqueta.get(slug) ?? name,
136
+ domain: row.domain ?? null,
137
+ risk: row.risk,
138
+ changes: 1,
139
+ last_changed: row.created_at.slice(0, 10),
140
+ });
141
+ }
142
+ }
143
+ return { modules: [...byModule.values()].slice(0, max), total: byModule.size };
144
+ }
145
+ /** El uuid del proyecto dentro del filtro que devuelve `projectFilterFor`. */
146
+ export function projectIdFromFilter(filter) {
147
+ return /project_id=eq\.([0-9a-f-]+)/.exec(filter)?.[1] ?? null;
148
+ }
149
+ export const FILES_CAP = 8;
150
+ const MAX_DEPENDENTS = 6;
151
+ /** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
152
+ * El mapa de la web dibuja las flechas sobre esta misma ventana: dos
153
+ * ventanas distintas darían dependencias distintas según se mire el dibujo
154
+ * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
155
+ export const MODULE_GRAPH_WINDOW_ROWS = 1000;
156
+ export function dependentsOf(targets, rows) {
157
+ // POR SLUG, NO POR toLowerCase(): media normalizacion se lee igual que una
158
+ // entera (invariante 16). Espejo de supabase/functions/mcp/scope.ts.
159
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
160
+ // Memo de slugModule: se llama una vez por fila y por dep. Espejo de
161
+ // supabase/functions/mcp/scope.ts (medido: 0,510 ms -> 2,698 ms sin esto).
162
+ const memo = new Map();
163
+ const slug = (s) => {
164
+ let v = memo.get(s);
165
+ if (v === undefined) {
166
+ v = slugModule(s);
167
+ memo.set(s, v);
168
+ }
169
+ return v;
170
+ };
171
+ const graph = new Map();
172
+ for (const r of rows) {
173
+ const label = (r.module ?? "").trim();
174
+ if (!label || graph.has(slug(label)))
175
+ continue;
176
+ const deps = Array.isArray(r.deps)
177
+ ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
178
+ : [];
179
+ graph.set(slug(label), deps);
180
+ }
181
+ const reverse = new Map();
182
+ for (const r of rows) {
183
+ const crudo = (r.module ?? "").trim();
184
+ if (!crudo)
185
+ continue;
186
+ const slugFrom = slug(crudo);
187
+ const from = etiqueta.get(slugFrom) ?? crudo;
188
+ for (const to of graph.get(slugFrom) ?? []) {
189
+ const key = slug(to);
190
+ if (key === slugFrom)
191
+ continue;
192
+ const set = reverse.get(key) ?? new Set();
193
+ set.add(from);
194
+ reverse.set(key, set);
195
+ }
196
+ }
197
+ const out = new Map();
198
+ for (const t of targets) {
199
+ const label = (t ?? "").trim();
200
+ if (!label)
201
+ continue;
202
+ const found = reverse.get(slug(label));
203
+ if (!found || found.size === 0)
204
+ continue;
205
+ out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
211
+ * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
212
+ * regresión DISTINTOS (count(distinct plain), all-time), solo los con
213
+ * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
214
+ * infle. No se refuta: cuenta historia, no vigencia.
215
+ */
216
+ /**
217
+ * ESPEJO de `RecidivismMap` en supabase/functions/mcp/scope.ts. Un Map de
218
+ * verdad —se itera y se muestra con la etiqueta buena— cuyo `get`/`has`
219
+ * normalizan la clave por slug antes de buscar, para que ningún consumidor
220
+ * tenga que acordarse de canonicalizar. Ver allí el razonamiento entero.
221
+ */
222
+ export class RecidivismMap extends Map {
223
+ #porSlug = new Map();
224
+ set(clave, valor) {
225
+ this.#porSlug.set(slugModule(clave), valor);
226
+ return super.set(clave, valor);
227
+ }
228
+ get(clave) {
229
+ return super.get(clave) ?? this.#porSlug.get(slugModule(clave));
230
+ }
231
+ has(clave) {
232
+ return super.has(clave) || this.#porSlug.has(slugModule(clave));
233
+ }
234
+ }
235
+ export function computeRecidivism(rows) {
236
+ // Agrupado por slug y presentado con la etiqueta representante: si las
237
+ // alertas de un módulo partido están escritas con las dos grafías, sus
238
+ // regresiones distintas son las de UN módulo, no las de dos.
239
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
240
+ const plainsByModule = new Map();
241
+ for (const r of rows) {
242
+ // SOLO LAS CONFIRMADAS. Hasta el 2026-08-02 esto contaba TODA fila de
243
+ // `regression_alerts`, o sea avisos que el propio atlas se generó: sumaba
244
+ // los confirmados, los que el dueño refutó, los que se cerraron solos al
245
+ // re-tocar el módulo y los 79 que son anteriores al campo `resolution`.
246
+ // Medido en producción: decía 110 y las confirmadas eran 20 — «Análisis de
247
+ // cambios» salía con 13 y tenía CERO.
248
+ //
249
+ // La migración que añadió `resolution` (20260725100000) dice en su propio
250
+ // comentario que existe para impedir «el número bonito y falso». Este
251
+ // contador nunca lo adoptó. Ahora sí.
252
+ //
253
+ // `fixed` lo escribe el agente que actuó sobre el aviso, no un oráculo
254
+ // independiente, así que sigue siendo autocertificado — pero es la mejor
255
+ // etiqueta que hay, y desde luego mejor que no mirar.
256
+ if ((r.resolution ?? "").trim().toLowerCase() !== "fixed")
257
+ continue;
258
+ const crudo = (r.module ?? "").trim();
259
+ const p = (r.plain ?? "").trim();
260
+ if (!crudo || !p)
261
+ continue;
262
+ const m = etiqueta.get(slugModule(crudo)) ?? crudo;
263
+ const set = plainsByModule.get(m) ?? new Set();
264
+ set.add(p);
265
+ plainsByModule.set(m, set);
266
+ }
267
+ const out = new RecidivismMap();
268
+ for (const [m, set] of plainsByModule) {
269
+ if (set.size >= 2)
270
+ out.set(m, set.size);
271
+ }
272
+ return out;
273
+ }
274
+ export function filesUnionByChange(rows, cap = FILES_CAP) {
275
+ const acc = new Map();
276
+ for (const r of rows) {
277
+ if (!Array.isArray(r.files))
278
+ continue;
279
+ let list = acc.get(r.changelog_id);
280
+ if (!list) {
281
+ list = [];
282
+ acc.set(r.changelog_id, list);
283
+ }
284
+ for (const f of r.files) {
285
+ if (typeof f === "string" && f.length > 0 && !list.includes(f)) {
286
+ list.push(f);
287
+ }
288
+ }
289
+ }
290
+ const out = new Map();
291
+ for (const [id, list] of acc) {
292
+ out.set(id, {
293
+ files: list.slice(0, cap),
294
+ more: Math.max(0, list.length - cap),
295
+ });
296
+ }
297
+ return out;
298
+ }
299
+ // Espejo de supabase/functions/mcp/scope.ts (paridad en
300
+ // test/espejoDeCommits.test.ts): aliases post-squash del mismo contenido,
301
+ // para que el hash citado exista en el main del consultante.
302
+ export function commitAliasesShort(raw) {
303
+ if (!Array.isArray(raw))
304
+ return [];
305
+ return raw
306
+ .filter((h) => typeof h === "string" && /^[0-9a-f]{7,64}$/i.test(h))
307
+ .map((h) => h.slice(0, 7));
308
+ }
309
+ export function commitLabel(hash, aliasesRaw) {
310
+ const corto = hash?.slice(0, 7) ?? null;
311
+ if (!corto)
312
+ return null;
313
+ const aliases = commitAliasesShort(aliasesRaw);
314
+ return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
315
+ }
316
+ export const FILE_COMMITS_CAP = 5;
317
+ export function recentCommitsForFile(changelogIds, commitById, cap = FILE_COMMITS_CAP) {
318
+ const all = changelogIds
319
+ .map((id) => commitById.get(id))
320
+ .filter((c) => Boolean(c));
321
+ return { commits: all.slice(0, cap), more: Math.max(0, all.length - cap) };
322
+ }
323
+ // Consultation metering (never billing): each successful read leaves a row in
324
+ // atlas_reads so the web can show "your agent consulted the atlas N times".
325
+ // Best-effort and non-blocking — metering must never break or slow a read.
326
+ // user_id is filled server-side (column default auth.uid()).
327
+ export function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
328
+ const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
329
+ void db
330
+ .insertRow("atlas_reads", {
331
+ project_id: projectId,
332
+ tool,
333
+ source: "stdio",
334
+ chars_served: charsServed,
335
+ // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
336
+ // metering, jamás contrato, y un servidor viejo simplemente no la manda.
337
+ ...(latencyMs !== undefined
338
+ ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
339
+ : {}),
340
+ })
341
+ .catch(() => { });
342
+ }
343
+ /** Reincidente a partir de 3 regresiones distintas. Espejo del hospedado. */
344
+ const RECIDIVIST_MIN = 3;
345
+ /** Prefijo normalizado por el que dos avisos se consideran el mismo. */
346
+ const FIRMA_CHARS = 120;
347
+ /**
348
+ * La firma de un aviso: su prefijo normalizado, sin tildes ni puntuacion.
349
+ * Espejo de supabase/functions/mcp/scope.ts.
350
+ */
351
+ export function firmaDeAviso(plain) {
352
+ return plain
353
+ .toLowerCase()
354
+ .normalize("NFD")
355
+ .replace(/[̀-ͯ]/g, "")
356
+ .replace(/[^a-z0-9]+/g, " ")
357
+ .trim()
358
+ .slice(0, FIRMA_CHARS);
359
+ }
360
+ /**
361
+ * Un aviso por FIRMA, no por texto exacto.
362
+ *
363
+ * `unoPorTexto` ya colapsaba los identicos y no bastaba: dos avisos que
364
+ * comparten las primeras ~200 letras y divergen en la ultima frase se servian
365
+ * los DOS y abrian la pantalla. Para quien lo lee es una repeticion, y un
366
+ * avisador que repite se ignora igual que uno que se equivoca.
367
+ *
368
+ * SOBREVIVE EL MAS LARGO, y eso es lo que lo hace seguro: si dos coinciden al
369
+ * principio y difieren al final, se queda el que trae la diferencia. Colapsar no
370
+ * puede costar informacion que solo estaba en uno.
371
+ *
372
+ * ES LA PANTALLA, NO LA IDENTIDAD: el dedup de creacion sigue distinguiendo por
373
+ * (simbolo, expectativa), porque «tiene que seguir» y «tiene que desaparecer»
374
+ * son afirmaciones opuestas y colapsarlas escondería una regresion real.
375
+ */
376
+ export function colapsaAvisosParecidos(avisos) {
377
+ const mejor = new Map();
378
+ for (const a of avisos) {
379
+ const plain = (a.plain ?? "").trim();
380
+ if (!plain)
381
+ continue;
382
+ const clave = firmaDeAviso(plain);
383
+ if (!clave)
384
+ continue;
385
+ const actual = mejor.get(clave);
386
+ if (!actual || plain.length > (actual.plain ?? "").trim().length) {
387
+ mejor.set(clave, a);
388
+ }
389
+ }
390
+ return [...mejor.values()];
391
+ }
392
+ /**
393
+ * El plan de accion: una sola lista priorizada, lo mas urgente primero.
394
+ *
395
+ * POR TIERS EXPLICITOS Y NO POR UNA PUNTUACION. Un numero oculto obliga a
396
+ * confiar; cuatro tiers con nombre dejan discutir el orden. Espejo de
397
+ * supabase/functions/mcp/scope.ts::buildActionPlan — paridad de SALIDAS fijada
398
+ * en test/elPaqueteNoPideToolsQueNoTiene.
399
+ */
400
+ export function buildActionPlan(input) {
401
+ const items = [];
402
+ // Tier 1 — un cambio analizado ya rompio algo.
403
+ for (const a of colapsaAvisosParecidos(input.alerts)) {
404
+ const plain = (a.plain ?? "").trim();
405
+ if (!plain)
406
+ continue;
407
+ items.push({
408
+ tier: 1,
409
+ kind: "regression",
410
+ module: a.module,
411
+ what: `Regresión abierta${a.module ? ` en ${a.module}` : ""}`,
412
+ why: plain,
413
+ });
414
+ }
415
+ // Tier 2 — un control de salud que el analisis marco roto.
416
+ for (const h of input.atRiskHealth) {
417
+ items.push({
418
+ tier: 2,
419
+ kind: "health",
420
+ module: null,
421
+ what: `Control de salud en riesgo: ${h.check}`,
422
+ why: h.evidence,
423
+ check: h.check,
424
+ });
425
+ }
426
+ // Tier 3 — modulo marcado hotspot (fragil por diseno).
427
+ const seenHotspots = new Set();
428
+ for (const m of input.modules) {
429
+ if (m.risk !== "hotspot")
430
+ continue;
431
+ const previas = input.recidivism.get(m.module);
432
+ items.push({
433
+ tier: 3,
434
+ kind: "hotspot",
435
+ module: m.module,
436
+ what: `Módulo crítico (hotspot): ${m.module}`,
437
+ why: `${m.changes} cambio(s), últ. ${m.last_changed}` +
438
+ (previas ? ` · ${previas} regresiones previas` : ""),
439
+ changes: m.changes,
440
+ lastChanged: m.last_changed,
441
+ ...(previas ? { prior: previas } : {}),
442
+ });
443
+ seenHotspots.add(m.module);
444
+ }
445
+ // Tier 4 — reincidente que NO es ya hotspot (no duplicar).
446
+ for (const [mod, count] of input.recidivism) {
447
+ if (count >= RECIDIVIST_MIN && !seenHotspots.has(mod)) {
448
+ items.push({
449
+ tier: 4,
450
+ kind: "recidivist",
451
+ module: mod,
452
+ what: `Reincidente: ${mod}`,
453
+ why: `${count} regresiones distintas a lo largo del tiempo`,
454
+ count,
455
+ });
456
+ }
457
+ }
458
+ return items
459
+ .map((it, i) => ({ it, i }))
460
+ .sort((a, b) => a.it.tier - b.it.tier || a.i - b.i)
461
+ .map(({ it }, idx) => ({ ...it, rank: idx + 1 }));
462
+ }
463
+ /**
464
+ * La linea de precision que acompana al plan. `null` cuando no hay nada honesto
465
+ * que decir — y esa es la mitad importante: un porcentaje sin refutaciones mide
466
+ * quien juzga, no los avisos. Espejo del hospedado.
467
+ */
468
+ export function lineaDePrecision(p) {
469
+ if (!p)
470
+ return null;
471
+ const juzgadas = p.acertado + p.refutado + p.sin_tocar + p.caducado;
472
+ if (juzgadas === 0)
473
+ return null;
474
+ if (p.precision === null) {
475
+ const unSoloLado = p.motivo === "un_solo_lado" ||
476
+ (p.motivo == null &&
477
+ p.denominador >= p.minimo &&
478
+ (p.acertado === 0 || p.refutado === 0));
479
+ if (unSoloLado) {
480
+ const falta = p.refutado === 0 ? "none refuted" : "none confirmed";
481
+ return (`Warning accuracy: not published yet — ${p.denominador} judged, but ${falta}. ` +
482
+ `A score with only one kind of verdict in it measures the judging, not the warnings.`);
483
+ }
484
+ return (`Warning accuracy: not enough judged warnings yet ` +
485
+ `(${p.denominador} of ${p.minimo} needed). We publish the number only when it means something.`);
486
+ }
487
+ return (`Warning accuracy: ${Math.round(p.precision * 100)}% ` +
488
+ `(${p.acertado} of ${p.denominador} judged warnings were real). ` +
489
+ `Warnings nobody judged are excluded, not counted as misses.`);
490
+ }
491
+ /** Ventana de avisos «abiertos» del plan: 14 dias. Espejo de analysis.ts. */
492
+ const ATLAS_ALERT_WINDOW_DAYS = 14;
493
+ export function alertasDesde() {
494
+ return new Date(Date.now() - ATLAS_ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
495
+ }
496
+ //# sourceMappingURL=agregados.js.map
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
- console.error(`Commit ${meta.hash.slice(0, 8)} has no analyzable diff (merge?). Skipped.`);
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
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * La entrega de la fricción por el MCP **local** (stdio), gemela de
3
+ * `supabase/functions/_shared/friccionConsultas.ts`.
4
+ *
5
+ * POR QUÉ EXISTE, medido el 24/08: el bloque de fricción se implementó sólo en
6
+ * la edge function, así que un agente conectado por el **CLI del paquete npm**
7
+ * —que es como se conecta la mayoría, y como estaba conectada la sesión que
8
+ * escribió esto— no veía la fricción JAMÁS. El lector y el comando
9
+ * `changebook friction` sí estaban en el CLI; lo que faltaba era la entrega.
10
+ *
11
+ * Es la misma deriva entre las dos implementaciones que ya costó las once
12
+ * portadas del paquete: el MCP stdio registraba 4 tools y su propio `sync.ts`
13
+ * pedía 7. Por eso esto vive en un fichero gemelo del del servidor y no
14
+ * mezclado con el lector: `test/laFriccionEsLaMismaEnLosDosLados.test.ts` los
15
+ * compara función a función, y un gemelo que se quede corto pone rojo.
16
+ *
17
+ * El corte es el MISMO que en el servidor: `friction.ts` rinde la línea de un
18
+ * suceso, esto arma la consulta y el bloque.
19
+ *
20
+ * ⚠ LO QUE SIGUE SIN CUBRIRSE, Y ES DELIBERADO: el hospedado sirve además la
21
+ * TASA por fichero en `atlas_file_context`, y el CLI no. No es un olvido: esa
22
+ * línea exige ≥2 correcciones sobre el mismo fichero y, medido el 24/08 contra
23
+ * las 1.509 filas de producción, la cumple **1 fichero de 341**. Pagar una
24
+ * consulta de red en el camino que corre ANTES DE CADA EDICIÓN para eso es un
25
+ * mal cambio. Si algún día la densidad sube, el gemelo que falta es
26
+ * `consultaPorFicheros` + `friccionPorRuta`, y el contrato de paridad de
27
+ * `test/laFriccionEsLaMismaEnLosDosLados.test.ts` es donde se engancha.
28
+ */
29
+ import { lineaDeSuceso, VENTANA_DIAS } from "./friction.js";
30
+ /**
31
+ * El techo de PostgREST. Pedir más no trae más: trae 1.000 y se calla.
32
+ *
33
+ * Aquí casi no muerde, y a propósito: la consulta filtra por veredicto en el
34
+ * servidor, así que lo que cuenta contra el techo son las correcciones (8 en 30
35
+ * días, medido) y no las ediciones (1.509).
36
+ */
37
+ export const TOPE_DE_FILAS = 1000;
38
+ /**
39
+ * Cuántos sucesos como mucho en el brief. Es lo PRIMERO que lee el agente al
40
+ * abrir y ya es largo: cinco caben sin desplazar a los avisos abiertos.
41
+ */
42
+ export const MAX_SUCESOS = 5;
43
+ export function desdeLaVentana(ahoraMs) {
44
+ return new Date(ahoraMs - VENTANA_DIAS * 24 * 60 * 60 * 1000).toISOString();
45
+ }
46
+ /**
47
+ * La consulta de sucesos. Idéntica a la del servidor, y el contrato de paridad
48
+ * lo exige carácter a carácter: dos URLs que difieran en el `order` o en el
49
+ * filtro darían briefs distintos según por dónde te conectes.
50
+ */
51
+ export function consultaDeSucesos(filtroProyecto, ahoraMs) {
52
+ return (`friction_event?select=occurred_at,path,module` +
53
+ `&veredicto=eq.correccion` +
54
+ `&occurred_at=gte.${desdeLaVentana(ahoraMs)}` +
55
+ `&order=occurred_at.desc&limit=${TOPE_DE_FILAS}${filtroProyecto}`);
56
+ }
57
+ export function sucesosDeFriccion(filas) {
58
+ // Se reordena aquí aunque la consulta ya pida `order=occurred_at.desc`: esta
59
+ // función es pura y tiene contrato propio, y un contrato que depende del
60
+ // `order` de OTRO módulo se rompe el día que alguien reutilice ésta con filas
61
+ // de otra procedencia.
62
+ return filas
63
+ .map((f) => ({ fecha: f.occurred_at, ruta: f.path, modulo: f.module }))
64
+ .sort((a, b) => (b.fecha ?? "").localeCompare(a.fecha ?? ""));
65
+ }
66
+ /**
67
+ * Las líneas del bloque del brief, o `[]` si no hay nada que decir.
68
+ *
69
+ * Publica SUCESOS, no una tasa: con 8 correcciones en 30 días un porcentaje no
70
+ * informa de nada, y ocho hechos con su fecha sí. El razonamiento entero está
71
+ * en `docs/superpowers/specs/2026-08-23-mapa-de-friccion-design.md`.
72
+ */
73
+ export function bloqueDeFriccion(filas) {
74
+ if (filas.length === 0)
75
+ return [];
76
+ const truncado = filas.length >= TOPE_DE_FILAS;
77
+ const sucesos = sucesosDeFriccion(filas);
78
+ const mostrados = sucesos.slice(0, MAX_SUCESOS);
79
+ // «al menos» cuando el servidor cortó: el conteo sigue siendo cierto como
80
+ // cota inferior, y decir el número pelado sería afirmar de más.
81
+ const cuantas = truncado ? `at least ${sucesos.length}` : `${sucesos.length}`;
82
+ const lineas = [
83
+ "",
84
+ `## Where work got redone (${cuantas} in the last ${VENTANA_DIAS} days)`,
85
+ ...mostrados.map((s) => `- ${lineaDeSuceso(s)}`),
86
+ ];
87
+ // No recortar en silencio: un tope alcanzado sin decirlo se lee como «esto es
88
+ // todo».
89
+ if (sucesos.length > mostrados.length) {
90
+ lineas.push(`- …and ${sucesos.length - mostrados.length} more.`);
91
+ }
92
+ return lineas;
93
+ }
94
+ /** La forma del structured, armada aquí para que el brief sólo la esparza. */
95
+ export function friccionParaStructured(filas) {
96
+ return sucesosDeFriccion(filas).map((x) => ({
97
+ date: (x.fecha ?? "").slice(0, 10),
98
+ path: x.ruta,
99
+ module: x.modulo,
100
+ }));
101
+ }
102
+ //# sourceMappingURL=friccionDelBrief.js.map