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 +4 -0
- package/dist/agregados.js +496 -0
- package/dist/analyze.js +40 -2
- package/dist/friccionDelBrief.js +102 -0
- package/dist/friction.js +1016 -0
- package/dist/git.js +42 -2
- package/dist/guard.js +184 -2
- package/dist/impact.js +172 -17
- package/dist/index.js +31 -1
- package/dist/respuestas.js +111 -0
- package/dist/supabase.js +38 -2
- package/dist/sync.js +36 -5
- package/dist/toolActionPlan.js +98 -0
- package/dist/toolProjectBrief.js +310 -0
- package/dist/toolUsage.js +128 -0
- package/dist/tools.js +332 -332
- package/dist/usage.js +120 -0
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
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
|