changebook 0.4.6 → 0.4.8

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
 
@@ -96,11 +96,6 @@ env vars below override them for CI/headless setups.
96
96
  | `CHANGEBOOK_ACCESS_TOKEN` | — | Short-lived JWT; refreshed automatically when it expires. |
97
97
  | `CHANGEBOOK_PROJECT` | — | Scope every query to one project (matched by slug, then exact name — usually the workspace folder name). Unset = all projects. |
98
98
  | `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
99
 
105
100
  ## `sync`: product map inside CLAUDE.md / AGENTS.md
106
101
 
@@ -119,7 +114,7 @@ touched. Re-run after analyzing changes (or wire it to a git hook).
119
114
  ## Security notes
120
115
 
121
116
  - MCP tools are read-only; only `analyze` writes (through the same audited
122
- Edge Function as the extension, with the same quotas).
117
+ server endpoint as the extension, with the same quotas).
123
118
  - `login` uses a loopback-only handoff: the web app asks for an explicit
124
119
  Authorize click and redirects the tokens to `http://127.0.0.1:<port>` in
125
120
  the URL fragment — they never leave your machine, and a `state` nonce ties
package/dist/context.js CHANGED
@@ -8,14 +8,22 @@
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. */
@@ -136,11 +144,35 @@ export function uninstallContextHook(dir) {
136
144
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
137
145
  return "removed";
138
146
  }
147
+ /**
148
+ * The pulse and the brief, composed. Separate from printContext so the WHOLE
149
+ * thing — including the pulse — stays inside the single timeout race: adding
150
+ * work outside it would erode the "never delays a session" guarantee.
151
+ *
152
+ * The two are gathered independently on purpose. Before this, a throw inside
153
+ * buildPayload took the whole payload down, and the most common cause of that
154
+ * throw is a dead session — which is exactly when the agent most needs to be
155
+ * told something. Now a broken brief still lets the pulse through.
156
+ */
157
+ async function buildSessionContext(db, dir) {
158
+ // Local and offline: one small file plus a git rev-parse. It cannot fail
159
+ // because of the network or the session, which is the point.
160
+ const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
161
+ let brief = null;
162
+ try {
163
+ brief = await buildPayload(db, dir);
164
+ }
165
+ catch {
166
+ brief = null;
167
+ }
168
+ const parts = [pulse, brief].filter(Boolean);
169
+ return parts.length > 0 ? parts.join("\n\n") : null;
170
+ }
139
171
  export async function printContext(db, dir) {
140
172
  try {
141
173
  const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CONTEXT_TIMEOUT_MS));
142
174
  const additionalContext = await Promise.race([
143
- buildPayload(db, dir),
175
+ buildSessionContext(db, dir),
144
176
  timeout,
145
177
  ]);
146
178
  if (!additionalContext)
@@ -61,4 +61,75 @@ export function clearCredentials() {
61
61
  return false;
62
62
  }
63
63
  }
64
+ const LOCK_FILE = path.join(DIR, "refresh.lock");
65
+ function sleep(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ /**
69
+ * Run `fn` while holding an exclusive, cross-process lock.
70
+ *
71
+ * Supabase rotates the refresh token on every use, and its reuse-detection
72
+ * revokes the WHOLE token family if a rotated token is ever replayed. The
73
+ * post-commit `analyze` runs detached in the background, so it can still be
74
+ * refreshing when the next commit's pre-commit `guard` (or another analyze)
75
+ * starts — two processes then spend the same stored refresh token and the
76
+ * account is logged out silently. A lockfile serializes the refresh across
77
+ * every process that shares the credentials file, so only one spends the token.
78
+ *
79
+ * Falls through and runs `fn` unlocked if the lock can't be taken within
80
+ * `timeoutMs` — a hung holder must never block a commit forever; that only
81
+ * degrades to the previous lock-less behavior. A stale lock left by a crashed
82
+ * holder (older than `staleMs`) is stolen.
83
+ */
84
+ export async function withCredentialsLock(fn, opts = {}) {
85
+ const lockPath = opts.lockPath ?? LOCK_FILE;
86
+ const timeoutMs = opts.timeoutMs ?? 30_000;
87
+ const staleMs = opts.staleMs ?? 60_000;
88
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
89
+ const deadline = Date.now() + timeoutMs;
90
+ let held = false;
91
+ for (;;) {
92
+ try {
93
+ const fd = fs.openSync(lockPath, "wx", 0o600);
94
+ fs.writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
95
+ fs.closeSync(fd);
96
+ held = true;
97
+ break;
98
+ }
99
+ catch (err) {
100
+ if (err.code !== "EEXIST")
101
+ throw err;
102
+ // Held by another process. Steal it only if it is stale (crashed holder).
103
+ let stolen = false;
104
+ try {
105
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
106
+ fs.rmSync(lockPath, { force: true });
107
+ stolen = true;
108
+ }
109
+ }
110
+ catch {
111
+ // The lock vanished between open and stat: retry immediately.
112
+ stolen = true;
113
+ }
114
+ if (stolen)
115
+ continue;
116
+ if (Date.now() >= deadline)
117
+ break; // give up waiting; proceed unlocked
118
+ await sleep(50);
119
+ }
120
+ }
121
+ try {
122
+ return await fn();
123
+ }
124
+ finally {
125
+ if (held) {
126
+ try {
127
+ fs.rmSync(lockPath, { force: true });
128
+ }
129
+ catch {
130
+ // Best-effort release; a leftover lock is stolen once it goes stale.
131
+ }
132
+ }
133
+ }
134
+ }
64
135
  //# sourceMappingURL=credentials.js.map
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).
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. */
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
16
  import { analyze } from "./analyze.js";
17
17
  import { atlasWebUrl, openInBrowser } from "./browser.js";
18
18
  import { clearCredentials, credentialsPath } from "./credentials.js";
19
+ import { recordFeed } from "./feed.js";
19
20
  import { runGuard } from "./guard.js";
20
21
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
21
22
  import { importHistory } from "./import.js";
@@ -136,9 +137,36 @@ async function main() {
136
137
  }
137
138
  case "analyze": {
138
139
  const db = new Supabase();
139
- requireCredentials(db);
140
140
  const options = parseAnalyzeArgs(process.argv.slice(3));
141
- await analyze(db, options);
141
+ const dir = options.dir ?? process.cwd();
142
+ // --commit IS the post-commit hook's path: the automatic feed, running
143
+ // detached where nobody reads its output. Record whether it landed so the
144
+ // next pre-commit can say it out loud (feed.ts). A manual `changebook
145
+ // analyze` is someone watching the terminal — no pulse needed, and
146
+ // recording one would let a hand-run failure warn about the hook.
147
+ const feeding = Boolean(options.commit);
148
+ if (!db.hasCredentials()) {
149
+ // Not requireCredentials(): it exits, and being logged out is exactly
150
+ // the failure the pulse exists to surface.
151
+ if (feeding)
152
+ await recordFeed(dir, { ok: false, reason: "No stored session." });
153
+ console.error(AUTH_HELP);
154
+ process.exit(1);
155
+ }
156
+ try {
157
+ await analyze(db, options);
158
+ }
159
+ catch (error) {
160
+ if (feeding) {
161
+ await recordFeed(dir, {
162
+ ok: false,
163
+ reason: error instanceof Error ? error.message : String(error),
164
+ });
165
+ }
166
+ throw error;
167
+ }
168
+ if (feeding)
169
+ await recordFeed(dir, { ok: true });
142
170
  // El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
143
171
  // así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
144
172
  // causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
@@ -147,9 +175,7 @@ async function main() {
147
175
  // archivos ni resucita un bloque borrado. Best-effort: un sync caído no
148
176
  // puede tumbar un análisis ya cobrado y registrado.
149
177
  try {
150
- await syncContextFiles(db, options.dir ?? process.cwd(), {
151
- refreshOnly: true,
152
- });
178
+ await syncContextFiles(db, dir, { refreshOnly: true });
153
179
  }
154
180
  catch (error) {
155
181
  console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
@@ -167,7 +193,14 @@ async function main() {
167
193
  // repo must commit exactly as before — runGuard resolves every failure
168
194
  // to exit 0 itself. The explicit exit also drops any fetch still racing
169
195
  // the timeout, so the commit never waits on a dangling socket.
170
- return process.exit(await runGuard(new Supabase(), arg ?? process.cwd()));
196
+ //
197
+ // allowRefresh: false — and it is that same exit that makes it necessary.
198
+ // A dropped fetch is harmless unless it happens to be the token refresh:
199
+ // Supabase rotates on receipt, so dying mid-flight burns the stored token
200
+ // without saving its replacement and logs the user out of everything.
201
+ // The guard is an optional warning with a 3.5s budget; it must never be
202
+ // able to cost a session. Expired access token → no warning this time.
203
+ return process.exit(await runGuard(new Supabase(process.env, { allowRefresh: false }), arg ?? process.cwd()));
171
204
  }
172
205
  case "hook": {
173
206
  const dir = process.argv[4] ?? process.cwd();
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 } from './credentials.js';
8
+ import { loadCredentials, 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';
@@ -22,6 +22,29 @@ variable (and optionally CHANGEBOOK_ACCESS_TOKEN). To extract it manually, sign
22
22
  in at https://changebook.app, open the browser console and run:
23
23
 
24
24
  JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.endsWith("-auth-token")))).refresh_token`;
25
+ /**
26
+ * Decide what to do at the start of a refresh, given the refresh token this
27
+ * process held before it took the lock and whatever is on disk now.
28
+ *
29
+ * If another process refreshed while we waited for the lock, the stored refresh
30
+ * token differs from ours AND comes with a fresh access token: adopt those and
31
+ * never spend our own. Replaying a rotated token trips Supabase's
32
+ * reuse-detection and revokes the whole family — the silent logout this guards
33
+ * against. Otherwise spend the freshest refresh token available.
34
+ */
35
+ export function decideRefresh(priorRefreshToken, stored) {
36
+ if (stored?.refresh_token &&
37
+ stored.refresh_token !== priorRefreshToken &&
38
+ stored.access_token) {
39
+ return {
40
+ kind: 'adopt',
41
+ access_token: stored.access_token,
42
+ refresh_token: stored.refresh_token,
43
+ };
44
+ }
45
+ const token = stored?.refresh_token ?? priorRefreshToken;
46
+ return token ? { kind: 'spend', refresh_token: token } : { kind: 'none' };
47
+ }
25
48
  export class SupabaseError extends Error {
26
49
  status;
27
50
  constructor(message, status) {
@@ -41,7 +64,21 @@ export class Supabase {
41
64
  // True when the tokens came from ~/.changebook/credentials.json: rotated
42
65
  // refresh tokens must be written back there or the stored one goes stale.
43
66
  persistRotation = false;
44
- constructor(env = process.env) {
67
+ /**
68
+ * Whether this process is allowed to SPEND the rotating refresh token.
69
+ *
70
+ * False for callers that run under a hard deadline and end in process.exit()
71
+ * — today, the pre-commit guard. Supabase rotates the refresh token the
72
+ * moment it RECEIVES the request, not when we read the reply, so a process
73
+ * that is killed mid-refresh burns the stored token without ever persisting
74
+ * the new one. The next process replays a spent token, Supabase's reuse
75
+ * detection fires, and the whole session dies. That is a silent logout
76
+ * caused by an optional warning — a terrible trade (diagnosed 2026-07-25:
77
+ * the session died ~30 min after every login, always on a commit).
78
+ */
79
+ allowRefresh = true;
80
+ constructor(env = process.env, opts = {}) {
81
+ this.allowRefresh = opts.allowRefresh ?? true;
45
82
  this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
46
83
  this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
47
84
  this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
@@ -264,30 +301,41 @@ export class Supabase {
264
301
  });
265
302
  return this.refreshing;
266
303
  }
267
- async doRefresh() {
268
- // The credentials file is shared across processes (the MCP server, the
269
- // post-commit hook, a second editor window). Any of them may have rotated
270
- // the token since we loaded it, so pick up the freshest one on disk before
271
- // spending ours — otherwise we replay a stale token and get logged out.
272
- if (this.persistRotation) {
273
- const stored = loadCredentials();
274
- if (stored?.refresh_token)
275
- this.refreshToken = stored.refresh_token;
304
+ doRefresh() {
305
+ // Refusing BEFORE the request is the whole point: once it leaves, the token
306
+ // is spent whether or not we survive to store the replacement. Callers that
307
+ // opt out get a plain 401 they can treat as "no atlas this time".
308
+ if (!this.allowRefresh) {
309
+ return Promise.reject(new SupabaseError("The stored session needs renewing and this process is not allowed to spend it (it runs under a deadline). Skipping.", 401));
276
310
  }
277
- if (!this.refreshToken) {
311
+ // Env-var sessions aren't shared through the credentials file, so there is
312
+ // nothing to coordinate between processes: refresh in place.
313
+ if (!this.persistRotation)
314
+ return this.doRefreshInner();
315
+ // Disk-backed sessions (from `changebook login`) can be refreshed by
316
+ // several processes at once — the detached post-commit analyze, the
317
+ // pre-commit guard, a second editor window. Serialize under a cross-process
318
+ // lock so only one spends the rotating token; the others adopt its result
319
+ // instead of replaying a token Supabase has already rotated (which would
320
+ // trip reuse-detection and log the whole account out silently).
321
+ return withCredentialsLock(() => this.doRefreshInner());
322
+ }
323
+ async doRefreshInner() {
324
+ // Under the lock: re-read disk so a refresh another process just completed
325
+ // is picked up before we spend anything.
326
+ const stored = this.persistRotation ? loadCredentials() : null;
327
+ const plan = decideRefresh(this.refreshToken, stored);
328
+ if (plan.kind === 'none') {
278
329
  throw new SupabaseError(`Session expired. ${AUTH_HELP}`, 401);
279
330
  }
280
- let res = await this.refreshOnce(this.refreshToken);
281
- // If the refresh was rejected and the session came from disk, another
282
- // process may have rotated the token in the tiny window since we read it;
283
- // reload once and retry with the newest before giving up.
284
- if (!res.ok && this.persistRotation) {
285
- const stored = loadCredentials();
286
- if (stored?.refresh_token && stored.refresh_token !== this.refreshToken) {
287
- this.refreshToken = stored.refresh_token;
288
- res = await this.refreshOnce(this.refreshToken);
289
- }
331
+ if (plan.kind === 'adopt') {
332
+ // Another process refreshed while we waited: use its fresh tokens.
333
+ this.accessToken = plan.access_token;
334
+ this.refreshToken = plan.refresh_token;
335
+ return;
290
336
  }
337
+ this.refreshToken = plan.refresh_token;
338
+ const res = await this.refreshOnce(this.refreshToken);
291
339
  if (!res.ok) {
292
340
  const body = (await res.text()).slice(0, 300);
293
341
  throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
package/dist/sync.js CHANGED
@@ -50,6 +50,27 @@ const SYNC_BUDGET_CHARS = 2_000;
50
50
  // shared analyses and a ≥60% rate before we call it a dependency.
51
51
  const MIN_PAIR_COUNT = 3;
52
52
  const MIN_PAIR_RATE = 0.6;
53
+ /**
54
+ * Espejo de supabase/functions/mcp/scope.ts::summarizeHealth — el paquete npm
55
+ * es autocontenido. Paridad fijada en test/saludEnBrief.
56
+ */
57
+ export function summarizeHealth(rows) {
58
+ let passed = 0;
59
+ const at_risk = [];
60
+ for (const r of rows) {
61
+ if (r.status === 'passed') {
62
+ passed += 1;
63
+ }
64
+ else if (r.status === 'at_risk') {
65
+ at_risk.push({
66
+ check: r.check_id,
67
+ evidence: (r.evidence ?? '').trim(),
68
+ since: r.updated_at ? r.updated_at.slice(0, 10) : null,
69
+ });
70
+ }
71
+ }
72
+ return { passed, at_risk };
73
+ }
53
74
  // Same window/limit the web uses for the signals strip.
54
75
  const ALERT_WINDOW_DAYS = 14;
55
76
  const MAX_ALERTS = 3;
@@ -76,7 +97,7 @@ export async function fetchBriefSection(db, targetDir) {
76
97
  }
77
98
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
78
99
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
79
- const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
100
+ const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
80
101
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
81
102
  projectFilter),
82
103
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -93,8 +114,14 @@ export async function fetchBriefSection(db, targetDir) {
93
114
  .then((rows) => rows.filter((r) => r.status === 'pending'))
94
115
  .catch(() => [])
95
116
  : Promise.resolve([]),
117
+ // Salud (project_checks): tabla diminuta (≤8 filas/proyecto), en paralelo.
118
+ db
119
+ .rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
120
+ projectFilter)
121
+ .catch(() => []),
96
122
  ]);
97
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
123
+ const health = summarizeHealth(healthRows);
124
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk);
98
125
  return { section, projectId, projectResolved };
99
126
  }
100
127
  export async function syncContextFiles(db, targetDir, opts = {}) {
@@ -119,7 +146,7 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
119
146
  }
120
147
  }
121
148
  /** Exported for tests. */
122
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
149
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = []) {
123
150
  // Newest-first rows: the first occurrence of a module is its latest state.
124
151
  const seen = new Map();
125
152
  for (const row of rows) {
@@ -225,6 +252,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
225
252
  return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
226
253
  });
227
254
  const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
255
+ // Salud en riesgo: los controles (auth, secretos, validación, límites…) que
256
+ // el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
257
+ // es seguridad. Solo los at_risk (lo accionable); el texto entero va a
258
+ // `atlas_project_brief`. Cap a 130 chars como las alertas.
259
+ const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
228
260
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
229
261
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
230
262
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
@@ -261,6 +293,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
261
293
  title: '### Regresiones detectadas (resolver o verificar YA)',
262
294
  lines: alertLines,
263
295
  },
296
+ {
297
+ key: 'health',
298
+ priority: 1,
299
+ title: '### Salud en riesgo (verifica antes de tocar)',
300
+ lines: healthLines,
301
+ },
264
302
  {
265
303
  key: 'hotspots',
266
304
  priority: 4,
package/dist/tools.js CHANGED
@@ -96,6 +96,31 @@ export function quotedInList(values) {
96
96
  .join(",");
97
97
  }
98
98
  export const FILES_CAP = 8;
99
+ /**
100
+ * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
101
+ * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
102
+ * regresión DISTINTOS (count(distinct plain), all-time), solo los con
103
+ * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
104
+ * infle. No se refuta: cuenta historia, no vigencia.
105
+ */
106
+ export function computeRecidivism(rows) {
107
+ const plainsByModule = new Map();
108
+ for (const r of rows) {
109
+ const m = (r.module ?? "").trim();
110
+ const p = (r.plain ?? "").trim();
111
+ if (!m || !p)
112
+ continue;
113
+ const set = plainsByModule.get(m) ?? new Set();
114
+ set.add(p);
115
+ plainsByModule.set(m, set);
116
+ }
117
+ const out = new Map();
118
+ for (const [m, set] of plainsByModule) {
119
+ if (set.size >= 2)
120
+ out.set(m, set.size);
121
+ }
122
+ return out;
123
+ }
99
124
  export function filesUnionByChange(rows, cap = FILES_CAP) {
100
125
  const acc = new Map();
101
126
  for (const r of rows) {
@@ -631,7 +656,7 @@ Args:
631
656
  - files (required): 1-8 repo-relative paths.
632
657
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
633
658
 
634
- Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
659
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recidivism: [{ module, prior_regressions }], recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
635
660
  inputSchema: {
636
661
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
637
662
  .describe("Repo-relative paths you are about to edit"),
@@ -710,7 +735,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
710
735
  for (const f of perFile) {
711
736
  commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
712
737
  }
713
- const [alerts, watched] = await Promise.all([
738
+ const [alerts, watched, recidivismRows] = await Promise.all([
714
739
  moduleNames.length
715
740
  ? 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` +
716
741
  pf)
@@ -723,6 +748,17 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
723
748
  .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
724
749
  pf)
725
750
  .catch(() => []),
751
+ // Reincidencia (edit-time, espejo del hospedado): problemas de
752
+ // regresión de TODO el tiempo (sin filtro resolved) de los módulos
753
+ // tocados. Depende de moduleNames como las alertas → mismo Promise.all,
754
+ // sin ronda extra. Se cuenta distinct plain abajo (mismo criterio que
755
+ // el RPC del brief).
756
+ moduleNames.length
757
+ ? db
758
+ .rest(`regression_alerts?select=module,plain&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&limit=500` +
759
+ pf)
760
+ .catch(() => [])
761
+ : Promise.resolve([]),
726
762
  ]);
727
763
  const watchedByFile = new Map();
728
764
  for (const w of watched) {
@@ -747,6 +783,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
747
783
  continue;
748
784
  alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
749
785
  }
786
+ // Reincidencia: nº de problemas de regresión DISTINTOS por módulo
787
+ // (count(distinct plain), all-time), solo los con antecedentes (>= 2).
788
+ // Fuente única compartida (computeRecidivism) — antes 3 copias.
789
+ const recidivismByModule = computeRecidivism(recidivismRows);
750
790
  // Ancla temporal: el último commit analizado del proyecto vs el HEAD
751
791
  // de este árbol (misma puerta de proyecto que la refutación).
752
792
  // Best-effort: el ancla jamás rompe la lectura que ancla.
@@ -778,8 +818,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
778
818
  lines.push("No atlas history for this file yet (new or never analyzed).");
779
819
  }
780
820
  for (const m of f.modules) {
821
+ const previas = recidivismByModule.get(m.module);
781
822
  lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
782
- (m.risk ? `, risk: ${m.risk}` : ""));
823
+ (m.risk ? `, risk: ${m.risk}` : "") +
824
+ (previas ? ` · ⚠ ${previas} prior regressions` : ""));
783
825
  if (m.last_note) {
784
826
  // Con fecha: una nota es una observación fechada, no estado.
785
827
  lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
@@ -816,6 +858,12 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
816
858
  module: m.module,
817
859
  plain,
818
860
  }))),
861
+ recidivism: f.modules
862
+ .map((m) => ({
863
+ module: m.module,
864
+ prior_regressions: recidivismByModule.get(m.module) ?? 0,
865
+ }))
866
+ .filter((x) => x.prior_regressions >= 2),
819
867
  watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
820
868
  name: w.name,
821
869
  value: w.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
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.6",
5
+ "version": "0.4.8",
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.6",
18
+ "version": "0.4.8",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }