changebook 0.4.8 → 0.4.9

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
@@ -50,6 +50,9 @@ your other servers.
50
50
  | `changebook analyze [dir]` | Analyze the repo's uncommitted changes (`git diff HEAD`) and update the atlas — same pipeline as the VS Code extension, no editor needed. |
51
51
  | `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
52
52
  | `changebook hook install\|uninstall\|status [dir]` | Git hooks: every new commit is analyzed in the background (post-commit, never blocks), and the signal guard warns before you commit to a module with an open alert (pre-commit). One pair of hooks covers Claude Code, Codex and manual commits — they all commit through git. |
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
+ | `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
+ | `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. |
53
56
  | `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. |
54
57
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
55
58
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
package/dist/analyze.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import { atlasWebUrl } from "./browser.js";
9
- import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
9
+ import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
10
10
  import { canonicalDiffHash } from "./canonical.js";
11
11
  import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
12
12
  export async function analyze(db, options = {}) {
@@ -124,7 +124,9 @@ async function commitMeta(cwd, ref) {
124
124
  }
125
125
  async function gitDiffHead(cwd) {
126
126
  try {
127
- const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0"], {
127
+ // Mismos ficheros excluidos que en commitDiff: el atlas no se analiza a sí
128
+ // mismo (ver FICHEROS_GENERADOS).
129
+ const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0", "--", ...FICHEROS_GENERADOS], {
128
130
  cwd,
129
131
  encoding: "utf8",
130
132
  maxBuffer: GIT_MAX_BUFFER_BYTES,
package/dist/context.js CHANGED
@@ -28,6 +28,38 @@ import { fetchBriefSection } from "./sync.js";
28
28
  import { commitAliasesShort, derivaContraHead } from "./tools.js";
29
29
  /** Hard ceiling on the critical path: past this, emit nothing and move on. */
30
30
  const CONTEXT_TIMEOUT_MS = 2_000;
31
+ /**
32
+ * A timeout that can lose the race without punishing the winner.
33
+ *
34
+ * The naive form — `new Promise(r => setTimeout(r, ms))` raced against the real
35
+ * work — has a bug that measurement makes obvious and reading never does: when
36
+ * the work wins, the timer is still pending, and Node will not exit until it
37
+ * fires. The process is done and just sits there. Measured 2026-07-26:
38
+ * `changebook context` took 2.18s wall clock to produce a payload it already had
39
+ * in ~200ms, on the session-start path, under a doc comment promising it would
40
+ * "never block it, slow it, or exit non-zero". It had been slowing every session
41
+ * by two seconds.
42
+ *
43
+ * `unref()` is the fix: the timer still fires if anything else is keeping the
44
+ * loop alive, but it never keeps the loop alive by itself. `cancelar()` is belt
45
+ * and braces for the long-lived case (the MCP server), where an unref'd timer
46
+ * would still fire pointlessly.
47
+ *
48
+ * Shared by both hooks on purpose. The defect existed independently in two
49
+ * places, which is the usual sign that the shape — not the instance — was wrong.
50
+ */
51
+ export function presupuestoDeTiempo(ms) {
52
+ let cancelar = () => { };
53
+ const vencido = new Promise((resolve) => {
54
+ const t = setTimeout(() => resolve(null), ms);
55
+ t.unref?.();
56
+ cancelar = () => {
57
+ clearTimeout(t);
58
+ resolve(null);
59
+ };
60
+ });
61
+ return { vencido, cancelar };
62
+ }
31
63
  async function buildPayload(db, dir) {
32
64
  // Logged out → silent no-op. A fresh clone with the hook installed but no
33
65
  // session must open exactly as fast as before.
@@ -60,24 +92,20 @@ async function buildPayload(db, dir) {
60
92
  }
61
93
  return drift ? `${section}\n\n${drift}` : section;
62
94
  }
63
- // ── SessionStart hook install (opt-in, per repo) ─────────────────────────────
64
- //
65
- // The push channel lives in `.claude/settings.json` under hooks.SessionStart.
66
- // Consent is the act of running `changebook hook-context install` (or saying
67
- // yes to init's offer) — this file is often committed and shared, so we NEVER
68
- // write it silently and NEVER clobber a config we can't parse.
69
95
  // `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
70
96
  // shell error is swallowed and the hook exits 0 — a missing binary must not
71
- // make a session-start hook look failed. printContext already emits only the
72
- // JSON payload (or nothing) on stdout.
73
- const HOOK_COMMAND = "changebook context 2>/dev/null || true";
74
- // Recognisable substring to find/remove our own entry without touching others.
75
- const HOOK_MARKER = "changebook context";
97
+ // make a hook look failed. Both commands emit only the JSON payload (or
98
+ // nothing) on stdout.
99
+ export const CONTEXT_HOOK = {
100
+ event: "SessionStart",
101
+ command: "changebook context 2>/dev/null || true",
102
+ marker: "changebook context",
103
+ };
76
104
  function settingsPath(dir) {
77
105
  return path.join(path.resolve(dir), ".claude", "settings.json");
78
106
  }
79
- function groupHasMarker(g) {
80
- return (g.hooks ?? []).some((h) => (h.command ?? "").includes(HOOK_MARKER));
107
+ function groupHasMarker(g, marker) {
108
+ return (g.hooks ?? []).some((h) => (h.command ?? "").includes(marker));
81
109
  }
82
110
  function readSettings(file) {
83
111
  let raw;
@@ -97,53 +125,71 @@ function readSettings(file) {
97
125
  return null; // unparseable → caller must refuse, never clobber
98
126
  }
99
127
  }
100
- export function contextHookInstalled(dir) {
128
+ export function settingsHookInstalled(dir, spec) {
101
129
  const s = readSettings(settingsPath(dir));
102
- return Boolean(s?.hooks?.SessionStart?.some(groupHasMarker));
130
+ return Boolean(s?.hooks?.[spec.event]?.some((g) => groupHasMarker(g, spec.marker)));
103
131
  }
104
- export function installContextHook(dir) {
132
+ export function installSettingsHook(dir, spec) {
105
133
  const file = settingsPath(dir);
106
134
  const settings = readSettings(file);
107
135
  if (settings === null) {
108
136
  throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
109
137
  }
110
138
  const hooks = (settings.hooks ??= {});
111
- const sessionStart = (hooks.SessionStart ??= []);
112
- if (sessionStart.some(groupHasMarker))
139
+ const list = (hooks[spec.event] ??= []);
140
+ if (list.some((g) => groupHasMarker(g, spec.marker)))
113
141
  return "already";
114
- sessionStart.push({
115
- hooks: [{ type: "command", command: HOOK_COMMAND }],
142
+ // El matcher solo se escribe si lo hay: un `matcher` presente y vacío es
143
+ // "todas las tools" en Claude Code, y en SessionStart no significa nada.
144
+ // Escribir la clave igualmente dejaría un campo mudo en un archivo que la
145
+ // gente lee y commitea.
146
+ list.push({
147
+ ...(spec.matcher ? { matcher: spec.matcher } : {}),
148
+ hooks: [{ type: "command", command: spec.command }],
116
149
  });
117
150
  fs.mkdirSync(path.dirname(file), { recursive: true });
118
151
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
119
152
  return "installed";
120
153
  }
121
- export function uninstallContextHook(dir) {
154
+ export function uninstallSettingsHook(dir, spec) {
122
155
  const file = settingsPath(dir);
123
156
  const settings = readSettings(file);
124
- if (!settings || !settings.hooks?.SessionStart)
157
+ if (!settings?.hooks?.[spec.event])
125
158
  return "absent";
126
- const before = settings.hooks.SessionStart;
159
+ const before = settings.hooks[spec.event];
127
160
  // Drop our command from every group, then drop groups left empty. Foreign
128
161
  // hooks in the same group (unusual, but possible) are preserved.
129
162
  const after = before
130
163
  .map((g) => ({
131
164
  ...g,
132
- hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(HOOK_MARKER)),
165
+ hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(spec.marker)),
133
166
  }))
134
167
  .filter((g) => (g.hooks ?? []).length > 0);
135
- if (after.length === before.length && before.every((g) => !groupHasMarker(g))) {
168
+ if (after.length === before.length &&
169
+ before.every((g) => !groupHasMarker(g, spec.marker))) {
136
170
  return "absent";
137
171
  }
138
172
  if (after.length > 0)
139
- settings.hooks.SessionStart = after;
173
+ settings.hooks[spec.event] = after;
140
174
  else
141
- delete settings.hooks.SessionStart;
175
+ delete settings.hooks[spec.event];
142
176
  if (Object.keys(settings.hooks).length === 0)
143
177
  delete settings.hooks;
144
178
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
145
179
  return "removed";
146
180
  }
181
+ // Envoltorios del canal SessionStart. Se mantienen con su nombre porque son la
182
+ // superficie que ya consume index.ts (y, a través de él, `init`): generalizar
183
+ // por dentro no es motivo para renombrar por fuera.
184
+ export function contextHookInstalled(dir) {
185
+ return settingsHookInstalled(dir, CONTEXT_HOOK);
186
+ }
187
+ export function installContextHook(dir) {
188
+ return installSettingsHook(dir, CONTEXT_HOOK);
189
+ }
190
+ export function uninstallContextHook(dir) {
191
+ return uninstallSettingsHook(dir, CONTEXT_HOOK);
192
+ }
147
193
  /**
148
194
  * The pulse and the brief, composed. Separate from printContext so the WHOLE
149
195
  * thing — including the pulse — stays inside the single timeout race: adding
@@ -170,11 +216,12 @@ async function buildSessionContext(db, dir) {
170
216
  }
171
217
  export async function printContext(db, dir) {
172
218
  try {
173
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CONTEXT_TIMEOUT_MS));
219
+ const { vencido, cancelar } = presupuestoDeTiempo(CONTEXT_TIMEOUT_MS);
174
220
  const additionalContext = await Promise.race([
175
221
  buildSessionContext(db, dir),
176
- timeout,
222
+ vencido,
177
223
  ]);
224
+ cancelar();
178
225
  if (!additionalContext)
179
226
  return;
180
227
  // The SessionStart contract: stdout JSON whose additionalContext is
@@ -61,6 +61,48 @@ export function clearCredentials() {
61
61
  return false;
62
62
  }
63
63
  }
64
+ const INFLIGHT_FILE = path.join(DIR, "refresh-inflight.json");
65
+ /**
66
+ * Deja constancia en disco de que ALGUIEN va a gastar el refresh token.
67
+ *
68
+ * Supabase lo rota **al recibir la peticion**, asi que entre "sale la peticion"
69
+ * y "guardamos el token nuevo" hay una ventana en la que el token del disco ya
70
+ * esta muerto sin que nadie lo sepa. Si el proceso no sobrevive a esa ventana
71
+ * —el `analyze` post-commit corre desacoplado en segundo plano, y basta con
72
+ * apagar el equipo o matar la terminal— el siguiente arranque reenvia el viejo,
73
+ * Supabase lo trata como robo y cierra la sesion.
74
+ *
75
+ * Esta marca no PUEDE evitarlo: nada del lado del cliente sobrevive a un
76
+ * `kill -9`. Lo que evita es que sea inexplicable. Sin ella, el usuario ve
77
+ * "Invalid Refresh Token: Already Used" y no tiene forma de saber por que; con
78
+ * ella se le puede decir cuando se quedo a medias la renovacion.
79
+ */
80
+ export function markRefreshInFlight(now = new Date()) {
81
+ try {
82
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
83
+ fs.writeFileSync(INFLIGHT_FILE, JSON.stringify({ at: now.toISOString(), pid: process.pid }), { mode: 0o600 });
84
+ }
85
+ catch {
86
+ // Es un diagnostico, no una garantia: si no se puede escribir, seguimos.
87
+ }
88
+ }
89
+ export function clearRefreshInFlight() {
90
+ try {
91
+ fs.rmSync(INFLIGHT_FILE, { force: true });
92
+ }
93
+ catch {
94
+ // idem
95
+ }
96
+ }
97
+ export function readRefreshInFlight() {
98
+ try {
99
+ const data = JSON.parse(fs.readFileSync(INFLIGHT_FILE, "utf8"));
100
+ return typeof data.at === "string" ? data : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
64
106
  const LOCK_FILE = path.join(DIR, "refresh.lock");
65
107
  function sleep(ms) {
66
108
  return new Promise((resolve) => setTimeout(resolve, ms));
package/dist/git.js CHANGED
@@ -60,12 +60,44 @@ export function usableSummary(message) {
60
60
  return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
61
61
  }
62
62
  /** `git show <hash> -U0` — the unified diff of a single commit, no context. */
63
+ /**
64
+ * Ficheros que ESCRIBE el propio ChangeBook y que por tanto no puede analizar.
65
+ *
66
+ * `sync` regenera el bloque del mapa dentro de CLAUDE.md/AGENTS.md, y `analyze`
67
+ * lo dispara en cada commit. Sin excluirlos se cierra un bucle: commiteas el
68
+ * mapa regenerado -> el análisis lo ve -> nace/actualiza el módulo "Mapeo de
69
+ * módulos" -> pasa a ser el más reciente -> el mapa se reordena -> vuelve a
70
+ * estar sucio. Cuesta un diff de una línea cada dos por tres y el 2026-07-25
71
+ * costó un conflicto de merge real.
72
+ *
73
+ * No es una preferencia de ruido: una herramienta que se mide a sí misma
74
+ * inventa actividad que no existe. Los 6 "cambios" del módulo del mapa son
75
+ * exactamente eso.
76
+ *
77
+ * Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
78
+ * salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
79
+ * mapa no es un cambio de producto.
80
+ */
81
+ export const FICHEROS_GENERADOS = [
82
+ ":(exclude)CLAUDE.md",
83
+ ":(exclude)AGENTS.md",
84
+ ":(exclude)**/CLAUDE.md",
85
+ ":(exclude)**/AGENTS.md",
86
+ ];
63
87
  export async function commitDiff(cwd, hash) {
64
88
  try {
65
89
  const { stdout } = await execFileAsync("git",
66
90
  // --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
67
91
  // as a revision, never as a git option (argument injection).
68
- ["show", "--pretty=format:", "-U0", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
92
+ [
93
+ "show",
94
+ "--pretty=format:",
95
+ "-U0",
96
+ "--end-of-options",
97
+ hash,
98
+ "--",
99
+ ...FICHEROS_GENERADOS,
100
+ ], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
69
101
  return stdout;
70
102
  }
71
103
  catch (error) {
package/dist/impact.js ADDED
@@ -0,0 +1,512 @@
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 } from "./git.js";
45
+ import { avisoRefutado, contarEnRepo, slugifyProject, } 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
+ /** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
70
+ const MAX_REFUTACIONES = 3;
71
+ export const IMPACT_HOOK = {
72
+ event: "PreToolUse",
73
+ // Mismo `2>/dev/null || true` que CONTEXT_HOOK y por lo mismo: un
74
+ // `changebook` que no está en el PATH de un compañero no puede convertirse en
75
+ // un hook que parece roto en cada edición.
76
+ command: "changebook impact 2>/dev/null || true",
77
+ marker: "changebook impact",
78
+ // Las cuatro tools que escriben en un archivo. El matcher es lo que evita
79
+ // arrancar un proceso por cada Read, Grep o Bash del agente.
80
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
81
+ };
82
+ /**
83
+ * La(s) ruta(s) que la tool va a escribir. `file_path` cubre Edit, Write y
84
+ * MultiEdit; `notebook_path` cubre NotebookEdit. Se devuelve lista porque el
85
+ * contrato puede crecer, no porque hoy haya dos.
86
+ */
87
+ export function pathsFromPayload(payload) {
88
+ const input = payload.tool_input ?? {};
89
+ return [input.file_path, input.notebook_path]
90
+ .filter((v) => typeof v === "string" && v.trim().length > 0)
91
+ .map((v) => v.trim());
92
+ }
93
+ /**
94
+ * Ruta con los enlaces simbólicos resueltos, incluso si la ruta no existe.
95
+ *
96
+ * No es defensa preventiva: sin esto el hook callaba SIEMPRE en macOS. `git
97
+ * rev-parse --show-toplevel` devuelve la ruta real (`/private/var/…`) y el
98
+ * payload del hook trae la que se usó para llegar (`/var/…`, que es un enlace al
99
+ * anterior). Son el mismo directorio, pero `path.relative` entre las dos daba
100
+ * `../../…`, la ruta se descartaba por «caer fuera del árbol», y no se avisaba
101
+ * de nada nunca. Fallo real, cazado por los tests el 2026-07-26.
102
+ *
103
+ * Sube hasta el primer ancestro que existe y recompone el resto, porque
104
+ * `realpathSync` falla con lo inexistente y con `Write` no tiene por qué existir
105
+ * ni el archivo ni sus carpetas.
106
+ */
107
+ function rutaRealProfunda(p) {
108
+ const abs = path.resolve(p);
109
+ const cola = [];
110
+ let actual = abs;
111
+ for (;;) {
112
+ try {
113
+ const real = fs.realpathSync(actual);
114
+ return cola.length > 0 ? path.join(real, ...cola.reverse()) : real;
115
+ }
116
+ catch {
117
+ const padre = path.dirname(actual);
118
+ if (padre === actual)
119
+ return abs; // se llegó a la raíz sin resolver nada
120
+ cola.push(path.basename(actual));
121
+ actual = padre;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Ruta absoluta (lo que manda el hook) → ruta relativa al repo (lo que guarda
127
+ * el atlas). `null` cuando queda fuera del árbol: un archivo de /tmp o del home
128
+ * no tiene módulo, y colarlo como ruta con `../` produciría cero coincidencias
129
+ * de forma silenciosa.
130
+ */
131
+ export function repoRelative(toplevel, filePath) {
132
+ const raiz = rutaRealProfunda(toplevel);
133
+ const abs = path.isAbsolute(filePath)
134
+ ? rutaRealProfunda(filePath)
135
+ : path.resolve(raiz, filePath);
136
+ const rel = path.relative(raiz, abs);
137
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
138
+ return null;
139
+ // El atlas guarda rutas de git: siempre con "/", también en Windows.
140
+ return rel.split(path.sep).join("/");
141
+ }
142
+ /**
143
+ * ¿Merece este módulo un aviso?
144
+ *
145
+ * Es LA decisión del archivo. Se dice algo solo cuando hay algo que el agente
146
+ * no puede deducir del archivo que tiene delante: quién depende de él (no está
147
+ * escrito en ninguna parte del código), una alerta abierta (historia), o
148
+ * reincidencia (historia). El riesgo por sí solo NO cuenta: "risk: medium" en
149
+ * cada edición es exactamente el ruido que hace que se deje de leer.
150
+ */
151
+ export function valeLaPena(m) {
152
+ return (m.dependents.length > 0 || m.alerts.length > 0 || m.priorRegressions >= 2);
153
+ }
154
+ /**
155
+ * El texto que se inyecta. Deliberadamente calcado a `atlas_file_context`
156
+ * ("↘ DEPENDS ON THIS", "⚠ OPEN ALERT", "prior regressions"): el agente ya sabe
157
+ * qué hacer con esas palabras porque las ve cuando pregunta él. Dos redacciones
158
+ * para el mismo hecho serían dos hechos para él.
159
+ */
160
+ export function impactText(file, modules) {
161
+ const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
162
+ for (const m of modules.slice(0, MAX_MODULES)) {
163
+ // El riesgo se nombra solo cuando es alto: ver valeLaPena.
164
+ const alto = m.risk === "high" || m.risk === "hotspot";
165
+ lines.push(`- Module **${m.module}**` +
166
+ (alto ? ` · risk: ${m.risk}` : "") +
167
+ (m.priorRegressions >= 2
168
+ ? ` · ⚠ ${m.priorRegressions} prior regressions`
169
+ : ""));
170
+ if (m.dependents.length > 0) {
171
+ lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
172
+ }
173
+ for (const plain of m.alerts)
174
+ lines.push(` - ⚠ OPEN ALERT: ${plain}`);
175
+ }
176
+ if (modules.length > MAX_MODULES) {
177
+ lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+ /**
182
+ * Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
183
+ * borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
184
+ * aviso vuelve a hacer falta.
185
+ */
186
+ export function rutasYaAvisadas(state, sessionId, ahora) {
187
+ if (!state || state.session_id !== sessionId)
188
+ return new Set();
189
+ const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
190
+ return new Set(vivas.map(([ruta]) => ruta));
191
+ }
192
+ /** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
193
+ export function estadoSiguiente(state, sessionId, nuevas, ahora) {
194
+ const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
195
+ for (const ruta of nuevas)
196
+ base[ruta] = ahora;
197
+ const podado = Object.entries(base)
198
+ .filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
199
+ .sort((a, b) => b[1] - a[1])
200
+ .slice(0, SEEN_MAX);
201
+ return { session_id: sessionId, seen: Object.fromEntries(podado) };
202
+ }
203
+ /**
204
+ * Módulos a los que pertenece un archivo, del más reciente al más antiguo.
205
+ *
206
+ * UNIÓN de filas y no la última foto, por la misma razón que `moduleFilesUnion`
207
+ * del guardián: los archivos que toca un módulo cambian en cada análisis, y
208
+ * quedarse con la fila más nueva silenciaba coincidencias reales (visto en vivo
209
+ * el 2026-07-18). El riesgo, en cambio, sí es el de la fila más nueva: es
210
+ * estado, no historia.
211
+ */
212
+ export function modulosDelArchivo(file, rows) {
213
+ const out = new Map();
214
+ for (const row of rows) {
215
+ const label = (row.module ?? "").trim();
216
+ if (!label || out.has(label))
217
+ continue;
218
+ const files = Array.isArray(row.files) ? row.files.map(String) : [];
219
+ if (files.includes(file))
220
+ out.set(label, row.risk);
221
+ }
222
+ return [...out.entries()].map(([module, risk]) => ({ module, risk }));
223
+ }
224
+ async function cachePath(dir) {
225
+ return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
226
+ }
227
+ function leerJson(file) {
228
+ if (!file)
229
+ return null;
230
+ try {
231
+ return JSON.parse(fs.readFileSync(file, "utf8"));
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ function escribirJson(file, data) {
238
+ if (!file)
239
+ return;
240
+ try {
241
+ fs.writeFileSync(file, JSON.stringify(data));
242
+ }
243
+ catch {
244
+ // Un .git de solo lectura no puede convertirse en una edición fallida.
245
+ }
246
+ }
247
+ async function fetchAtlas(db, dir, env) {
248
+ // Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
249
+ // el nombre del directorio, casado por slug del servidor primero.
250
+ const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
251
+ const slug = slugifyProject(candidate);
252
+ let projects = slug
253
+ ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
254
+ : [];
255
+ if (projects.length === 0) {
256
+ projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
257
+ }
258
+ const projectId = projects[0]?.id ?? null;
259
+ if (!projectId) {
260
+ return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
261
+ }
262
+ // TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
263
+ // crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
264
+ // que sean tres y no dos: `deps` y `files` viven en la misma tabla, pero no en
265
+ // la misma ventana.
266
+ //
267
+ // El grafo se pide con `deps=not.is.null`, exactamente como tools.ts. Traerlo
268
+ // de la ventana general costó un fallo real, cazado en el E2E del 2026-07-26:
269
+ // de 931 filas recientes solo 613 tenían grafo, así que el mismo módulo salía
270
+ // con 3 dependientes empujado y 4 preguntado. Dos respuestas para el mismo
271
+ // hecho, y el agente sin forma de saber cuál le mintió — el fallo contra el
272
+ // que yo mismo había escrito el comentario de GRAPH_WINDOW_ROWS.
273
+ //
274
+ // Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
275
+ // reincidencia (all-time) salen del mismo conjunto.
276
+ const [rows, deps, alerts] = await Promise.all([
277
+ db
278
+ .rest(`change_module?select=module,files,risk,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
279
+ .catch(() => []),
280
+ db
281
+ .rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
282
+ .catch(() => []),
283
+ db
284
+ .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}`)
285
+ .catch(() => []),
286
+ ]);
287
+ return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
288
+ }
289
+ /**
290
+ * Las señales, SIEMPRE de disco. Nunca de la red.
291
+ *
292
+ * Esto empezó consultando en línea y no funcionaba, y el fallo enseña más que el
293
+ * arreglo: medido contra producción el 2026-07-26, resolver el proyecto tarda
294
+ * ~590 ms y las dos consultas otros ~320 ms. Con el techo por edición no llegaba
295
+ * — y como no llegaba, nunca escribía la caché, así que TODAS las llamadas eran
296
+ * en frío para siempre. Una caché que solo se escribe al final de un camino que
297
+ * no termina no es una caché.
298
+ *
299
+ * Así que el camino crítico no toca la red jamás: lee el archivo, y si está frío
300
+ * o caducado lanza un refresco DESACOPLADO (`impact --warm`) y calla esta vez.
301
+ * Mismo patrón que el hook post-commit. El coste real es que la primera edición
302
+ * de cada ventana de 5 minutos no avisa; a cambio, las otras cuarenta cuestan
303
+ * una lectura de disco. En una sesión de vibe coding ese cambio es todo a favor.
304
+ */
305
+ async function atlasSignals(db, dir) {
306
+ const file = await cachePath(dir);
307
+ // Sin sitio donde guardar (no es un repo git, .git de solo lectura) no hay
308
+ // canal: calentar sería gastar un proceso y tres consultas para tirar el
309
+ // resultado a la basura.
310
+ if (!file)
311
+ return null;
312
+ const cached = leerJson(file);
313
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
314
+ return cached;
315
+ if (db.hasCredentials())
316
+ calentarDesacoplado(dir);
317
+ return null;
318
+ }
319
+ /**
320
+ * Lanza el refresco de la caché en un proceso aparte y se olvida de él.
321
+ *
322
+ * `stdio: "ignore"` no es higiene, es obligatorio: el stdout del hijo llegaría
323
+ * a la misma tubería que Claude Code lee como salida del hook, y un JSON a
324
+ * medias o dos objetos pegados sería basura inyectada en el contexto del agente.
325
+ * `detached` + `unref` para que el padre pueda morir ya y no retenga la edición.
326
+ */
327
+ function calentarDesacoplado(dir) {
328
+ try {
329
+ const hijo = spawn(process.execPath, [process.argv[1], "impact", "--warm", dir], {
330
+ detached: true,
331
+ stdio: "ignore",
332
+ });
333
+ hijo.unref();
334
+ }
335
+ catch {
336
+ // Sin poder lanzarlo, la caché se quedará fría y no habrá avisos. Mal, pero
337
+ // no tan mal como que falle una edición.
338
+ }
339
+ }
340
+ /**
341
+ * `changebook impact --warm [dir]` — el lado desacoplado. Consulta y escribe la
342
+ * caché. Sin stdin, sin stdout, sin código de salida que importe.
343
+ */
344
+ export async function warmImpactCache(db, dir, env = process.env) {
345
+ try {
346
+ if (!db.hasCredentials())
347
+ return;
348
+ const file = await cachePath(dir);
349
+ if (!file)
350
+ return;
351
+ // Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
352
+ // fresca mientras este arrancaba, no gastar dos veces la misma consulta.
353
+ const cached = leerJson(file);
354
+ if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
355
+ return;
356
+ escribirJson(file, await fetchAtlas(db, dir, env));
357
+ }
358
+ catch {
359
+ // Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
360
+ // avisos, y eso ya lo cubre `hook-impact status`.
361
+ }
362
+ }
363
+ // ── El cuerpo ────────────────────────────────────────────────────────────────
364
+ /** stdin completo, acotado. El hook manda un objeto pequeño; nada más cabe. */
365
+ async function readStdin(limit = 256 * 1024) {
366
+ const chunks = [];
367
+ let total = 0;
368
+ for await (const chunk of process.stdin) {
369
+ const buf = Buffer.from(chunk);
370
+ total += buf.length;
371
+ if (total > limit)
372
+ break;
373
+ chunks.push(buf);
374
+ }
375
+ return Buffer.concat(chunks).toString("utf8");
376
+ }
377
+ async function buildImpact(db, payload) {
378
+ const rutas = pathsFromPayload(payload);
379
+ if (rutas.length === 0)
380
+ return null;
381
+ const dir = payload.cwd?.trim() || process.cwd();
382
+ // El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
383
+ // el atlas guarda rutas relativas a la raíz del repo.
384
+ const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
385
+ cwd: dir,
386
+ encoding: "utf8",
387
+ })
388
+ .then(({ stdout }) => stdout.trim())
389
+ .catch(() => dir);
390
+ const relativas = [
391
+ ...new Set(rutas
392
+ .map((r) => repoRelative(toplevel, r))
393
+ .filter((r) => Boolean(r))),
394
+ ];
395
+ if (relativas.length === 0)
396
+ return null;
397
+ const ahora = Date.now();
398
+ const sessionId = payload.session_id?.trim() || "sin-sesion";
399
+ const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
400
+ const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
401
+ const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
402
+ if (pendientes.length === 0)
403
+ return null;
404
+ const signals = await atlasSignals(db, dir);
405
+ if (!signals?.project_id)
406
+ return null;
407
+ const recidivism = computeRecidivism(signals.alerts);
408
+ const abiertas = signals.alerts.filter((a) => !a.resolved_at);
409
+ // Refutación al servir, igual que en atlas_file_context: el mismo grep que
410
+ // corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
411
+ // un grep. Aquí importa el doble: un aviso empujado que ya no es verdad
412
+ // enseña al agente a ignorar los empujados.
413
+ //
414
+ // Acotada a mano, y por una razón que no es cosmética: contarEnRepo usa
415
+ // execFileSync, o sea que BLOQUEA el bucle de eventos y el techo de arriba no
416
+ // puede desalojarlo — un Promise.race no gana a una llamada síncrona. Un
417
+ // archivo con muchas alertas vivas podría comerse el presupuesto entero a
418
+ // 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
419
+ // que es el lado seguro (la duda deja pasar el aviso).
420
+ let grepsRestantes = MAX_REFUTACIONES;
421
+ const refutada = (a) => {
422
+ if (grepsRestantes <= 0)
423
+ return false;
424
+ grepsRestantes -= 1;
425
+ return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
426
+ };
427
+ const depsRows = signals.deps ?? [];
428
+ const bloques = [];
429
+ const avisadas = [];
430
+ for (const file of pendientes) {
431
+ const modulos = modulosDelArchivo(file, signals.rows);
432
+ if (modulos.length === 0)
433
+ continue;
434
+ const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
435
+ const impactos = modulos
436
+ .map((m) => ({
437
+ module: m.module,
438
+ risk: m.risk,
439
+ dependents: dependientes.get(m.module) ?? [],
440
+ alerts: abiertas
441
+ .filter((a) => (a.module ?? "").trim() === m.module && a.plain)
442
+ .filter((a) => !refutada(a))
443
+ .map((a) => a.plain),
444
+ priorRegressions: recidivism.get(m.module) ?? 0,
445
+ }))
446
+ .filter(valeLaPena);
447
+ if (impactos.length === 0)
448
+ continue;
449
+ bloques.push(impactText(file, impactos));
450
+ avisadas.push(file);
451
+ }
452
+ // Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
453
+ // nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
454
+ if (avisadas.length > 0) {
455
+ escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
456
+ }
457
+ return bloques.length > 0 ? bloques.join("\n\n") : null;
458
+ }
459
+ /**
460
+ * El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
461
+ * con el JSON roto o pasado el presupuesto, se emite cero y se sale con 0.
462
+ *
463
+ * Se emite `additionalContext` a secas, sin `permissionDecision`: la decisión de
464
+ * permisos se queda en manos del usuario, exactamente como si este hook no
465
+ * existiera. Y por contrato de Claude Code, salir con 2 bloquearía la edición —
466
+ * de ahí que aquí no se lance nunca nada hacia fuera.
467
+ */
468
+ export async function printImpact(db) {
469
+ try {
470
+ // UN presupuesto para todo, no uno por etapa: dos carreras de 1,2 s en
471
+ // serie son 2,4 s de impuesto real por edición, que es justo lo que este
472
+ // techo existe para impedir. Se comparte entre leer stdin y leer la caché.
473
+ //
474
+ // Y se cancela en `finally`, no al final del camino feliz: los caminos que
475
+ // CALLAN son los más frecuentes (archivo limpio, ya avisado, caché fría) y
476
+ // cada `return` temprano dejaba el temporizador en pie. Medido el
477
+ // 2026-07-26: 1,39 s por edición silenciosa contra 0,23 s de la que habla —
478
+ // el proceso ya había terminado y se quedaba esperando a su propio reloj.
479
+ const { vencido, cancelar } = presupuestoDeTiempo(IMPACT_TIMEOUT_MS);
480
+ try {
481
+ const raw = await Promise.race([readStdin(), vencido.then(() => "")]);
482
+ if (!raw.trim())
483
+ return;
484
+ let payload;
485
+ try {
486
+ payload = JSON.parse(raw);
487
+ }
488
+ catch {
489
+ return;
490
+ }
491
+ const additionalContext = await Promise.race([
492
+ buildImpact(db, payload).catch(() => null),
493
+ vencido,
494
+ ]);
495
+ if (!additionalContext)
496
+ return;
497
+ process.stdout.write(JSON.stringify({
498
+ hookSpecificOutput: {
499
+ hookEventName: "PreToolUse",
500
+ additionalContext,
501
+ },
502
+ }));
503
+ }
504
+ finally {
505
+ cancelar();
506
+ }
507
+ }
508
+ catch {
509
+ // Editar un archivo no puede fallar por nosotros.
510
+ }
511
+ }
512
+ //# sourceMappingURL=impact.js.map
package/dist/index.js CHANGED
@@ -21,7 +21,8 @@ import { runGuard } from "./guard.js";
21
21
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
22
22
  import { importHistory } from "./import.js";
23
23
  import { registerAgents } from "./init.js";
24
- import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
24
+ import { contextHookInstalled, installContextHook, installSettingsHook, printContext, settingsHookInstalled, uninstallContextHook, uninstallSettingsHook, } from "./context.js";
25
+ import { IMPACT_HOOK, printImpact, warmImpactCache } from "./impact.js";
25
26
  import { login } from "./login.js";
26
27
  import { AUTH_HELP, Supabase } from "./supabase.js";
27
28
  import { syncContextFiles } from "./sync.js";
@@ -52,6 +53,12 @@ Usage:
52
53
  changebook hook-context install|uninstall|status [dir]
53
54
  Push the atlas into EVERY Claude Code session at turn 0
54
55
  via a SessionStart hook in .claude/settings.json
56
+ changebook impact Print the blast radius of the file a PreToolUse hook
57
+ payload (on stdin) is about to edit
58
+ changebook hook-impact install|uninstall|status [dir]
59
+ Tell the agent who depends on a file BEFORE it edits it,
60
+ via a PreToolUse hook in .claude/settings.json. Never
61
+ blocks an edit; silent when there is nothing to say
55
62
  changebook init [dir] login + register MCP in every agent found + hook + sync
56
63
  changebook open Open the web atlas in the browser
57
64
  changebook serve Run the MCP server on stdio (default with no arguments)
@@ -246,6 +253,43 @@ async function main() {
246
253
  }
247
254
  return;
248
255
  }
256
+ case "impact": {
257
+ // --warm es el lado DESACOPLADO, el que sí toca la red: lo lanza el propio
258
+ // hook en un proceso aparte cuando encuentra la caché fría. No lee stdin y
259
+ // no escribe en stdout (ver calentarDesacoplado: su salida iría a la
260
+ // tubería que Claude Code lee como respuesta del hook).
261
+ if (arg === "--warm") {
262
+ await warmImpactCache(new Supabase(), process.argv[4] ?? process.cwd());
263
+ return;
264
+ }
265
+ // Camino crítico de CADA edición: como `context`, jamás requireCredentials
266
+ // (saldría con 1) y jamás help. printImpact falla abierto — sin sesión,
267
+ // sin caché o lento, cero salida y exit 0. Y nunca sale con 2, que es el
268
+ // código con el que Claude Code BLOQUEA la edición.
269
+ await printImpact(new Supabase());
270
+ return;
271
+ }
272
+ case "hook-impact": {
273
+ const dir = process.argv[4] ?? process.cwd();
274
+ if (arg === "install") {
275
+ const r = installSettingsHook(dir, IMPACT_HOOK);
276
+ console.error(r === "installed"
277
+ ? `✓ PreToolUse hook installed (${dir}/.claude/settings.json). Before every edit, the agent now gets told who depends on the file it is about to touch.`
278
+ : "PreToolUse hook already installed.");
279
+ }
280
+ else if (arg === "uninstall") {
281
+ const r = uninstallSettingsHook(dir, IMPACT_HOOK);
282
+ console.error(r === "removed"
283
+ ? "✓ PreToolUse hook removed."
284
+ : "No ChangeBook PreToolUse hook found.");
285
+ }
286
+ else {
287
+ console.error(settingsHookInstalled(dir, IMPACT_HOOK)
288
+ ? `✓ ChangeBook PreToolUse hook installed (${dir}/.claude/settings.json).`
289
+ : "✗ No ChangeBook PreToolUse hook. Install with: changebook hook-impact install");
290
+ }
291
+ return;
292
+ }
249
293
  case "init": {
250
294
  let db = new Supabase();
251
295
  if (!db.hasCredentials()) {
@@ -272,6 +316,15 @@ async function main() {
272
316
  console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
273
317
  "(no tool call needed, never stale):\n changebook hook-context install");
274
318
  }
319
+ // El segundo canal se ofrece aparte porque responde a otra pregunta. El
320
+ // primero da el mapa al abrir; este avisa de a quién te llevas por delante
321
+ // justo antes de escribir, que es lo que hace falta cuando nadie pregunta
322
+ // nada. Se ofrece, NUNCA se instala en silencio: mismo motivo que el otro,
323
+ // .claude/settings.json se commitea y se comparte.
324
+ if (!settingsHookInstalled(dir, IMPACT_HOOK)) {
325
+ console.error("\nOptional: before every edit, tell the agent who depends on the file " +
326
+ "it is about to touch:\n changebook hook-impact install");
327
+ }
275
328
  console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
276
329
  return;
277
330
  }
package/dist/login.js CHANGED
@@ -15,6 +15,38 @@ import * as http from "node:http";
15
15
  import { atlasWebUrl, openInBrowser } from "./browser.js";
16
16
  import { credentialsPath, saveCredentials } from "./credentials.js";
17
17
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
18
+ const SUPABASE_URL = process.env.CHANGEBOOK_SUPABASE_URL ??
19
+ "https://oyosihxkecspjkiligga.supabase.co";
20
+ const ANON_KEY = process.env.CHANGEBOOK_SUPABASE_ANON_KEY ??
21
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
22
+ /**
23
+ * Canjea un codigo de un solo uso por una sesion PROPIA de este CLI.
24
+ *
25
+ * Espejo de `exchangeTokenHash` de la extension (src/auth/supabaseAuth.ts), que
26
+ * lleva usando este camino desde que se diagnostico el problema. El backend
27
+ * acuña el codigo con `generateLink({type:"magiclink"})` en la funcion
28
+ * editor-handoff — no se manda ningun correo — y aqui se cambia por tokens que
29
+ * no comparte nadie mas.
30
+ */
31
+ async function canjearTokenHash(tokenHash) {
32
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/verify`, {
33
+ method: "POST",
34
+ headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
35
+ body: JSON.stringify({ type: "magiclink", token_hash: tokenHash }),
36
+ signal: AbortSignal.timeout(30_000),
37
+ });
38
+ if (!res.ok) {
39
+ throw new Error(`No se pudo canjear el codigo de acceso (${res.status}). Vuelve a intentarlo.`);
40
+ }
41
+ const data = (await res.json());
42
+ if (!data.refresh_token) {
43
+ throw new Error("El canje no devolvio una sesion utilizable.");
44
+ }
45
+ return {
46
+ access_token: data.access_token ?? undefined,
47
+ refresh_token: data.refresh_token,
48
+ };
49
+ }
18
50
  const CALLBACK_PAGE = `<!DOCTYPE html>
19
51
  <html><head><meta charset="utf-8"><title>ChangeBook</title></head>
20
52
  <body style="font-family: sans-serif; max-width: 480px; margin: 80px auto; text-align: center;">
@@ -22,6 +54,7 @@ const CALLBACK_PAGE = `<!DOCTYPE html>
22
54
  <script>
23
55
  const params = new URLSearchParams(window.location.hash.slice(1));
24
56
  const payload = {
57
+ token_hash: params.get("token_hash"),
25
58
  access_token: params.get("access_token"),
26
59
  refresh_token: params.get("refresh_token"),
27
60
  state: params.get("state"),
@@ -62,6 +95,28 @@ export async function login() {
62
95
  res.end();
63
96
  return;
64
97
  }
98
+ // VIA PREFERIDA: un codigo de un solo uso que se canjea por una
99
+ // sesion PROPIA de este CLI. La via heredada —recibir los tokens
100
+ // vivos de la pestana web— entrega a dos clientes el MISMO refresh
101
+ // token rotatorio, y Supabase revoca la familia entera en cuanto uno
102
+ // de los dos lo rota. Como el navegador refresca solo en segundo
103
+ // plano, siempre gana el, y la sesion del CLI moria ~30 min despues
104
+ // de cada login, con reloj. La extension ya usaba este camino desde
105
+ // que se diagnostico; al CLI no se le habia traido.
106
+ if (data.token_hash) {
107
+ canjearTokenHash(data.token_hash).then((sesion) => {
108
+ res.writeHead(200, { "Content-Type": "application/json" });
109
+ res.end('{"ok":true}');
110
+ server.close();
111
+ resolve(sesion);
112
+ }, (err) => {
113
+ res.writeHead(400);
114
+ res.end();
115
+ server.close();
116
+ reject(err);
117
+ });
118
+ return;
119
+ }
65
120
  if (!data.refresh_token) {
66
121
  res.writeHead(400);
67
122
  res.end();
@@ -93,7 +148,12 @@ export async function login() {
93
148
  reject(new Error("Could not open a loopback port."));
94
149
  return;
95
150
  }
96
- const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}`;
151
+ // `th=1` anuncia que este CLI sabe canjear un token_hash. La web solo lo
152
+ // manda si lo ve: sin la marca, un CLI antiguo recibiria un codigo que no
153
+ // entiende y el login fallaria sin explicacion. Arreglar un fallo de
154
+ // sesion rompiendo el login de quien no ha actualizado no seria arreglar
155
+ // nada.
156
+ const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}&th=1`;
97
157
  console.error("Opening your browser to sign in to ChangeBook…");
98
158
  console.error(`If it doesn't open, visit:\n\n ${url}\n`);
99
159
  openInBrowser(url);
package/dist/supabase.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * every query to the signed-in user. Refreshes the access token with the
6
6
  * refresh token when needed (refresh does not require a captcha).
7
7
  */
8
- import { loadCredentials, saveCredentials, withCredentialsLock, } from './credentials.js';
8
+ import { clearRefreshInFlight, credentialsPath, loadCredentials, markRefreshInFlight, readRefreshInFlight, saveCredentials, withCredentialsLock, } from './credentials.js';
9
9
  import { slugifyProject } from './guard.js';
10
10
  // Public defaults — the anon key is the same public key the web app ships.
11
11
  const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
@@ -335,10 +335,30 @@ export class Supabase {
335
335
  return;
336
336
  }
337
337
  this.refreshToken = plan.refresh_token;
338
+ // Una renovacion anterior que quedo a medias: la dejo escrita ANTES de que
339
+ // esta salga, para no confundirla con la mia.
340
+ const aMedias = this.persistRotation ? readRefreshInFlight() : null;
341
+ // Supabase rota el token AL RECIBIR la peticion, asi que desde esta linea y
342
+ // hasta que se guarde el nuevo, el token del disco esta muerto sin que nadie
343
+ // lo sepa. Dejar constancia no evita que maten al proceso en esa ventana
344
+ // —el analyze post-commit corre desacoplado— pero convierte el sintoma de
345
+ // dentro de media hora en algo explicable.
346
+ if (this.persistRotation)
347
+ markRefreshInFlight();
338
348
  const res = await this.refreshOnce(this.refreshToken);
339
349
  if (!res.ok) {
340
350
  const body = (await res.text()).slice(0, 300);
341
- throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
351
+ // El token esta gastado o muerto de todos modos: la marca ya no sirve.
352
+ if (this.persistRotation)
353
+ clearRefreshInFlight();
354
+ const yaUsado = /already[ _]used|refresh[ _]token[ _]not[ _]valid/i.test(body);
355
+ const explicacion = yaUsado && aMedias
356
+ ? `\n\nQUE PASO: una renovacion anterior empezo el ${aMedias.at} (proceso ${aMedias.pid}) y no llego a terminar. ` +
357
+ `El servidor rotó el token al recibirla, pero este equipo no guardó el nuevo — normalmente porque mataron al proceso ` +
358
+ `(el analisis post-commit corre en segundo plano). Al reintentar con el viejo, Supabase lo toma por robado y cierra la sesion. ` +
359
+ `No has hecho nada mal y no hay nada que reparar: basta con volver a entrar.`
360
+ : '';
361
+ throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}${explicacion}\n\n${AUTH_HELP}`, res.status);
342
362
  }
343
363
  const data = (await res.json());
344
364
  this.accessToken = data.access_token;
@@ -352,9 +372,33 @@ export class Supabase {
352
372
  refresh_token: this.refreshToken,
353
373
  access_token: this.accessToken,
354
374
  });
375
+ // Guardado: la ventana peligrosa se ha cerrado. Se retira la marca
376
+ // DESPUES de escribir, no antes — al reves dejaria un hueco en el que
377
+ // ni hay marca ni hay token nuevo, que es justo el estado que la marca
378
+ // existe para poder contar.
379
+ clearRefreshInFlight();
355
380
  }
356
- catch {
357
- // Non-fatal: the in-memory session still works for this process.
381
+ catch (err) {
382
+ // NO es inofensivo, aunque lo parezca desde aquí.
383
+ //
384
+ // Este proceso sigue funcionando con su sesion en memoria, y por eso el
385
+ // codigo anterior se lo tragaba en silencio. Pero el token rotado se ha
386
+ // perdido: Supabase ya invalido el viejo, asi que el SIGUIENTE proceso
387
+ // leera del disco un token gastado, lo reenviara, y la deteccion de
388
+ // reutilizacion revocara la sesion entera. El sintoma aparece minutos u
389
+ // horas despues, sin ninguna relacion visible con este fallo de
390
+ // escritura — que es lo que lo hacia indiagnosticable.
391
+ //
392
+ // Firma en los logs de auth del 2026-07-25: un refresco con 200 a las
393
+ // 06:51:54 y, diez minutos mas tarde y sin nada en medio, un 400
394
+ // "Possible abuse attempt". Es exactamente lo que deja este camino.
395
+ //
396
+ // No se lanza: tumbar la orden en curso castigaria al usuario por algo
397
+ // que todavia funciona. Se avisa, alto y con el arreglo puesto.
398
+ const motivo = err instanceof Error ? err.message : String(err);
399
+ console.error(`ChangeBook: no se pudo guardar la sesion renovada en ${credentialsPath()} (${motivo}).\n` +
400
+ `Esta orden termina bien, pero la siguiente reutilizara un token ya gastado y la sesion se cerrara sola.\n` +
401
+ `Comprueba los permisos de ese fichero y vuelve a entrar con: changebook login`);
358
402
  }
359
403
  }
360
404
  }
package/dist/sync.js CHANGED
@@ -200,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
200
200
  // la tool solo cubre definiciones y el agente acababa pagando atlas Y
201
201
  // grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
202
202
  // hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
203
- 'Disparador tool: orientarte `atlas_project_brief` (`intent:"orient"`) · editar un archivo → `atlas_file_context` con su ruta · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
203
+ // Los VERBOS son literales a propósito. La ley medida el 2026-07-21 fue que
204
+ // la adopción es coincidencia LITERAL (los disparadores literales la movieron
205
+ // de 60/100/0 a 100/100/60), y el 2026-07-26 se comprobó el otro lado de la
206
+ // misma ley: T4 pidió «vas a cambiar el comportamiento de la función X» y el
207
+ // agente, con el atlas disponible, fue directo a grep — 32 turnos, 1,46M
208
+ // tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
209
+ // habla quien programa con un agente. Quien no pregunta nada no reconoce un
210
+ // disparador abstracto: hay que nombrar sus verbos.
211
+ 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
204
212
  '',
205
213
  'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
206
214
  '',
207
215
  // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
208
- `${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Anuncia antes de atacar un encargo, y DILE al usuario los riesgos que el atlas te enseñe — él no los ve.`,
216
+ //
217
+ // «Anuncia» pasó a «di qué harías y ESPERA su OK» el 2026-07-26, a petición
218
+ // de Raúl y con razón: anunciar y ponerse a trabajar en el mismo turno no le
219
+ // deja decidir nada. Y valía para «un encargo», o sea solo para la cola;
220
+ // ahora vale al abrir, que es cuando el agente elige por su cuenta qué mirar
221
+ // y qué tocar. Lo que se gasta aquí se recupera en la línea de la cola, que
222
+ // decía esto mismo por segunda vez.
223
+ `${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Al abrir: di en 2 líneas qué harías y por qué, y ESPERA su OK antes de tocar código. DILE los riesgos que el atlas te enseñe — él no los ve.`,
209
224
  '',
210
225
  ];
211
226
  if (modules.length === 0) {
@@ -266,7 +281,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
266
281
  ].slice(0, 3);
267
282
  const taskLines = taskTitles.length > 0
268
283
  ? [
269
- `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Al empezar, propón al usuario cuál atacarías y por qué, y espera su OK antes de tocar código. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
284
+ `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
270
285
  ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
271
286
  ]
272
287
  : [];
package/dist/tools.js CHANGED
@@ -96,6 +96,49 @@ export function quotedInList(values) {
96
96
  .join(",");
97
97
  }
98
98
  export const FILES_CAP = 8;
99
+ const MAX_DEPENDENTS = 6;
100
+ /** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
101
+ * El mapa de la web dibuja las flechas sobre esta misma ventana: dos
102
+ * ventanas distintas darían dependencias distintas según se mire el dibujo
103
+ * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
104
+ const MODULE_GRAPH_WINDOW_ROWS = 1000;
105
+ export function dependentsOf(targets, rows) {
106
+ const graph = new Map();
107
+ for (const r of rows) {
108
+ const label = (r.module ?? "").trim();
109
+ if (!label || graph.has(label.toLowerCase()))
110
+ continue;
111
+ const deps = Array.isArray(r.deps)
112
+ ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
113
+ : [];
114
+ graph.set(label.toLowerCase(), deps);
115
+ }
116
+ const reverse = new Map();
117
+ for (const r of rows) {
118
+ const from = (r.module ?? "").trim();
119
+ if (!from)
120
+ continue;
121
+ for (const to of graph.get(from.toLowerCase()) ?? []) {
122
+ const key = to.toLowerCase();
123
+ if (key === from.toLowerCase())
124
+ continue;
125
+ const set = reverse.get(key) ?? new Set();
126
+ set.add(from);
127
+ reverse.set(key, set);
128
+ }
129
+ }
130
+ const out = new Map();
131
+ for (const t of targets) {
132
+ const label = (t ?? "").trim();
133
+ if (!label)
134
+ continue;
135
+ const found = reverse.get(label.toLowerCase());
136
+ if (!found || found.size === 0)
137
+ continue;
138
+ out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
139
+ }
140
+ return out;
141
+ }
99
142
  /**
100
143
  * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
101
144
  * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
@@ -735,7 +778,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
735
778
  for (const f of perFile) {
736
779
  commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
737
780
  }
738
- const [alerts, watched, recidivismRows] = await Promise.all([
781
+ const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
739
782
  moduleNames.length
740
783
  ? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
741
784
  pf)
@@ -759,6 +802,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
759
802
  pf)
760
803
  .catch(() => [])
761
804
  : Promise.resolve([]),
805
+ // Radio de impacto: grafo COMPLETO del proyecto (no filtrado por
806
+ // moduleNames — quién depende de X vive en las filas de OTROS
807
+ // módulos). En el mismo Promise.all: cero rondas extra, cero turnos
808
+ // extra. Best-effort: sin columna, el contexto se sirve sin radio.
809
+ db
810
+ .rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
811
+ pf)
812
+ .catch(() => []),
762
813
  ]);
763
814
  const watchedByFile = new Map();
764
815
  for (const w of watched) {
@@ -787,6 +838,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
787
838
  // (count(distinct plain), all-time), solo los con antecedentes (>= 2).
788
839
  // Fuente única compartida (computeRecidivism) — antes 3 copias.
789
840
  const recidivismByModule = computeRecidivism(recidivismRows);
841
+ const dependentsByModule = dependentsOf(moduleNames, depsRows);
790
842
  // Ancla temporal: el último commit analizado del proyecto vs el HEAD
791
843
  // de este árbol (misma puerta de proyecto que la refutación).
792
844
  // Best-effort: el ancla jamás rompe la lectura que ancla.
@@ -829,6 +881,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
829
881
  for (const plain of alertsByModule.get(m.module) ?? []) {
830
882
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
831
883
  }
884
+ const dependents = dependentsByModule.get(m.module);
885
+ if (dependents?.length) {
886
+ lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
887
+ }
832
888
  }
833
889
  for (const w of watchedByFile.get(f.file) ?? []) {
834
890
  lines.push(`- Current value: ${w.name} = ${w.value}` +
@@ -864,6 +920,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
864
920
  prior_regressions: recidivismByModule.get(m.module) ?? 0,
865
921
  }))
866
922
  .filter((x) => x.prior_regressions >= 2),
923
+ // Espejo del hospedado: el structuredContent no puede prometer
924
+ // menos que la prosa.
925
+ dependents: f.modules
926
+ .map((m) => ({
927
+ module: m.module,
928
+ dependents: dependentsByModule.get(m.module) ?? [],
929
+ }))
930
+ .filter((x) => x.dependents.length > 0),
867
931
  watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
868
932
  name: w.name,
869
933
  value: w.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "mcpName": "io.github.raulbr90/changebook",
5
5
  "description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
4
  "description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
5
- "version": "0.4.8",
5
+ "version": "0.4.9",
6
6
  "websiteUrl": "https://changebook.dev",
7
7
  "remotes": [
8
8
  {
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "changebook",
18
- "version": "0.4.8",
18
+ "version": "0.4.9",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }