changebook 0.4.8 → 0.4.10
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 +3 -0
- package/dist/analyze.js +5 -3
- package/dist/context.js +76 -29
- package/dist/credentials.js +42 -0
- package/dist/git.js +77 -2
- package/dist/guard.js +267 -23
- package/dist/impact.js +630 -0
- package/dist/import.js +2 -2
- package/dist/index.js +54 -1
- package/dist/login.js +61 -1
- package/dist/supabase.js +48 -4
- package/dist/sync.js +94 -23
- package/dist/tools.js +101 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/impact.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook impact` — el radio de impacto, EMPUJADO. Un hook `PreToolUse` de
|
|
3
|
+
* Claude Code que, justo antes de que el agente escriba en un archivo, le mete
|
|
4
|
+
* en contexto quién depende de lo que está a punto de tocar.
|
|
5
|
+
*
|
|
6
|
+
* POR QUÉ EXISTE, medido y no supuesto:
|
|
7
|
+
*
|
|
8
|
+
* El 2026-07-26, la tarea T4 del benchmark («vas a cambiar el comportamiento de
|
|
9
|
+
* computeRecidivism, ¿qué más hay que revisar?») se corrió con el atlas
|
|
10
|
+
* instalado y disponible. El agente hizo 32 turnos de grep, gastó 1.464.591
|
|
11
|
+
* tokens y llamó al atlas CERO veces: `atlas_adoption: 0`. El radio de impacto
|
|
12
|
+
* —desplegado, testado y en producción— fue invisible.
|
|
13
|
+
*
|
|
14
|
+
* La lección no es que faltara una tool. Es que un canal que depende de que el
|
|
15
|
+
* agente se acuerde de usarlo es un canal que se puede ignorar, y quien programa
|
|
16
|
+
* a base de «hazme esto» no pregunta nada: no hay turno en el que decida
|
|
17
|
+
* orientarse. Los disparadores literales de `sync.ts` mueven la aguja (60→100%
|
|
18
|
+
* medido el 2026-07-21) pero no la clavan: siguen necesitando que el agente
|
|
19
|
+
* reconozca una frase.
|
|
20
|
+
*
|
|
21
|
+
* Este canal no lo necesita. Salta porque va a editar, y punto.
|
|
22
|
+
*
|
|
23
|
+
* TRES REGLAS, y las tres son de supervivencia del producto:
|
|
24
|
+
*
|
|
25
|
+
* 1. NUNCA bloquea. Se emite `additionalContext` y nada más — sin
|
|
26
|
+
* `permissionDecision`, así que el sistema de permisos del usuario decide
|
|
27
|
+
* como si no estuviéramos. Un hook que puede tumbar una edición se
|
|
28
|
+
* desinstala el día que se equivoca.
|
|
29
|
+
* 2. NUNCA cuesta tiempo perceptible. Corre antes de CADA edición, así que la
|
|
30
|
+
* red se paga una vez cada CACHE_TTL_MS y el resto de las ediciones son una
|
|
31
|
+
* lectura de disco. Con presupuesto duro por encima: pasado eso, silencio.
|
|
32
|
+
* 3. NUNCA repite. Un aviso idéntico en las 20 ediciones seguidas del mismo
|
|
33
|
+
* archivo es ruido que se paga en tokens y que entrena al agente a
|
|
34
|
+
* ignorarlo. Se avisa una vez por (sesión, archivo).
|
|
35
|
+
*
|
|
36
|
+
* Y una cuarta que es la que hace que se lea: SILENCIO cuando no hay nada que
|
|
37
|
+
* decir. Sin dependientes, sin alerta abierta y sin reincidencia, no se emite
|
|
38
|
+
* una sola letra.
|
|
39
|
+
*/
|
|
40
|
+
import { spawn } from "node:child_process";
|
|
41
|
+
import * as fs from "node:fs";
|
|
42
|
+
import * as path from "node:path";
|
|
43
|
+
import { presupuestoDeTiempo } from "./context.js";
|
|
44
|
+
import { execFileAsync, gitPath, projectNameFor } from "./git.js";
|
|
45
|
+
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
46
|
+
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
47
|
+
/**
|
|
48
|
+
* Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
|
|
49
|
+
* este corre por edición y no por sesión: 20 ediciones × 2 s serían 40 s de
|
|
50
|
+
* impuesto sobre el bucle que este producto dice acelerar.
|
|
51
|
+
*/
|
|
52
|
+
const IMPACT_TIMEOUT_MS = 1_200;
|
|
53
|
+
/** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
|
|
54
|
+
const CACHE_TTL_MS = 5 * 60_000;
|
|
55
|
+
/**
|
|
56
|
+
* Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
|
|
57
|
+
* ventanas distintas darían dependientes distintos según se pregunte o se
|
|
58
|
+
* empuje, y el agente no tiene forma de saber cuál de las dos le mintió.
|
|
59
|
+
*/
|
|
60
|
+
const GRAPH_WINDOW_ROWS = 1_000;
|
|
61
|
+
/** Tope de filas de alertas; alimenta reincidencia (all-time) y abiertas. */
|
|
62
|
+
const ALERT_WINDOW_ROWS = 500;
|
|
63
|
+
/** Un archivo vuelve a avisar si se vuelve a él mucho después. */
|
|
64
|
+
const SEEN_TTL_MS = 30 * 60_000;
|
|
65
|
+
/** Cota del archivo de estado: es una nota, no un historial. */
|
|
66
|
+
const SEEN_MAX = 200;
|
|
67
|
+
/** Cuántos módulos se describen si el archivo pertenece a varios. */
|
|
68
|
+
const MAX_MODULES = 3;
|
|
69
|
+
/**
|
|
70
|
+
* Cuánto de la nota del análisis se sirve.
|
|
71
|
+
*
|
|
72
|
+
* SE SIRVE, y ese es el cambio del 2026-07-26 por la tarde. El hook nacía
|
|
73
|
+
* escueto a propósito —dependientes, alertas, reincidencia— y las notas se
|
|
74
|
+
* quedaron fuera por miedo al ruido. Midiendo se vio el coste de esa decisión:
|
|
75
|
+
* la nota de `Carga y arranque de la aplicación` sobre sync.ts dice «el
|
|
76
|
+
* SYNC_BUDGET_CHARS se rebaja de 3.000 a 2.000 porque la prosa gorda expulsaba
|
|
77
|
+
* el mapa», que es EXACTAMENTE el fallo en el que caí dos veces ese mismo día. Y
|
|
78
|
+
* es lo único que ningún grep puede encontrar: no está en el código, está en la
|
|
79
|
+
* historia del análisis. El canal estaba construido y servía lo que git ya sabe.
|
|
80
|
+
*
|
|
81
|
+
* Acotada, no libre: 320 chars por nota y solo en los módulos que YA se
|
|
82
|
+
* reportan (los que tienen dependientes, alerta abierta o reincidencia). Sin el
|
|
83
|
+
* tope, un archivo de diez módulos volcaría diez párrafos y el hook volvería a
|
|
84
|
+
* ser ruido que se aprende a ignorar.
|
|
85
|
+
*
|
|
86
|
+
* 320 y no 220: con 220, la nota de sync.ts se cortaba en «…porque la prosa
|
|
87
|
+
* gord…», justo antes de la conclusión. Estas notas describen primero y concluyen
|
|
88
|
+
* al final, así que un tope corto se come exactamente la carga útil y deja la
|
|
89
|
+
* paja. Tres notas de 320 son ~250 tokens una vez por archivo y sesión.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_NOTE_CHARS = 320;
|
|
92
|
+
/** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
|
|
93
|
+
const MAX_REFUTACIONES = 3;
|
|
94
|
+
export const IMPACT_HOOK = {
|
|
95
|
+
event: "PreToolUse",
|
|
96
|
+
// Mismo `2>/dev/null || true` que CONTEXT_HOOK y por lo mismo: un
|
|
97
|
+
// `changebook` que no está en el PATH de un compañero no puede convertirse en
|
|
98
|
+
// un hook que parece roto en cada edición.
|
|
99
|
+
command: "changebook impact 2>/dev/null || true",
|
|
100
|
+
marker: "changebook impact",
|
|
101
|
+
// Las cuatro tools que escriben en un archivo. El matcher es lo que evita
|
|
102
|
+
// arrancar un proceso por cada Read, Grep o Bash del agente.
|
|
103
|
+
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* La(s) ruta(s) que la tool va a escribir. `file_path` cubre Edit, Write y
|
|
107
|
+
* MultiEdit; `notebook_path` cubre NotebookEdit. Se devuelve lista porque el
|
|
108
|
+
* contrato puede crecer, no porque hoy haya dos.
|
|
109
|
+
*/
|
|
110
|
+
export function pathsFromPayload(payload) {
|
|
111
|
+
const input = payload.tool_input ?? {};
|
|
112
|
+
return [input.file_path, input.notebook_path]
|
|
113
|
+
.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
114
|
+
.map((v) => v.trim());
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Ruta con los enlaces simbólicos resueltos, incluso si la ruta no existe.
|
|
118
|
+
*
|
|
119
|
+
* No es defensa preventiva: sin esto el hook callaba SIEMPRE en macOS. `git
|
|
120
|
+
* rev-parse --show-toplevel` devuelve la ruta real (`/private/var/…`) y el
|
|
121
|
+
* payload del hook trae la que se usó para llegar (`/var/…`, que es un enlace al
|
|
122
|
+
* anterior). Son el mismo directorio, pero `path.relative` entre las dos daba
|
|
123
|
+
* `../../…`, la ruta se descartaba por «caer fuera del árbol», y no se avisaba
|
|
124
|
+
* de nada nunca. Fallo real, cazado por los tests el 2026-07-26.
|
|
125
|
+
*
|
|
126
|
+
* Sube hasta el primer ancestro que existe y recompone el resto, porque
|
|
127
|
+
* `realpathSync` falla con lo inexistente y con `Write` no tiene por qué existir
|
|
128
|
+
* ni el archivo ni sus carpetas.
|
|
129
|
+
*/
|
|
130
|
+
function rutaRealProfunda(p) {
|
|
131
|
+
const abs = path.resolve(p);
|
|
132
|
+
const cola = [];
|
|
133
|
+
let actual = abs;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
const real = fs.realpathSync(actual);
|
|
137
|
+
return cola.length > 0 ? path.join(real, ...cola.reverse()) : real;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const padre = path.dirname(actual);
|
|
141
|
+
if (padre === actual)
|
|
142
|
+
return abs; // se llegó a la raíz sin resolver nada
|
|
143
|
+
cola.push(path.basename(actual));
|
|
144
|
+
actual = padre;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Ruta absoluta (lo que manda el hook) → ruta relativa al repo (lo que guarda
|
|
150
|
+
* el atlas). `null` cuando queda fuera del árbol: un archivo de /tmp o del home
|
|
151
|
+
* no tiene módulo, y colarlo como ruta con `../` produciría cero coincidencias
|
|
152
|
+
* de forma silenciosa.
|
|
153
|
+
*/
|
|
154
|
+
export function repoRelative(toplevel, filePath) {
|
|
155
|
+
const raiz = rutaRealProfunda(toplevel);
|
|
156
|
+
const abs = path.isAbsolute(filePath)
|
|
157
|
+
? rutaRealProfunda(filePath)
|
|
158
|
+
: path.resolve(raiz, filePath);
|
|
159
|
+
const rel = path.relative(raiz, abs);
|
|
160
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
161
|
+
return null;
|
|
162
|
+
// El atlas guarda rutas de git: siempre con "/", también en Windows.
|
|
163
|
+
return rel.split(path.sep).join("/");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* ¿Merece este módulo un aviso?
|
|
167
|
+
*
|
|
168
|
+
* Es LA decisión del archivo. Se dice algo solo cuando hay algo que el agente
|
|
169
|
+
* no puede deducir del archivo que tiene delante: quién depende de él (no está
|
|
170
|
+
* escrito en ninguna parte del código), una alerta abierta (historia), o
|
|
171
|
+
* reincidencia (historia). El riesgo por sí solo NO cuenta: "risk: medium" en
|
|
172
|
+
* cada edición es exactamente el ruido que hace que se deje de leer.
|
|
173
|
+
*/
|
|
174
|
+
export function valeLaPena(m) {
|
|
175
|
+
return (m.dependents.length > 0 || m.alerts.length > 0 || m.priorRegressions >= 2);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* El texto que se inyecta. Deliberadamente calcado a `atlas_file_context`
|
|
179
|
+
* ("↘ DEPENDS ON THIS", "⚠ OPEN ALERT", "prior regressions"): el agente ya sabe
|
|
180
|
+
* qué hacer con esas palabras porque las ve cuando pregunta él. Dos redacciones
|
|
181
|
+
* para el mismo hecho serían dos hechos para él.
|
|
182
|
+
*/
|
|
183
|
+
export function impactText(file, modules) {
|
|
184
|
+
const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
|
|
185
|
+
for (const m of modules.slice(0, MAX_MODULES)) {
|
|
186
|
+
// El riesgo se nombra solo cuando es alto: ver valeLaPena.
|
|
187
|
+
const alto = m.risk === "high" || m.risk === "hotspot";
|
|
188
|
+
lines.push(`- Module **${m.module}**` +
|
|
189
|
+
(alto ? ` · risk: ${m.risk}` : "") +
|
|
190
|
+
(m.priorRegressions >= 2
|
|
191
|
+
? ` · ⚠ ${m.priorRegressions} prior regressions`
|
|
192
|
+
: ""));
|
|
193
|
+
if (m.dependents.length > 0) {
|
|
194
|
+
lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
|
|
195
|
+
}
|
|
196
|
+
for (const a of m.alerts) {
|
|
197
|
+
lines.push(` - ⚠ OPEN ALERT: ${a.plain}`);
|
|
198
|
+
// La condicion, resuelta. Va en su propia linea y con el simbolo delante
|
|
199
|
+
// para que se lea como un hecho comprobado y no como parte de la prosa del
|
|
200
|
+
// modelo, que es justo la que hedgea.
|
|
201
|
+
//
|
|
202
|
+
// Y DICE LAS DOS LECTURAS a proposito, porque el hecho no distingue: un
|
|
203
|
+
// simbolo que sigue apareciendo cuando se esperaba ausente puede ser una
|
|
204
|
+
// referencia que se quedo sin actualizar (el caso `noscriptFor`, que rompio
|
|
205
|
+
// el build) o un aviso que ya no vale (el caso `registerTools`). Antes se
|
|
206
|
+
// elegia la segunda en silencio y se tiraba el aviso; ahora se le dan al
|
|
207
|
+
// agente el hecho y las dos salidas, que es mas informacion que la que
|
|
208
|
+
// habia en cualquiera de las dos versiones anteriores.
|
|
209
|
+
if (a.donde && a.donde.length > 0) {
|
|
210
|
+
lines.push(` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
|
|
211
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`);
|
|
212
|
+
}
|
|
213
|
+
// El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
|
|
214
|
+
// vez de dejar que el agente (o el refutador) la compruebe donde no era —
|
|
215
|
+
// que es lo que tiraba las dos alertas del desfase de esquema.
|
|
216
|
+
if (a.fueraDelRepo) {
|
|
217
|
+
lines.push(` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
|
|
221
|
+
// observación fechada, no estado vigente. Va DESPUÉS de dependientes y
|
|
222
|
+
// alertas porque es contexto, no una orden.
|
|
223
|
+
if (m.note) {
|
|
224
|
+
lines.push(` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (modules.length > MAX_MODULES) {
|
|
228
|
+
lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
|
|
229
|
+
}
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
|
|
234
|
+
* borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
|
|
235
|
+
* aviso vuelve a hacer falta.
|
|
236
|
+
*/
|
|
237
|
+
export function rutasYaAvisadas(state, sessionId, ahora) {
|
|
238
|
+
if (!state || state.session_id !== sessionId)
|
|
239
|
+
return new Set();
|
|
240
|
+
const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
|
|
241
|
+
return new Set(vivas.map(([ruta]) => ruta));
|
|
242
|
+
}
|
|
243
|
+
/** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
|
|
244
|
+
export function estadoSiguiente(state, sessionId, nuevas, ahora) {
|
|
245
|
+
const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
|
|
246
|
+
for (const ruta of nuevas)
|
|
247
|
+
base[ruta] = ahora;
|
|
248
|
+
const podado = Object.entries(base)
|
|
249
|
+
.filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
|
|
250
|
+
.sort((a, b) => b[1] - a[1])
|
|
251
|
+
.slice(0, SEEN_MAX);
|
|
252
|
+
return { session_id: sessionId, seen: Object.fromEntries(podado) };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Módulos a los que pertenece un archivo, del más reciente al más antiguo.
|
|
256
|
+
*
|
|
257
|
+
* UNIÓN de filas y no la última foto, por la misma razón que `moduleFilesUnion`
|
|
258
|
+
* del guardián: los archivos que toca un módulo cambian en cada análisis, y
|
|
259
|
+
* quedarse con la fila más nueva silenciaba coincidencias reales (visto en vivo
|
|
260
|
+
* el 2026-07-18). El riesgo, en cambio, sí es el de la fila más nueva: es
|
|
261
|
+
* estado, no historia.
|
|
262
|
+
*/
|
|
263
|
+
export function modulosDelArchivo(file, rows) {
|
|
264
|
+
const out = new Map();
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
const label = (row.module ?? "").trim();
|
|
267
|
+
if (!label || out.has(label))
|
|
268
|
+
continue;
|
|
269
|
+
const files = Array.isArray(row.files) ? row.files.map(String) : [];
|
|
270
|
+
if (!files.includes(file))
|
|
271
|
+
continue;
|
|
272
|
+
const nota = (row.note ?? "").trim();
|
|
273
|
+
out.set(label, {
|
|
274
|
+
risk: row.risk,
|
|
275
|
+
note: nota
|
|
276
|
+
? nota.length > MAX_NOTE_CHARS
|
|
277
|
+
? `${nota.slice(0, MAX_NOTE_CHARS - 1).trimEnd()}…`
|
|
278
|
+
: nota
|
|
279
|
+
: null,
|
|
280
|
+
noteDate: (row.created_at ?? "").slice(0, 10) || null,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return [...out.entries()].map(([module, v]) => ({ module, ...v }));
|
|
284
|
+
}
|
|
285
|
+
async function cachePath(dir) {
|
|
286
|
+
return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
|
|
287
|
+
}
|
|
288
|
+
function leerJson(file) {
|
|
289
|
+
if (!file)
|
|
290
|
+
return null;
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function escribirJson(file, data) {
|
|
299
|
+
if (!file)
|
|
300
|
+
return;
|
|
301
|
+
try {
|
|
302
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Un .git de solo lectura no puede convertirse en una edición fallida.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function fetchAtlas(db, dir, env) {
|
|
309
|
+
// Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
|
|
310
|
+
// el nombre del directorio, casado por slug del servidor primero.
|
|
311
|
+
const candidate = projectNameFor(dir, env);
|
|
312
|
+
const slug = slugifyProject(candidate);
|
|
313
|
+
let projects = slug
|
|
314
|
+
? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
315
|
+
: [];
|
|
316
|
+
if (projects.length === 0) {
|
|
317
|
+
projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
|
|
318
|
+
}
|
|
319
|
+
const projectId = projects[0]?.id ?? null;
|
|
320
|
+
if (!projectId) {
|
|
321
|
+
return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
|
|
322
|
+
}
|
|
323
|
+
// TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
|
|
324
|
+
// crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
|
|
325
|
+
// que sean tres y no dos: `deps` y `files` viven en la misma tabla, pero no en
|
|
326
|
+
// la misma ventana.
|
|
327
|
+
//
|
|
328
|
+
// El grafo se pide con `deps=not.is.null`, exactamente como tools.ts. Traerlo
|
|
329
|
+
// de la ventana general costó un fallo real, cazado en el E2E del 2026-07-26:
|
|
330
|
+
// de 931 filas recientes solo 613 tenían grafo, así que el mismo módulo salía
|
|
331
|
+
// con 3 dependientes empujado y 4 preguntado. Dos respuestas para el mismo
|
|
332
|
+
// hecho, y el agente sin forma de saber cuál le mintió — el fallo contra el
|
|
333
|
+
// que yo mismo había escrito el comentario de GRAPH_WINDOW_ROWS.
|
|
334
|
+
//
|
|
335
|
+
// Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
|
|
336
|
+
// reincidencia (all-time) salen del mismo conjunto.
|
|
337
|
+
const [rows, deps, alerts] = await Promise.all([
|
|
338
|
+
db
|
|
339
|
+
.rest(`change_module?select=module,files,risk,note,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
340
|
+
.catch(() => []),
|
|
341
|
+
db
|
|
342
|
+
.rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
343
|
+
.catch(() => []),
|
|
344
|
+
db
|
|
345
|
+
.rest(`regression_alerts?select=module,plain,files,resolved_at,evidence_symbol,evidence_expect&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
|
|
346
|
+
.catch(() => []),
|
|
347
|
+
]);
|
|
348
|
+
return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Las señales, SIEMPRE de disco. Nunca de la red.
|
|
352
|
+
*
|
|
353
|
+
* Esto empezó consultando en línea y no funcionaba, y el fallo enseña más que el
|
|
354
|
+
* arreglo: medido contra producción el 2026-07-26, resolver el proyecto tarda
|
|
355
|
+
* ~590 ms y las dos consultas otros ~320 ms. Con el techo por edición no llegaba
|
|
356
|
+
* — y como no llegaba, nunca escribía la caché, así que TODAS las llamadas eran
|
|
357
|
+
* en frío para siempre. Una caché que solo se escribe al final de un camino que
|
|
358
|
+
* no termina no es una caché.
|
|
359
|
+
*
|
|
360
|
+
* Así que el camino crítico no toca la red jamás: lee el archivo, y si está frío
|
|
361
|
+
* o caducado lanza un refresco DESACOPLADO (`impact --warm`) y calla esta vez.
|
|
362
|
+
* Mismo patrón que el hook post-commit. El coste real es que la primera edición
|
|
363
|
+
* de cada ventana de 5 minutos no avisa; a cambio, las otras cuarenta cuestan
|
|
364
|
+
* una lectura de disco. En una sesión de vibe coding ese cambio es todo a favor.
|
|
365
|
+
*/
|
|
366
|
+
async function atlasSignals(db, dir) {
|
|
367
|
+
const file = await cachePath(dir);
|
|
368
|
+
// Sin sitio donde guardar (no es un repo git, .git de solo lectura) no hay
|
|
369
|
+
// canal: calentar sería gastar un proceso y tres consultas para tirar el
|
|
370
|
+
// resultado a la basura.
|
|
371
|
+
if (!file)
|
|
372
|
+
return null;
|
|
373
|
+
const cached = leerJson(file);
|
|
374
|
+
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
|
|
375
|
+
return cached;
|
|
376
|
+
if (db.hasCredentials())
|
|
377
|
+
calentarDesacoplado(dir);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Lanza el refresco de la caché en un proceso aparte y se olvida de él.
|
|
382
|
+
*
|
|
383
|
+
* `stdio: "ignore"` no es higiene, es obligatorio: el stdout del hijo llegaría
|
|
384
|
+
* a la misma tubería que Claude Code lee como salida del hook, y un JSON a
|
|
385
|
+
* medias o dos objetos pegados sería basura inyectada en el contexto del agente.
|
|
386
|
+
* `detached` + `unref` para que el padre pueda morir ya y no retenga la edición.
|
|
387
|
+
*/
|
|
388
|
+
function calentarDesacoplado(dir) {
|
|
389
|
+
try {
|
|
390
|
+
const hijo = spawn(process.execPath, [process.argv[1], "impact", "--warm", dir], {
|
|
391
|
+
detached: true,
|
|
392
|
+
stdio: "ignore",
|
|
393
|
+
});
|
|
394
|
+
hijo.unref();
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Sin poder lanzarlo, la caché se quedará fría y no habrá avisos. Mal, pero
|
|
398
|
+
// no tan mal como que falle una edición.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* `changebook impact --warm [dir]` — el lado desacoplado. Consulta y escribe la
|
|
403
|
+
* caché. Sin stdin, sin stdout, sin código de salida que importe.
|
|
404
|
+
*/
|
|
405
|
+
export async function warmImpactCache(db, dir, env = process.env) {
|
|
406
|
+
try {
|
|
407
|
+
if (!db.hasCredentials())
|
|
408
|
+
return;
|
|
409
|
+
const file = await cachePath(dir);
|
|
410
|
+
if (!file)
|
|
411
|
+
return;
|
|
412
|
+
// Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
|
|
413
|
+
// fresca mientras este arrancaba, no gastar dos veces la misma consulta.
|
|
414
|
+
const cached = leerJson(file);
|
|
415
|
+
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
|
|
416
|
+
return;
|
|
417
|
+
const fresco = await fetchAtlas(db, dir, env);
|
|
418
|
+
// Un calentamiento que NO resolvió el proyecto no se cachea.
|
|
419
|
+
//
|
|
420
|
+
// Cazado el 2026-07-26 montando el banco de pruebas: `impact --warm` sin
|
|
421
|
+
// CHANGEBOOK_PROJECT en un directorio cuyo nombre no es el del proyecto
|
|
422
|
+
// guardaba `{project_id: null, rows: []}`, y `atlasSignals` lo tomaba por
|
|
423
|
+
// caché válida durante CACHE_TTL_MS. Resultado: cinco minutos de silencio
|
|
424
|
+
// que se leen EXACTAMENTE igual que "no hay nada que decir" — la clase de
|
|
425
|
+
// fallo que este producto existe para cazar, dentro del producto.
|
|
426
|
+
//
|
|
427
|
+
// Sin cachearlo, el siguiente intento vuelve a preguntar. Cuesta un
|
|
428
|
+
// calentamiento desacoplado más; callar cinco minutos cuesta el canal.
|
|
429
|
+
if (!fresco.project_id)
|
|
430
|
+
return;
|
|
431
|
+
escribirJson(file, fresco);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
|
|
435
|
+
// avisos, y eso ya lo cubre `hook-impact status`.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ── El cuerpo ────────────────────────────────────────────────────────────────
|
|
439
|
+
/** stdin completo, acotado. El hook manda un objeto pequeño; nada más cabe. */
|
|
440
|
+
async function readStdin(limit = 256 * 1024) {
|
|
441
|
+
const chunks = [];
|
|
442
|
+
let total = 0;
|
|
443
|
+
for await (const chunk of process.stdin) {
|
|
444
|
+
const buf = Buffer.from(chunk);
|
|
445
|
+
total += buf.length;
|
|
446
|
+
if (total > limit)
|
|
447
|
+
break;
|
|
448
|
+
chunks.push(buf);
|
|
449
|
+
}
|
|
450
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
451
|
+
}
|
|
452
|
+
async function buildImpact(db, payload) {
|
|
453
|
+
const rutas = pathsFromPayload(payload);
|
|
454
|
+
if (rutas.length === 0)
|
|
455
|
+
return null;
|
|
456
|
+
const dir = payload.cwd?.trim() || process.cwd();
|
|
457
|
+
// El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
|
|
458
|
+
// el atlas guarda rutas relativas a la raíz del repo.
|
|
459
|
+
const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
|
|
460
|
+
cwd: dir,
|
|
461
|
+
encoding: "utf8",
|
|
462
|
+
})
|
|
463
|
+
.then(({ stdout }) => stdout.trim())
|
|
464
|
+
.catch(() => dir);
|
|
465
|
+
const relativas = [
|
|
466
|
+
...new Set(rutas
|
|
467
|
+
.map((r) => repoRelative(toplevel, r))
|
|
468
|
+
.filter((r) => Boolean(r))),
|
|
469
|
+
];
|
|
470
|
+
if (relativas.length === 0)
|
|
471
|
+
return null;
|
|
472
|
+
const ahora = Date.now();
|
|
473
|
+
const sessionId = payload.session_id?.trim() || "sin-sesion";
|
|
474
|
+
const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
|
|
475
|
+
const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
|
|
476
|
+
const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
|
|
477
|
+
if (pendientes.length === 0)
|
|
478
|
+
return null;
|
|
479
|
+
const signals = await atlasSignals(db, dir);
|
|
480
|
+
if (!signals?.project_id)
|
|
481
|
+
return null;
|
|
482
|
+
const recidivism = computeRecidivism(signals.alerts);
|
|
483
|
+
const abiertas = signals.alerts.filter((a) => !a.resolved_at);
|
|
484
|
+
// Refutación al servir, igual que en atlas_file_context: el mismo grep que
|
|
485
|
+
// corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
|
|
486
|
+
// un grep. Aquí importa el doble: un aviso empujado que ya no es verdad
|
|
487
|
+
// enseña al agente a ignorar los empujados.
|
|
488
|
+
//
|
|
489
|
+
// Acotada a mano, y por una razón que no es cosmética: contarEnRepo usa
|
|
490
|
+
// execFileSync, o sea que BLOQUEA el bucle de eventos y el techo de arriba no
|
|
491
|
+
// puede desalojarlo — un Promise.race no gana a una llamada síncrona. Un
|
|
492
|
+
// archivo con muchas alertas vivas podría comerse el presupuesto entero a
|
|
493
|
+
// 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
|
|
494
|
+
// que es el lado seguro (la duda deja pasar el aviso).
|
|
495
|
+
//
|
|
496
|
+
// El presupuesto lo comparten refutar y localizar, porque son el mismo grep
|
|
497
|
+
// sobre el mismo simbolo y solo uno de los dos aplica a cada aviso: 'present'
|
|
498
|
+
// se refuta, 'absent' se localiza (ver avisoRefutado). Nunca se gastan dos.
|
|
499
|
+
let grepsRestantes = MAX_REFUTACIONES;
|
|
500
|
+
const refutada = (a) => {
|
|
501
|
+
if (a.evidence_expect !== "present")
|
|
502
|
+
return false;
|
|
503
|
+
if (grepsRestantes <= 0)
|
|
504
|
+
return false;
|
|
505
|
+
grepsRestantes -= 1;
|
|
506
|
+
return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
|
|
507
|
+
};
|
|
508
|
+
/** Donde sigue apareciendo el simbolo de un aviso 'absent'. Contesta el condicional. */
|
|
509
|
+
const localizar = (a) => {
|
|
510
|
+
if (a.evidence_expect !== "absent")
|
|
511
|
+
return null;
|
|
512
|
+
if (grepsRestantes <= 0)
|
|
513
|
+
return null;
|
|
514
|
+
grepsRestantes -= 1;
|
|
515
|
+
return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
|
|
516
|
+
};
|
|
517
|
+
const depsRows = signals.deps ?? [];
|
|
518
|
+
const bloques = [];
|
|
519
|
+
const avisadas = [];
|
|
520
|
+
for (const file of pendientes) {
|
|
521
|
+
const modulos = modulosDelArchivo(file, signals.rows);
|
|
522
|
+
if (modulos.length === 0)
|
|
523
|
+
continue;
|
|
524
|
+
const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
|
|
525
|
+
const impactos = modulos
|
|
526
|
+
.map((m) => ({
|
|
527
|
+
module: m.module,
|
|
528
|
+
risk: m.risk,
|
|
529
|
+
note: m.note,
|
|
530
|
+
noteDate: m.noteDate,
|
|
531
|
+
dependents: dependientes.get(m.module) ?? [],
|
|
532
|
+
// Una linea por texto: dos avisos con la MISMA frase no le dejan al
|
|
533
|
+
// agente hacer nada distinto, por mucho que por dentro sean
|
|
534
|
+
// afirmaciones opuestas. Gana el que trae linea de comprobacion.
|
|
535
|
+
alerts: unoPorTexto(abiertas
|
|
536
|
+
.filter((a) => (a.module ?? "").trim() === m.module && a.plain)
|
|
537
|
+
.filter((a) => !refutada(a))
|
|
538
|
+
.map((a) => {
|
|
539
|
+
const donde = localizar(a);
|
|
540
|
+
const fuera = dondeComprobarlo(a);
|
|
541
|
+
return {
|
|
542
|
+
plain: a.plain,
|
|
543
|
+
...(donde
|
|
544
|
+
? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
|
|
545
|
+
: {}),
|
|
546
|
+
...(fuera ? { fueraDelRepo: fuera } : {}),
|
|
547
|
+
};
|
|
548
|
+
}), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)),
|
|
549
|
+
priorRegressions: recidivism.get(m.module) ?? 0,
|
|
550
|
+
}))
|
|
551
|
+
.filter(valeLaPena)
|
|
552
|
+
// Por PELIGRO, no por lo más reciente. Solo caben MAX_MODULES y el orden
|
|
553
|
+
// decide qué se ve: con el orden por recencia, el módulo cuya nota decía
|
|
554
|
+
// «la prosa gorda expulsaba el mapa» —6 regresiones previas, y el fallo
|
|
555
|
+
// exacto en el que caí dos veces el 2026-07-26— quedaba en el «+N more».
|
|
556
|
+
// Servir la nota no vale nada si la nota que importa no entra.
|
|
557
|
+
//
|
|
558
|
+
// Total y determinista: el desempate por nombre mantiene el string estable
|
|
559
|
+
// entre sesiones, que es lo que evita pagar una escritura de caché a 1,25×
|
|
560
|
+
// en vez de una lectura a 0,1×.
|
|
561
|
+
.sort((a, b) => b.alerts.length - a.alerts.length ||
|
|
562
|
+
b.priorRegressions - a.priorRegressions ||
|
|
563
|
+
b.dependents.length - a.dependents.length ||
|
|
564
|
+
a.module.localeCompare(b.module));
|
|
565
|
+
if (impactos.length === 0)
|
|
566
|
+
continue;
|
|
567
|
+
bloques.push(impactText(file, impactos));
|
|
568
|
+
avisadas.push(file);
|
|
569
|
+
}
|
|
570
|
+
// Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
|
|
571
|
+
// nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
|
|
572
|
+
if (avisadas.length > 0) {
|
|
573
|
+
escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
|
|
574
|
+
}
|
|
575
|
+
return bloques.length > 0 ? bloques.join("\n\n") : null;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
|
|
579
|
+
* con el JSON roto o pasado el presupuesto, se emite cero y se sale con 0.
|
|
580
|
+
*
|
|
581
|
+
* Se emite `additionalContext` a secas, sin `permissionDecision`: la decisión de
|
|
582
|
+
* permisos se queda en manos del usuario, exactamente como si este hook no
|
|
583
|
+
* existiera. Y por contrato de Claude Code, salir con 2 bloquearía la edición —
|
|
584
|
+
* de ahí que aquí no se lance nunca nada hacia fuera.
|
|
585
|
+
*/
|
|
586
|
+
export async function printImpact(db) {
|
|
587
|
+
try {
|
|
588
|
+
// UN presupuesto para todo, no uno por etapa: dos carreras de 1,2 s en
|
|
589
|
+
// serie son 2,4 s de impuesto real por edición, que es justo lo que este
|
|
590
|
+
// techo existe para impedir. Se comparte entre leer stdin y leer la caché.
|
|
591
|
+
//
|
|
592
|
+
// Y se cancela en `finally`, no al final del camino feliz: los caminos que
|
|
593
|
+
// CALLAN son los más frecuentes (archivo limpio, ya avisado, caché fría) y
|
|
594
|
+
// cada `return` temprano dejaba el temporizador en pie. Medido el
|
|
595
|
+
// 2026-07-26: 1,39 s por edición silenciosa contra 0,23 s de la que habla —
|
|
596
|
+
// el proceso ya había terminado y se quedaba esperando a su propio reloj.
|
|
597
|
+
const { vencido, cancelar } = presupuestoDeTiempo(IMPACT_TIMEOUT_MS);
|
|
598
|
+
try {
|
|
599
|
+
const raw = await Promise.race([readStdin(), vencido.then(() => "")]);
|
|
600
|
+
if (!raw.trim())
|
|
601
|
+
return;
|
|
602
|
+
let payload;
|
|
603
|
+
try {
|
|
604
|
+
payload = JSON.parse(raw);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const additionalContext = await Promise.race([
|
|
610
|
+
buildImpact(db, payload).catch(() => null),
|
|
611
|
+
vencido,
|
|
612
|
+
]);
|
|
613
|
+
if (!additionalContext)
|
|
614
|
+
return;
|
|
615
|
+
process.stdout.write(JSON.stringify({
|
|
616
|
+
hookSpecificOutput: {
|
|
617
|
+
hookEventName: "PreToolUse",
|
|
618
|
+
additionalContext,
|
|
619
|
+
},
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
cancelar();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
// Editar un archivo no puede fallar por nosotros.
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
//# sourceMappingURL=impact.js.map
|
package/dist/import.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { atlasWebUrl } from "./browser.js";
|
|
13
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
13
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
14
14
|
import { canonicalDiffHash } from "./canonical.js";
|
|
15
15
|
import { optimizeTokensForAI } from "./optimize.js";
|
|
16
16
|
// Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
|
|
@@ -29,7 +29,7 @@ const SKIP_FILE_PATTERNS = [
|
|
|
29
29
|
];
|
|
30
30
|
export async function importHistory(db, options = {}) {
|
|
31
31
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
32
|
-
const projectName =
|
|
32
|
+
const projectName = projectNameFor(cwd);
|
|
33
33
|
const count = Math.max(1, Math.min(options.commits ?? 25, 100));
|
|
34
34
|
// A previous run may have been interrupted after submitting: the Anthropic
|
|
35
35
|
// batch keeps running server-side, so finish those before spending more.
|