changebook 0.4.7 → 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
@@ -2,10 +2,10 @@
2
2
 
3
3
  MCP (Model Context Protocol) server that lets coding agents — Claude Code,
4
4
  Codex, Cursor — query the **ChangeBook product memory** (the module map and the
5
- analyzed change history stored in Supabase) instead of re-reading the
6
- codebase, plus a CLI that feeds that memory from any terminal: sign in,
5
+ analyzed change history from your ChangeBook account) instead of re-reading
6
+ the codebase, plus a CLI that feeds that memory from any terminal: sign in,
7
7
  analyze uncommitted changes, sync the product map. All MCP tools are
8
- read-only, and Row Level Security scopes every query to the signed-in user.
8
+ read-only, and every query is scoped to the signed-in user.
9
9
 
10
10
  ## Two ways to run it
11
11
 
@@ -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. |
@@ -96,11 +99,6 @@ env vars below override them for CI/headless setups.
96
99
  | `CHANGEBOOK_ACCESS_TOKEN` | — | Short-lived JWT; refreshed automatically when it expires. |
97
100
  | `CHANGEBOOK_PROJECT` | — | Scope every query to one project (matched by slug, then exact name — usually the workspace folder name). Unset = all projects. |
98
101
  | `CHANGEBOOK_WEB_URL` | `https://changebook.app` | Web app used by `login`/`open` and printed after `analyze`. |
99
- | `CHANGEBOOK_SUPABASE_URL` | production project | Override for other environments. |
100
- | `CHANGEBOOK_SUPABASE_ANON_KEY` | production public key | Override for other environments. |
101
-
102
- The embedded anon key is the same public key the web app ships — it grants
103
- nothing by itself; your session token plus RLS decide what you can read.
104
102
 
105
103
  ## `sync`: product map inside CLAUDE.md / AGENTS.md
106
104
 
@@ -119,7 +117,7 @@ touched. Re-run after analyzing changes (or wire it to a git hook).
119
117
  ## Security notes
120
118
 
121
119
  - MCP tools are read-only; only `analyze` writes (through the same audited
122
- Edge Function as the extension, with the same quotas).
120
+ server endpoint as the extension, with the same quotas).
123
121
  - `login` uses a loopback-only handoff: the web app asks for an explicit
124
122
  Authorize click and redirects the tokens to `http://127.0.0.1:<port>` in
125
123
  the URL fragment — they never leave your machine, and a `state` nonce ties
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
@@ -8,18 +8,58 @@
8
8
  * file that can go stale between commits: it is generated on the spot, per
9
9
  * session.
10
10
  *
11
- * FAIL-OPEN, ABSOLUTE. This runs on the critical path of opening a session.
12
- * Every failure mode logged out, offline, no project, or merely slow must
13
- * print NOTHING and exit 0. A broken or slow atlas can never block or delay a
14
- * user's session start. The whole body races a short timeout; on timeout or
15
- * any throw, we emit nothing.
11
+ * FAIL-OPEN, ABSOLUTE, in the sense that matters: this runs on the critical
12
+ * path of opening a session, so nothing here may ever block it, slow it, or
13
+ * exit non-zero. The whole body races a short timeout; on timeout or any
14
+ * throw, we emit nothing and exit 0.
15
+ *
16
+ * What is NO LONGER absolute is the silence. Failing quietly used to mean the
17
+ * agent opened a session with no atlas AND no idea why — and the commonest
18
+ * cause, a dead CLI session, is precisely when it most needs to know, because
19
+ * everything the atlas told it about recent work is stale. So the feed pulse
20
+ * (feed.ts) is emitted even when the brief itself cannot be built. Quiet
21
+ * failure and invisible failure are different things; only the first one was
22
+ * ever the goal.
16
23
  */
17
24
  import * as fs from "node:fs";
18
25
  import path from "node:path";
26
+ import { feedWarningFor } from "./feed.js";
19
27
  import { fetchBriefSection } from "./sync.js";
20
28
  import { commitAliasesShort, derivaContraHead } from "./tools.js";
21
29
  /** Hard ceiling on the critical path: past this, emit nothing and move on. */
22
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
+ }
23
63
  async function buildPayload(db, dir) {
24
64
  // Logged out → silent no-op. A fresh clone with the hook installed but no
25
65
  // session must open exactly as fast as before.
@@ -52,24 +92,20 @@ async function buildPayload(db, dir) {
52
92
  }
53
93
  return drift ? `${section}\n\n${drift}` : section;
54
94
  }
55
- // ── SessionStart hook install (opt-in, per repo) ─────────────────────────────
56
- //
57
- // The push channel lives in `.claude/settings.json` under hooks.SessionStart.
58
- // Consent is the act of running `changebook hook-context install` (or saying
59
- // yes to init's offer) — this file is often committed and shared, so we NEVER
60
- // write it silently and NEVER clobber a config we can't parse.
61
95
  // `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
62
96
  // shell error is swallowed and the hook exits 0 — a missing binary must not
63
- // make a session-start hook look failed. printContext already emits only the
64
- // JSON payload (or nothing) on stdout.
65
- const HOOK_COMMAND = "changebook context 2>/dev/null || true";
66
- // Recognisable substring to find/remove our own entry without touching others.
67
- 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
+ };
68
104
  function settingsPath(dir) {
69
105
  return path.join(path.resolve(dir), ".claude", "settings.json");
70
106
  }
71
- function groupHasMarker(g) {
72
- 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));
73
109
  }
74
110
  function readSettings(file) {
75
111
  let raw;
@@ -89,60 +125,103 @@ function readSettings(file) {
89
125
  return null; // unparseable → caller must refuse, never clobber
90
126
  }
91
127
  }
92
- export function contextHookInstalled(dir) {
128
+ export function settingsHookInstalled(dir, spec) {
93
129
  const s = readSettings(settingsPath(dir));
94
- return Boolean(s?.hooks?.SessionStart?.some(groupHasMarker));
130
+ return Boolean(s?.hooks?.[spec.event]?.some((g) => groupHasMarker(g, spec.marker)));
95
131
  }
96
- export function installContextHook(dir) {
132
+ export function installSettingsHook(dir, spec) {
97
133
  const file = settingsPath(dir);
98
134
  const settings = readSettings(file);
99
135
  if (settings === null) {
100
136
  throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
101
137
  }
102
138
  const hooks = (settings.hooks ??= {});
103
- const sessionStart = (hooks.SessionStart ??= []);
104
- if (sessionStart.some(groupHasMarker))
139
+ const list = (hooks[spec.event] ??= []);
140
+ if (list.some((g) => groupHasMarker(g, spec.marker)))
105
141
  return "already";
106
- sessionStart.push({
107
- 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 }],
108
149
  });
109
150
  fs.mkdirSync(path.dirname(file), { recursive: true });
110
151
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
111
152
  return "installed";
112
153
  }
113
- export function uninstallContextHook(dir) {
154
+ export function uninstallSettingsHook(dir, spec) {
114
155
  const file = settingsPath(dir);
115
156
  const settings = readSettings(file);
116
- if (!settings || !settings.hooks?.SessionStart)
157
+ if (!settings?.hooks?.[spec.event])
117
158
  return "absent";
118
- const before = settings.hooks.SessionStart;
159
+ const before = settings.hooks[spec.event];
119
160
  // Drop our command from every group, then drop groups left empty. Foreign
120
161
  // hooks in the same group (unusual, but possible) are preserved.
121
162
  const after = before
122
163
  .map((g) => ({
123
164
  ...g,
124
- hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(HOOK_MARKER)),
165
+ hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(spec.marker)),
125
166
  }))
126
167
  .filter((g) => (g.hooks ?? []).length > 0);
127
- if (after.length === before.length && before.every((g) => !groupHasMarker(g))) {
168
+ if (after.length === before.length &&
169
+ before.every((g) => !groupHasMarker(g, spec.marker))) {
128
170
  return "absent";
129
171
  }
130
172
  if (after.length > 0)
131
- settings.hooks.SessionStart = after;
173
+ settings.hooks[spec.event] = after;
132
174
  else
133
- delete settings.hooks.SessionStart;
175
+ delete settings.hooks[spec.event];
134
176
  if (Object.keys(settings.hooks).length === 0)
135
177
  delete settings.hooks;
136
178
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
137
179
  return "removed";
138
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
+ }
193
+ /**
194
+ * The pulse and the brief, composed. Separate from printContext so the WHOLE
195
+ * thing — including the pulse — stays inside the single timeout race: adding
196
+ * work outside it would erode the "never delays a session" guarantee.
197
+ *
198
+ * The two are gathered independently on purpose. Before this, a throw inside
199
+ * buildPayload took the whole payload down, and the most common cause of that
200
+ * throw is a dead session — which is exactly when the agent most needs to be
201
+ * told something. Now a broken brief still lets the pulse through.
202
+ */
203
+ async function buildSessionContext(db, dir) {
204
+ // Local and offline: one small file plus a git rev-parse. It cannot fail
205
+ // because of the network or the session, which is the point.
206
+ const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
207
+ let brief = null;
208
+ try {
209
+ brief = await buildPayload(db, dir);
210
+ }
211
+ catch {
212
+ brief = null;
213
+ }
214
+ const parts = [pulse, brief].filter(Boolean);
215
+ return parts.length > 0 ? parts.join("\n\n") : null;
216
+ }
139
217
  export async function printContext(db, dir) {
140
218
  try {
141
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CONTEXT_TIMEOUT_MS));
219
+ const { vencido, cancelar } = presupuestoDeTiempo(CONTEXT_TIMEOUT_MS);
142
220
  const additionalContext = await Promise.race([
143
- buildPayload(db, dir),
144
- timeout,
221
+ buildSessionContext(db, dir),
222
+ vencido,
145
223
  ]);
224
+ cancelar();
146
225
  if (!additionalContext)
147
226
  return;
148
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/feed.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The atlas' feed pulse: did the automatic ingestion actually land?
3
+ *
4
+ * The post-commit hook runs detached and never blocks the commit (see hook.ts),
5
+ * which is the right call — but it means that when it fails (expired session,
6
+ * offline, out of credits) NOTHING surfaces. The atlas just stops learning, and
7
+ * the only symptom is data that looks slightly old, which is indistinguishable
8
+ * from "nothing has happened". A tool whose whole job is to notice silent
9
+ * decay must not decay silently itself.
10
+ *
11
+ * Real case that motivated this (2026-07-25): the stored session died, two
12
+ * commits in a row fed nothing, and the failure was only found by reading a log
13
+ * nobody had a reason to open — by which point the first failure's evidence had
14
+ * already been overwritten by the second.
15
+ *
16
+ * The contract is deliberately one-directional and cheap:
17
+ * `analyze --commit` (the hook path) records the outcome here.
18
+ * `guard` (pre-commit, FOREGROUND, a human is watching) reads it and says it
19
+ * out loud at the next commit.
20
+ *
21
+ * Everything here is best-effort: a read-only .git, a corrupt file or a missing
22
+ * git must never turn into a failed commit. Losing the pulse is bad; breaking
23
+ * someone's commit to report it would be worse.
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { gitPath } from "./git.js";
28
+ const FEED_FILE = "changebook-feed.json";
29
+ /** Where the post-commit hook keeps the full output; cited in the warning. */
30
+ export const HOOK_LOG_FILE = "changebook-hook.log";
31
+ const MAX_REASON_CHARS = 200;
32
+ /**
33
+ * The first paragraph of an error, which is the part that says what went wrong.
34
+ * Several of our errors append a blank line and then the multi-line AUTH_HELP;
35
+ * collapsing all of that into one line produced a warning whose "Last error:"
36
+ * trailed off mid-sentence into the help text ("… Run: changebook login It
37
+ * opens https:"). The remedy line already tells the user what to do.
38
+ */
39
+ function firstParagraph(reason) {
40
+ return reason.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim();
41
+ }
42
+ export async function feedStatusPath(dir) {
43
+ return gitPath(dir, FEED_FILE).catch(() => null);
44
+ }
45
+ export async function readFeedStatus(dir) {
46
+ try {
47
+ const file = await feedStatusPath(dir);
48
+ if (!file)
49
+ return null;
50
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
51
+ // A hand-edited or half-written file must not crash a commit: accept it
52
+ // only if the two fields the warning depends on are the right shape.
53
+ if (typeof data.ok !== "boolean" || typeof data.failures !== "number") {
54
+ return null;
55
+ }
56
+ return data;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Record how the automatic feed went. Failures accumulate a streak so the
64
+ * warning can say "the last 4 commits" instead of only ever "the last one" —
65
+ * the difference between a blip you can ignore and a week of silence you can't.
66
+ */
67
+ export async function recordFeed(dir, result, now = new Date()) {
68
+ try {
69
+ const file = await feedStatusPath(dir);
70
+ if (!file)
71
+ return;
72
+ const previous = await readFeedStatus(dir);
73
+ const at = now.toISOString();
74
+ const status = result.ok
75
+ ? { ok: true, at, failures: 0 }
76
+ : {
77
+ ok: false,
78
+ at,
79
+ failures: (previous?.ok === false ? previous.failures : 0) + 1,
80
+ since: previous?.ok === false ? (previous.since ?? at) : at,
81
+ reason: result.reason
82
+ ? firstParagraph(result.reason).slice(0, MAX_REASON_CHARS)
83
+ : undefined,
84
+ };
85
+ fs.writeFileSync(file, JSON.stringify(status));
86
+ }
87
+ catch {
88
+ // Best-effort: never let bookkeeping break a commit.
89
+ }
90
+ }
91
+ /** A session problem is the one failure the user can fix in one command. */
92
+ function needsLogin(reason) {
93
+ return /session|refresh[_ ]token|logged out|no stored session|\b40[13]\b/i.test(reason ?? "");
94
+ }
95
+ function remedyFor(reason) {
96
+ return needsLogin(reason)
97
+ ? "changebook login"
98
+ : "changebook analyze --commit HEAD (to see the error in full)";
99
+ }
100
+ /**
101
+ * The warning a human sees, as text. Built separately from printing it so it
102
+ * can be tested, same shape as guard.ts' findingsMessage. Empty string when
103
+ * there is nothing to say — a healthy feed must stay completely silent, or the
104
+ * warning becomes noise people learn to skip.
105
+ */
106
+ export function feedWarning(status, opts = {}) {
107
+ const { logPath = `.git/${HOOK_LOG_FILE}`, audience = "human" } = opts;
108
+ if (!status || status.ok || status.failures < 1)
109
+ return "";
110
+ const commits = status.failures === 1
111
+ ? "The last commit was not analyzed"
112
+ : `The last ${status.failures} commits were not analyzed`;
113
+ const since = status.since ? ` (since ${status.since.slice(0, 16).replace("T", " ")} UTC)` : "";
114
+ if (audience === "agent") {
115
+ const lines = [
116
+ "⚠ ChangeBook: this project's atlas is NOT being fed.",
117
+ `${commits}${since}, so anything it tells you about recent work is out of date — say so before you rely on it.`,
118
+ ];
119
+ if (status.reason)
120
+ lines.push(`Last error: ${status.reason}`);
121
+ lines.push(needsLogin(status.reason)
122
+ ? "Tell the user to run `changebook login` in their terminal. It opens a browser to authorize, so you cannot complete it yourself."
123
+ : `Tell the user their atlas stopped updating; the full error is in ${logPath}.`);
124
+ return lines.join("\n");
125
+ }
126
+ const lines = [
127
+ "\n⚠ ChangeBook: your atlas has stopped being fed.",
128
+ ` ${commits}${since}, so what it tells you is going stale.`,
129
+ ];
130
+ if (status.reason)
131
+ lines.push(` Last error: ${status.reason}`);
132
+ lines.push(` Fix: ${remedyFor(status.reason)}`);
133
+ lines.push(` Full output: ${logPath}\n`);
134
+ return lines.join("\n");
135
+ }
136
+ /** Convenience for callers that only want the text for a directory. */
137
+ export async function feedWarningFor(dir, opts = {}) {
138
+ const status = await readFeedStatus(dir);
139
+ if (!status || status.ok)
140
+ return "";
141
+ const file = await feedStatusPath(dir);
142
+ const logPath = file
143
+ ? path.join(path.dirname(file), HOOK_LOG_FILE)
144
+ : `.git/${HOOK_LOG_FILE}`;
145
+ return feedWarning(status, { ...opts, logPath });
146
+ }
147
+ //# sourceMappingURL=feed.js.map
package/dist/git.js CHANGED
@@ -5,8 +5,22 @@
5
5
  * compress differently across subcommands and break the server-side dedup.
6
6
  */
7
7
  import { execFile } from "node:child_process";
8
+ import * as path from "node:path";
8
9
  import { promisify } from "node:util";
9
10
  export const execFileAsync = promisify(execFile);
11
+ /**
12
+ * Absolute path to a file inside this repo's git dir (the guard's cache, the
13
+ * feed pulse, the hook log). `--git-path` rather than building
14
+ * `<git-dir>/<name>` by hand: it resolves a linked worktree's common dir, so
15
+ * every process that shares the repo agrees on where these files live.
16
+ */
17
+ export async function gitPath(dir, name) {
18
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], {
19
+ cwd: dir,
20
+ encoding: "utf8",
21
+ });
22
+ return path.resolve(dir, stdout.trim());
23
+ }
10
24
  export const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
11
25
  // Mirror of the extension's default changebook.maxDiffCharacters (the server
12
26
  // rejects anything above 60k anyway).
@@ -46,12 +60,44 @@ export function usableSummary(message) {
46
60
  return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
47
61
  }
48
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
+ ];
49
87
  export async function commitDiff(cwd, hash) {
50
88
  try {
51
89
  const { stdout } = await execFileAsync("git",
52
90
  // --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
53
91
  // as a revision, never as a git option (argument injection).
54
- ["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 });
55
101
  return stdout;
56
102
  }
57
103
  catch (error) {
package/dist/guard.js CHANGED
@@ -15,7 +15,8 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { execFileSync } from "node:child_process";
18
- import { execFileAsync } from "./git.js";
18
+ import { execFileAsync, gitPath } from "./git.js";
19
+ import { feedWarningFor } from "./feed.js";
19
20
  /** Exit code that asks the pre-commit hook to abort the commit. */
20
21
  export const EXIT_BLOCK = 3;
21
22
  // A commit should never feel slow because of us: whatever the network hasn't
@@ -200,10 +201,6 @@ export function contarEnRepo(dir, simbolo) {
200
201
  return code === 1 ? 0 : null;
201
202
  }
202
203
  }
203
- async function gitPath(dir, name) {
204
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
205
- return path.resolve(dir, stdout.trim());
206
- }
207
204
  export async function stagedFiles(dir) {
208
205
  // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
209
206
  // (default quotepath would emit "m\303\263dulo.ts", which never matches the
@@ -273,11 +270,29 @@ async function fetchSignals(db, dir, env) {
273
270
  }
274
271
  return { alerts, filesByModule, projectId, fromCache: false };
275
272
  }
276
- /** One-line last-run trace so "why didn't it warn?" is answerable. */
273
+ // Un fallo intermitente no se diagnostica con la última foto: hace falta la
274
+ // racha. Este log guardaba SOLO la última corrida, y el 2026-07-25 eso costó un
275
+ // diagnóstico — la corrida que gastó la sesión ya había sido pisada por la
276
+ // siguiente cuando fui a mirar. Acotado por tamaño, no por olvido.
277
+ const GUARD_LOG_MAX_BYTES = 65_536;
278
+ const GUARD_LOG_KEEP_BYTES = 32_768;
279
+ /** Traza por corrida, en append acotado, para que "¿por qué no avisó?" tenga respuesta. */
277
280
  async function logRun(dir, message) {
278
281
  try {
279
282
  const file = await gitPath(dir, "changebook-guard.log");
280
- fs.writeFileSync(file, `${new Date().toISOString()} ${message}\n`);
283
+ // Recortar ANTES de anexar: con appendFileSync no hay descriptor abierto de
284
+ // por medio, pero el orden mantiene el fichero por debajo del techo aunque
285
+ // esta corrida escriba una línea larga.
286
+ try {
287
+ if (fs.statSync(file).size > GUARD_LOG_MAX_BYTES) {
288
+ const keep = fs.readFileSync(file).subarray(-GUARD_LOG_KEEP_BYTES);
289
+ fs.writeFileSync(file, keep);
290
+ }
291
+ }
292
+ catch {
293
+ // El fichero aún no existe: nada que recortar.
294
+ }
295
+ fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`);
281
296
  }
282
297
  catch {
283
298
  // Best-effort only.
@@ -314,6 +329,14 @@ export async function runGuard(db, dir, env = process.env) {
314
329
  const mode = (env.CHANGEBOOK_GUARD ?? "").trim().toLowerCase();
315
330
  if (mode === "off")
316
331
  return 0;
332
+ // The atlas' feed pulse, FIRST and offline. This is the one warning that has
333
+ // to survive a dead session — a dead session is its most common cause, and
334
+ // every check below this point either needs credentials or the network. It
335
+ // reads one small file, so it cannot slow a commit down, and like everything
336
+ // else here it only informs: the exit code is untouched.
337
+ const pulse = await feedWarningFor(dir);
338
+ if (pulse)
339
+ console.error(pulse);
317
340
  if (!db.hasCredentials())
318
341
  return 0;
319
342
  let staged;
package/dist/hook.js CHANGED
@@ -22,16 +22,34 @@ function cliEntry() {
22
22
  }
23
23
  const POST_COMMIT_MARKER = "# changebook post-commit hook";
24
24
  const PRE_COMMIT_MARKER = "# changebook pre-commit guard";
25
+ // The hook log grows by one run per commit, so it needs a ceiling — but one
26
+ // that keeps enough history to diagnose a streak of failures, which is the
27
+ // whole reason it is worth keeping at all.
28
+ const LOG_MAX_BYTES = 262_144;
29
+ const LOG_TRIM_KEEP_BYTES = 131_072;
25
30
  /** Exported for tests: the contract between this script and guard.ts. */
26
31
  export function postCommitScript() {
27
32
  // Background subshell + `|| true`: a broken analyze (offline, out of
28
33
  // credits, logged out) must never make `git commit` fail or feel slow.
29
- // The log keeps only the last run so it can't grow unbounded.
34
+ //
35
+ // The log APPENDS, with a UTC header per run. It used to keep only the last
36
+ // run to stay bounded, which cost us a real diagnosis on 2026-07-25: two
37
+ // commits failed in a row and the second overwrote the evidence of the first,
38
+ // so which process broke the session was no longer answerable. Bounded is
39
+ // still right, but by trimming — not by forgetting everything but the last
40
+ // line. The trim runs BEFORE the redirection opens on purpose: replacing the
41
+ // file while the append fd is already open would leave that fd pointing at
42
+ // the unlinked inode and silently throw the run's output away.
43
+ const trimmed = `${LOG_TRIM_KEEP_BYTES}`;
30
44
  return `#!/bin/sh
31
45
  ${POST_COMMIT_MARKER} — analyzes each commit into your ChangeBook atlas.
32
46
  # Runs in the background and never blocks the commit. Remove with:
33
47
  # changebook hook uninstall
34
- ( ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
48
+ LOG="$(git rev-parse --git-path changebook-hook.log)"
49
+ if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt ${LOG_MAX_BYTES} ]; then
50
+ tail -c ${trimmed} "$LOG" > "$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG"
51
+ fi
52
+ ( { date -u +'=== %Y-%m-%dT%H:%M:%SZ'; ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD; } >> "$LOG" 2>&1 & ) || true
35
53
  `;
36
54
  }
37
55
  /** Exported for tests: the contract between this script and guard.ts. */