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 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,12 +6,12 @@
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, projectNameFor, 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 = {}) {
13
13
  const cwd = path.resolve(options.dir ?? process.cwd());
14
- const projectName = path.basename(cwd);
14
+ const projectName = projectNameFor(cwd);
15
15
  let rawDiff;
16
16
  let commitHash;
17
17
  let committedAt;
@@ -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
@@ -4,7 +4,7 @@
4
4
  * normalization live here — a drift between them would make the same commit
5
5
  * compress differently across subcommands and break the server-side dedup.
6
6
  */
7
- import { execFile } from "node:child_process";
7
+ import { execFile, execFileSync } from "node:child_process";
8
8
  import * as path from "node:path";
9
9
  import { promisify } from "node:util";
10
10
  export const execFileAsync = promisify(execFile);
@@ -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) {
@@ -82,4 +114,47 @@ export function gitErrorMessage(error) {
82
114
  }
83
115
  return error instanceof Error ? error.message : String(error);
84
116
  }
117
+ /**
118
+ * El nombre del proyecto al que reportar desde `dir`.
119
+ *
120
+ * NACE DE UN FLECO REAL. El 2026-07-25 trabajé en un worktree llamado `seo-wt` y
121
+ * el atlas creó un proyecto `seo-wt` en la cuenta de Raúl: gastó un análisis y
122
+ * dejó 4 módulos huérfanos en su selector de proyectos. No fue un bug del
123
+ * servidor: los cuatro sitios del CLI que deducen el nombre lo sacaban de
124
+ * `path.basename(cwd)`, y en un worktree eso NO es el nombre del repo.
125
+ *
126
+ * Un worktree enlazado es el MISMO repositorio: sus commits van al mismo sitio y
127
+ * su historia es la misma. Reportar a otro proyecto parte el atlas en dos por un
128
+ * detalle de cómo tienes montado el disco, y encima en silencio.
129
+ *
130
+ * Orden: `CHANGEBOOK_PROJECT` manda siempre (quien quiera un proyecto aparte por
131
+ * worktree lo dice y ya); si no, el nombre del árbol PRINCIPAL; y si no se puede
132
+ * averiguar, el basename de siempre.
133
+ *
134
+ * Se detecta comparando `--git-dir` con `--git-common-dir`: en el árbol principal
135
+ * son el mismo; en un worktree enlazado el primero es `.git/worktrees/<nombre>`.
136
+ */
137
+ export function projectNameFor(dir, env = process.env) {
138
+ const explicito = env.CHANGEBOOK_PROJECT?.trim();
139
+ if (explicito)
140
+ return explicito;
141
+ const base = path.basename(path.resolve(dir));
142
+ try {
143
+ const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
144
+ cwd: dir, encoding: "utf8", timeout: 5_000,
145
+ }).trim();
146
+ const comun = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
147
+ cwd: dir, encoding: "utf8", timeout: 5_000,
148
+ }).trim();
149
+ if (!gitDir || !comun || gitDir === comun)
150
+ return base;
151
+ // Worktree enlazado: el árbol principal es el padre del git-dir común.
152
+ const principal = path.basename(path.dirname(comun));
153
+ return principal || base;
154
+ }
155
+ catch {
156
+ // Sin git, o con una versión que no soporta estas banderas: como siempre.
157
+ return base;
158
+ }
159
+ }
85
160
  //# sourceMappingURL=git.js.map