changebook 0.3.1 → 0.4.0

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
@@ -7,7 +7,24 @@ 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
8
  read-only, and Row Level Security scopes every query to the signed-in user.
9
9
 
10
- ## Quick start
10
+ ## Two ways to run it
11
+
12
+ **Local (npm):** the CLI runs the MCP server on stdio — no server to host, works
13
+ offline against your account. See Quick start below.
14
+
15
+ **Hosted (HTTP, no install):** point any client at the hosted endpoint with a
16
+ Personal Access Token — nothing to install, just a URL:
17
+
18
+ ```bash
19
+ claude mcp add --transport http changebook \
20
+ https://mcp.changebook.app \
21
+ --header "Authorization: Bearer <your-PAT>"
22
+ ```
23
+
24
+ Generate the PAT in the web app (Account → Access tokens). The token is
25
+ SHA-256-hashed server-side and revocable; every query is scoped to your account.
26
+
27
+ ## Quick start (local)
11
28
 
12
29
  ```bash
13
30
  npx changebook init # login (browser) + register in Claude Code/Codex + sync
@@ -17,7 +34,9 @@ npx changebook init # login (browser) + register in Claude Code/Codex + sync
17
34
  (one Authorize click — no token copy-pasting), registers the MCP server in
18
35
  **every coding agent it finds on the machine** — Claude Code, Codex, Cursor,
19
36
  Windsurf, Claude Desktop and VS Code (Copilot agent mode) — installs the
20
- post-commit hook so the atlas updates itself, and writes the product map
37
+ git hooks (post-commit: the atlas updates itself; pre-commit: the signal
38
+ guard warns before touching a module with an open alert), and writes the
39
+ product map
21
40
  into the project's `CLAUDE.md`/`AGENTS.md`. It only touches agents that are
22
41
  actually installed, and merges into existing MCP configs without clobbering
23
42
  your other servers.
@@ -30,7 +49,8 @@ your other servers.
30
49
  | `changebook logout` | Forget the stored session. |
31
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. |
32
51
  | `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
33
- | `changebook hook install\|uninstall\|status [dir]` | Git post-commit hook: every new commit is analyzed in the background (never blocks the commit). One hook covers Claude Code, Codex and manual commits — they all commit through git. |
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 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. |
34
54
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
35
55
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
36
56
  | `changebook open` | Open the web atlas in the browser. |
package/dist/analyze.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import { atlasWebUrl } from "./browser.js";
9
9
  import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
10
+ import { canonicalDiffHash } from "./canonical.js";
10
11
  import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
11
12
  export async function analyze(db, options = {}) {
12
13
  const cwd = path.resolve(options.dir ?? process.cwd());
@@ -46,6 +47,9 @@ export async function analyze(db, options = {}) {
46
47
  const { status, body } = await db.invokeFunction("analyze-diff", {
47
48
  compressedDiff,
48
49
  rawDiffChars: rawDiff.length,
50
+ // Identidad de contenido calculada sobre el diff CRUDO, antes de
51
+ // comprimir: es lo único que coincide entre este hook y el webhook.
52
+ rawContentHash: canonicalDiffHash(rawDiff),
49
53
  projectName,
50
54
  commitHash,
51
55
  committedAt,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Canonical content hash of a unified diff — mirror of the server's
3
+ * canonicalDiffContent/canonicalDiffHash in supabase/functions/_shared/
4
+ * analysis.ts. The npm package must be self-contained, so this is a
5
+ * deliberate duplicate pinned equal by test/canonicalParity.test.ts: drift
6
+ * breaks CI, never the dedup.
7
+ *
8
+ * Why the CLIENT computes it, on the RAW diff, BEFORE compressing: the CLI
9
+ * compresses with optimize.ts and the GitHub webhook sends the API patch
10
+ * as-is, so the texts that reach the server for the SAME change differ and
11
+ * no server-side hash can reconcile them (visto en vivo 2026-07-18: el
12
+ * squash de la PR #165 no dedupeó contra el análisis del hook). Only the raw
13
+ * content, canonicalized, is a stable identity across senders.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ export function canonicalDiffContent(diff) {
17
+ const out = [];
18
+ let minusPath = '';
19
+ for (const line of diff.split('\n')) {
20
+ if (line.startsWith('--- a/') || line === '--- /dev/null') {
21
+ minusPath = line === '--- /dev/null' ? '' : line.slice(6).trim();
22
+ continue;
23
+ }
24
+ if (line.startsWith('+++ b/') || line === '+++ /dev/null') {
25
+ const path = line === '+++ /dev/null' ? minusPath : line.slice(6).trim();
26
+ out.push(`F:${path}`);
27
+ continue;
28
+ }
29
+ if (line.startsWith('Binary files ')) {
30
+ out.push(line);
31
+ continue;
32
+ }
33
+ if (line.startsWith('+') || line.startsWith('-'))
34
+ out.push(line);
35
+ }
36
+ return out.join('\n');
37
+ }
38
+ export function canonicalDiffHash(diff) {
39
+ const canonical = canonicalDiffContent(diff);
40
+ return createHash('sha256')
41
+ .update((canonical || diff).replace(/\s+/g, ''))
42
+ .digest('hex');
43
+ }
44
+ //# sourceMappingURL=canonical.js.map
package/dist/guard.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `changebook guard` — the pre-commit signal guard. Checks the files staged
3
+ * for commit against the atlas' open regression alerts and warns when the
4
+ * commit is about to touch a module with an unresolved alert ("you're editing
5
+ * something that a previous change already broke").
6
+ *
7
+ * Contract with the pre-commit hook (see hook.ts):
8
+ * exit 0 — pass (including every failure mode: offline, logged out, no
9
+ * project in the atlas… a guard that blocks commits when the
10
+ * network is down would get uninstalled the same day)
11
+ * exit 3 — findings under CHANGEBOOK_GUARD=block; the hook maps it to a
12
+ * failed commit. Any OTHER non-zero exit (node missing, crash)
13
+ * deliberately does NOT block.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { execFileAsync } from "./git.js";
18
+ /** Exit code that asks the pre-commit hook to abort the commit. */
19
+ export const EXIT_BLOCK = 3;
20
+ // A commit should never feel slow because of us: whatever the network hasn't
21
+ // answered by then is treated as "no findings".
22
+ const GUARD_TIMEOUT_MS = 3_500;
23
+ // Open alerts move at analysis speed (one per commit at most), so a short
24
+ // cache makes rebases/amend streaks free without risking stale warnings.
25
+ const CACHE_TTL_MS = 5 * 60_000;
26
+ const MAX_ALERTS = 20;
27
+ /**
28
+ * Same slug the backend derives from the project name (analysis.ts slugify):
29
+ * the CLI sends `path.basename(cwd)` as projectName and the server slugifies
30
+ * it, so reproducing that transform is how the guard finds its project row.
31
+ */
32
+ export function slugifyProject(value) {
33
+ return value
34
+ .normalize("NFD")
35
+ .replace(/[\u0300-\u036f]/g, "") // strip diacritics left by NFD
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, "-")
38
+ .replace(/^-+|-+$/g, "")
39
+ .slice(0, 50);
40
+ }
41
+ /**
42
+ * PostgREST `in.(...)` list. Module labels are human text ("Perfil y cuenta")
43
+ * so every value is double-quoted with `"` and `\` escaped — a comma or paren
44
+ * inside a label must never split the list.
45
+ */
46
+ export function pgInList(values) {
47
+ return values
48
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
49
+ .join(",");
50
+ }
51
+ /**
52
+ * change_module rows → the UNION of files each module touched recently. The
53
+ * union, not the latest row: a module's changes touch different files each
54
+ * time, and an alerted module's alert applies to ALL of them — matching only
55
+ * the newest snapshot silently missed real hits (visto en vivo 2026-07-18:
56
+ * PublicAtlas.tsx pertenece a «Páginas legales» por filas anteriores y el
57
+ * guardián calló ante su alerta abierta).
58
+ */
59
+ export function moduleFilesUnion(rows) {
60
+ const map = new Map();
61
+ for (const row of rows) {
62
+ const label = (row.module ?? "").trim();
63
+ if (!label)
64
+ continue;
65
+ const set = map.get(label) ?? new Set();
66
+ if (Array.isArray(row.files)) {
67
+ for (const f of row.files)
68
+ set.add(String(f));
69
+ }
70
+ map.set(label, set);
71
+ }
72
+ return new Map([...map.entries()].map(([k, v]) => [k, [...v]]));
73
+ }
74
+ /** Open alerts × staged files → warnings, deduped by (module, message). */
75
+ export function guardFindings(staged, alerts, filesByModule) {
76
+ const stagedSet = new Set(staged);
77
+ const seen = new Set();
78
+ const findings = [];
79
+ for (const alert of alerts) {
80
+ const module = (alert.module ?? "").trim();
81
+ const plain = (alert.plain ?? "").trim();
82
+ if (!module || !plain)
83
+ continue;
84
+ const touched = (filesByModule.get(module) ?? []).filter((f) => stagedSet.has(f));
85
+ if (touched.length === 0)
86
+ continue;
87
+ const key = module + "\u0000" + plain;
88
+ if (seen.has(key))
89
+ continue;
90
+ seen.add(key);
91
+ findings.push({ module, plain, staged: touched });
92
+ }
93
+ return findings;
94
+ }
95
+ async function gitPath(dir, name) {
96
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
97
+ return path.resolve(dir, stdout.trim());
98
+ }
99
+ export async function stagedFiles(dir) {
100
+ // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
101
+ // (default quotepath would emit "m\303\263dulo.ts", which never matches the
102
+ // real UTF-8 path the atlas stores → the alert is silenced). -z also covers
103
+ // paths with newlines. Product en español: rutas con acentos son plausibles.
104
+ const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], { cwd: dir, encoding: "utf8" });
105
+ return stdout.split("\0").filter(Boolean);
106
+ }
107
+ function loadCache(file) {
108
+ try {
109
+ const cache = JSON.parse(fs.readFileSync(file, "utf8"));
110
+ if (Date.now() - cache.fetched_at > CACHE_TTL_MS)
111
+ return null;
112
+ return cache;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ async function fetchSignals(db, dir, env) {
119
+ const cacheFile = await gitPath(dir, "changebook-guard-cache.json").catch(() => null);
120
+ const cached = cacheFile ? loadCache(cacheFile) : null;
121
+ if (cached) {
122
+ return {
123
+ alerts: cached.alerts,
124
+ filesByModule: new Map(Object.entries(cached.files)),
125
+ projectId: cached.project_id,
126
+ fromCache: true,
127
+ };
128
+ }
129
+ // Same project the analyze/hook pipeline reports to: CHANGEBOOK_PROJECT
130
+ // wins, otherwise the directory name, matched by server-side slug first.
131
+ const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
132
+ const slug = slugifyProject(candidate);
133
+ let projects = slug
134
+ ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
135
+ : [];
136
+ if (projects.length === 0) {
137
+ projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
138
+ }
139
+ let alerts = [];
140
+ let filesByModule = new Map();
141
+ const projectId = projects[0]?.id ?? null;
142
+ if (projectId) {
143
+ alerts = await db.rest(`regression_alerts?select=module,plain,created_at&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
144
+ const modules = [
145
+ ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
146
+ ];
147
+ if (modules.length > 0) {
148
+ const rows = await db.rest(`change_module?select=module,files,created_at&project_id=eq.${projectId}&module=in.(${encodeURIComponent(pgInList(modules))})&order=created_at.desc&limit=200`);
149
+ filesByModule = moduleFilesUnion(rows);
150
+ }
151
+ }
152
+ if (cacheFile) {
153
+ const cache = {
154
+ fetched_at: Date.now(),
155
+ project_id: projectId,
156
+ alerts,
157
+ files: Object.fromEntries(filesByModule),
158
+ };
159
+ try {
160
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
161
+ }
162
+ catch {
163
+ // Cache is an optimization; a read-only .git dir must not break the guard.
164
+ }
165
+ }
166
+ return { alerts, filesByModule, projectId, fromCache: false };
167
+ }
168
+ /** One-line last-run trace so "why didn't it warn?" is answerable. */
169
+ async function logRun(dir, message) {
170
+ try {
171
+ const file = await gitPath(dir, "changebook-guard.log");
172
+ fs.writeFileSync(file, `${new Date().toISOString()} ${message}\n`);
173
+ }
174
+ catch {
175
+ // Best-effort only.
176
+ }
177
+ }
178
+ function printFindings(findings, block) {
179
+ console.error(`\n⚠ ChangeBook: ${findings.length === 1 ? "an open alert" : `${findings.length} open alerts`} on what you're about to commit:\n`);
180
+ for (const f of findings) {
181
+ console.error(` • ${f.module} — ${f.plain}`);
182
+ console.error(` staged: ${f.staged.slice(0, 5).join(", ")}`);
183
+ }
184
+ console.error(block
185
+ ? "\nCommit blocked (CHANGEBOOK_GUARD=block). Review the alert in your atlas (changebook open) or bypass once with: git commit --no-verify\n"
186
+ : "\nReview or dismiss the alert in your atlas: changebook open\n");
187
+ }
188
+ /**
189
+ * Returns the process exit code. Everything that can go wrong resolves to 0
190
+ * (pass): the guard informs, it does not gatekeep — except when the user
191
+ * explicitly opts into CHANGEBOOK_GUARD=block.
192
+ */
193
+ export async function runGuard(db, dir, env = process.env) {
194
+ const mode = (env.CHANGEBOOK_GUARD ?? "").trim().toLowerCase();
195
+ if (mode === "off")
196
+ return 0;
197
+ if (!db.hasCredentials())
198
+ return 0;
199
+ let staged;
200
+ try {
201
+ staged = await stagedFiles(dir);
202
+ }
203
+ catch {
204
+ return 0;
205
+ }
206
+ if (staged.length === 0)
207
+ return 0;
208
+ let signals;
209
+ try {
210
+ signals = await Promise.race([
211
+ fetchSignals(db, dir, env),
212
+ new Promise((resolve) => {
213
+ // The caller process.exit()s right after, so the losing fetch never
214
+ // holds the commit hostage; unref keeps the timer from doing so either.
215
+ setTimeout(() => resolve(null), GUARD_TIMEOUT_MS).unref();
216
+ }),
217
+ ]);
218
+ }
219
+ catch (error) {
220
+ await logRun(dir, `error: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`);
221
+ return 0;
222
+ }
223
+ if (signals === null) {
224
+ await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
225
+ return 0;
226
+ }
227
+ const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
228
+ // La consulta del guardián también es una consulta del atlas (QA
229
+ // 2026-07-18: el contador solo veía las tools MCP y el trabajo más
230
+ // constante del atlas era invisible). Solo las frescas — un rebase servido
231
+ // de caché no re-consulta nada. Best-effort y ACOTADO: jamás puede
232
+ // convertir un commit rápido en uno lento.
233
+ if (!signals.fromCache && signals.projectId) {
234
+ await Promise.race([
235
+ db
236
+ .insertRow("atlas_reads", {
237
+ project_id: signals.projectId,
238
+ tool: "guard_precommit",
239
+ source: "guard",
240
+ })
241
+ .catch(() => { }),
242
+ new Promise((resolve) => {
243
+ setTimeout(resolve, 800).unref();
244
+ }),
245
+ ]);
246
+ }
247
+ await logRun(dir, `staged=${staged.length} openAlerts=${signals.alerts.length} findings=${findings.length}`);
248
+ if (findings.length === 0)
249
+ return 0;
250
+ const block = mode === "block";
251
+ printFindings(findings, block);
252
+ return block ? EXIT_BLOCK : 0;
253
+ }
254
+ //# sourceMappingURL=guard.js.map
package/dist/hook.js CHANGED
@@ -1,33 +1,77 @@
1
1
  /**
2
- * `changebook hook install|uninstall|status [dir]` — git post-commit hook that
3
- * analyzes each new commit into the atlas, in the background, without ever
4
- * blocking the commit. One analysis per commit, deduped server-side by hash,
5
- * so the cost is bounded and re-runs are free. This one hook covers every
6
- * client that commits: Claude Code, Codex, the terminal, any editor.
2
+ * `changebook hook install|uninstall|status [dir]` — the pair of git hooks
3
+ * that make the atlas live:
4
+ *
5
+ * post-commit analyzes each new commit into the atlas, in the background,
6
+ * without ever blocking the commit. One analysis per commit,
7
+ * deduped server-side by hash, so the cost is bounded and
8
+ * re-runs are free.
9
+ * pre-commit the signal guard: warns (never blocks, unless the user opts
10
+ * into CHANGEBOOK_GUARD=block) when a staged file belongs to a
11
+ * module with an open regression alert.
12
+ *
13
+ * These two hooks cover every client that commits: Claude Code, Codex, the
14
+ * terminal, any editor.
7
15
  */
8
16
  import * as fs from "node:fs";
9
17
  import * as path from "node:path";
10
18
  import { fileURLToPath } from "node:url";
11
19
  import { execFileAsync } from "./git.js";
12
- const MARKER = "# changebook post-commit hook";
13
- function hookScript() {
14
- const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
20
+ function cliEntry() {
21
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
22
+ }
23
+ const POST_COMMIT_MARKER = "# changebook post-commit hook";
24
+ const PRE_COMMIT_MARKER = "# changebook pre-commit guard";
25
+ /** Exported for tests: the contract between this script and guard.ts. */
26
+ export function postCommitScript() {
15
27
  // Background subshell + `|| true`: a broken analyze (offline, out of
16
28
  // credits, logged out) must never make `git commit` fail or feel slow.
17
29
  // The log keeps only the last run so it can't grow unbounded.
18
30
  return `#!/bin/sh
19
- ${MARKER} — analyzes each commit into your ChangeBook atlas.
31
+ ${POST_COMMIT_MARKER} — analyzes each commit into your ChangeBook atlas.
20
32
  # Runs in the background and never blocks the commit. Remove with:
21
33
  # changebook hook uninstall
22
- ( ${JSON.stringify(process.execPath)} ${JSON.stringify(entry)} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
34
+ ( ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
35
+ `;
36
+ }
37
+ /** Exported for tests: the contract between this script and guard.ts. */
38
+ export function preCommitScript() {
39
+ // Foreground (a warning printed after the commit would be pointless) but
40
+ // fail-open by exit-code contract: ONLY the guard's deliberate exit 3
41
+ // (CHANGEBOOK_GUARD=block with findings) aborts the commit. A missing node,
42
+ // a crash, a network error — any other status — lets the commit through.
43
+ // The if/elif form is immune to `set -e`: a non-zero guard exit (crash,
44
+ // node missing, exit 3) is consumed by the condition instead of aborting the
45
+ // script mid-line. This matters when the line is pasted into a foreign
46
+ // pre-commit that runs under `set -e` (classic Husky) — only the deliberate
47
+ // exit 3 must block, never a crash.
48
+ return `#!/bin/sh
49
+ ${PRE_COMMIT_MARKER} — warns when a staged file belongs to a module with an
50
+ # open ChangeBook alert. Warn-only unless CHANGEBOOK_GUARD=block. Remove with:
51
+ # changebook hook uninstall
52
+ if ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} guard; then :; elif [ $? -eq 3 ]; then exit 1; fi
23
53
  `;
24
54
  }
25
- async function hookPath(dir) {
55
+ const HOOKS = [
56
+ {
57
+ name: "post-commit",
58
+ marker: POST_COMMIT_MARKER,
59
+ script: postCommitScript,
60
+ installedNote: "Every new commit is analyzed into your atlas in the background (1 credit each; re-runs of the same commit are free).",
61
+ },
62
+ {
63
+ name: "pre-commit",
64
+ marker: PRE_COMMIT_MARKER,
65
+ script: preCommitScript,
66
+ installedNote: "Before each commit, you'll be warned if a staged file belongs to a module with an open alert (set CHANGEBOOK_GUARD=block to make it blocking, =off to silence it).",
67
+ },
68
+ ];
69
+ async function hookPath(dir, name) {
26
70
  // --git-path (not --git-dir + "/hooks") resolves core.hooksPath — set by
27
71
  // Husky in most modern JS repos to .husky/ — and linked worktrees' common
28
- // dir. Building <git-dir>/hooks/post-commit by hand ignores both, so the hook
72
+ // dir. Building <git-dir>/hooks/<name> by hand ignores both, so the hook
29
73
  // installed to a path git never runs and the atlas silently stops updating.
30
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks/post-commit"], {
74
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", `hooks/${name}`], {
31
75
  cwd: dir,
32
76
  encoding: "utf8",
33
77
  }).catch(() => {
@@ -36,34 +80,84 @@ async function hookPath(dir) {
36
80
  // The path is relative to `dir`.
37
81
  return path.resolve(dir, stdout.trim());
38
82
  }
83
+ /** Whether `child` resolves inside `parent` (not the same, actually within). */
84
+ function isInside(parent, child) {
85
+ const rel = path.relative(parent, child);
86
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
87
+ }
88
+ /**
89
+ * Refuse to install into a GLOBAL core.hooksPath. `git config --global
90
+ * core.hooksPath ~/.git-hooks` (a common setup) makes `--git-path hooks/…`
91
+ * resolve OUTSIDE this repo, so the post-commit would fire for EVERY repo on
92
+ * the machine — including private work repos — uploading their diffs and
93
+ * minting phantom projects. Husky's in-repo .husky/ stays inside the worktree
94
+ * and is fine; only a hooks dir outside both the worktree and the git-dir is
95
+ * the dangerous global case.
96
+ */
97
+ async function assertHookInsideRepo(dir, file) {
98
+ const run = (args) => execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
99
+ .then((r) => r.stdout.trim())
100
+ .catch(() => "");
101
+ const top = await run(["rev-parse", "--show-toplevel"]);
102
+ const common = await run(["rev-parse", "--git-common-dir"]);
103
+ const roots = [top, common && path.resolve(dir, common)].filter(Boolean);
104
+ if (roots.length > 0 && !roots.some((root) => isInside(root, file))) {
105
+ throw new Error(`Refusing to install: core.hooksPath points OUTSIDE this repo (${path.dirname(file)}), ` +
106
+ `so the hook would run for every repo on your machine and upload their diffs. ` +
107
+ `Install per-repo by unsetting the global hooksPath, or point it at an in-repo dir like .husky/.`);
108
+ }
109
+ }
39
110
  export async function installHook(dir) {
40
- const file = await hookPath(dir);
41
- if (fs.existsSync(file)) {
42
- const current = fs.readFileSync(file, "utf8");
43
- if (!current.includes(MARKER)) {
44
- throw new Error(`A post-commit hook already exists at ${file} and it isn't ChangeBook's — not overwriting it. Add this line to it manually if you want both:\n\n` +
45
- hookScript().split("\n").slice(-2).join("\n"));
111
+ // Install each hook independently and report the conflicts together at the
112
+ // end: a foreign pre-commit (Husky, a repo's own guard) must not stop the
113
+ // post-commit analyzer from being installed, and vice versa.
114
+ const problems = [];
115
+ for (const hook of HOOKS) {
116
+ const file = await hookPath(dir, hook.name);
117
+ await assertHookInsideRepo(dir, file);
118
+ if (fs.existsSync(file)) {
119
+ const current = fs.readFileSync(file, "utf8");
120
+ if (!current.includes(hook.marker)) {
121
+ problems.push(`A ${hook.name} hook already exists at ${file} and it isn't ChangeBook's — not overwriting it. Add this to it manually if you want both:\n\n` +
122
+ hook.script().split("\n").slice(4).join("\n"));
123
+ continue;
124
+ }
46
125
  }
126
+ fs.mkdirSync(path.dirname(file), { recursive: true });
127
+ fs.writeFileSync(file, hook.script(), { mode: 0o755 });
128
+ console.error(`✓ ${hook.name} hook installed (${file}).`);
129
+ console.error(hook.installedNote);
47
130
  }
48
- fs.mkdirSync(path.dirname(file), { recursive: true });
49
- fs.writeFileSync(file, hookScript(), { mode: 0o755 });
50
- console.error(`✓ post-commit hook installed (${file}).`);
51
- console.error("Every new commit is analyzed into your atlas in the background (1 credit each; re-runs of the same commit are free).");
131
+ if (problems.length > 0)
132
+ throw new Error(problems.join("\n\n"));
52
133
  }
53
134
  export async function uninstallHook(dir) {
54
- const file = await hookPath(dir);
55
- if (!fs.existsSync(file) || !fs.readFileSync(file, "utf8").includes(MARKER)) {
56
- console.error("No ChangeBook post-commit hook found.");
57
- return;
135
+ for (const hook of HOOKS) {
136
+ const file = await hookPath(dir, hook.name);
137
+ if (!fs.existsSync(file) ||
138
+ !fs.readFileSync(file, "utf8").includes(hook.marker)) {
139
+ console.error(`No ChangeBook ${hook.name} hook found.`);
140
+ continue;
141
+ }
142
+ fs.unlinkSync(file);
143
+ console.error(`✓ ${hook.name} hook removed.`);
58
144
  }
59
- fs.unlinkSync(file);
60
- console.error("✓ post-commit hook removed.");
61
145
  }
62
146
  export async function hookStatus(dir) {
63
- const file = await hookPath(dir);
64
- const installed = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(MARKER);
65
- console.error(installed
66
- ? `✓ ChangeBook post-commit hook installed (${file}).`
67
- : "No ChangeBook post-commit hook in this repository. Install it with: changebook hook install");
147
+ let missing = false;
148
+ for (const hook of HOOKS) {
149
+ const file = await hookPath(dir, hook.name);
150
+ const installed = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(hook.marker);
151
+ if (installed) {
152
+ console.error(`✓ ChangeBook ${hook.name} hook installed (${file}).`);
153
+ }
154
+ else {
155
+ missing = true;
156
+ console.error(`✗ No ChangeBook ${hook.name} hook in this repository.`);
157
+ }
158
+ }
159
+ if (missing) {
160
+ console.error("Install the missing hooks with: changebook hook install");
161
+ }
68
162
  }
69
163
  //# sourceMappingURL=hook.js.map
package/dist/import.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import * as path from "node:path";
12
12
  import { atlasWebUrl } from "./browser.js";
13
13
  import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
14
+ import { canonicalDiffHash } from "./canonical.js";
14
15
  import { optimizeTokensForAI } from "./optimize.js";
15
16
  // Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
16
17
  const CHUNK_SIZE = 20;
@@ -55,6 +56,7 @@ export async function importHistory(db, options = {}) {
55
56
  items.push({
56
57
  compressedDiff: compressed,
57
58
  rawDiffChars: diff.length,
59
+ rawContentHash: canonicalDiffHash(diff),
58
60
  commitHash: commit.hash,
59
61
  committedAt: commit.date,
60
62
  });
package/dist/index.js CHANGED
@@ -8,11 +8,15 @@
8
8
  * The CLI subcommands feed and connect that memory without the VS Code
9
9
  * extension: login, analyze, sync, init, open.
10
10
  */
11
+ import { readFileSync } from "node:fs";
12
+ import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
11
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
16
  import { analyze } from "./analyze.js";
14
17
  import { atlasWebUrl, openInBrowser } from "./browser.js";
15
18
  import { clearCredentials, credentialsPath } from "./credentials.js";
19
+ import { runGuard } from "./guard.js";
16
20
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
17
21
  import { importHistory } from "./import.js";
18
22
  import { registerAgents } from "./init.js";
@@ -20,6 +24,10 @@ import { login } from "./login.js";
20
24
  import { AUTH_HELP, Supabase } from "./supabase.js";
21
25
  import { syncContextFiles } from "./sync.js";
22
26
  import { registerTools } from "./tools.js";
27
+ // Single source of truth for the reported version: package.json (dist is one
28
+ // level below it). Avoids the hardcoded "0.2.0" drifting from the published
29
+ // version (audit M5). test/mcpVersion.test.ts pins package.json ↔ server.json.
30
+ const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
23
31
  const HELP = `changebook — the ChangeBook product memory, from any terminal
24
32
 
25
33
  Usage:
@@ -31,14 +39,19 @@ Usage:
31
39
  Backfill the last N commits (default 25) via the
32
40
  Anthropic Batch API — 50% cheaper, non-interactive
33
41
  changebook hook install|uninstall|status [dir]
34
- Git post-commit hook: analyze every new commit automatically
42
+ Git hooks: analyze every new commit (post-commit) and
43
+ warn before committing to a module with an open alert
44
+ (pre-commit signal guard)
45
+ changebook guard [dir] Check staged files against open atlas alerts
46
+ (what the pre-commit hook runs; exit 3 = block)
35
47
  changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
36
48
  changebook init [dir] login + register MCP in every agent found + hook + sync
37
49
  changebook open Open the web atlas in the browser
38
50
  changebook serve Run the MCP server on stdio (default with no arguments)
39
51
 
40
52
  Environment: CHANGEBOOK_REFRESH_TOKEN / CHANGEBOOK_ACCESS_TOKEN override the stored
41
- session (CI/headless); CHANGEBOOK_PROJECT scopes queries to one project.`;
53
+ session (CI/headless); CHANGEBOOK_PROJECT scopes queries to one project;
54
+ CHANGEBOOK_GUARD=off|block tunes the pre-commit signal guard (default: warn).`;
42
55
  function requireCredentials(db) {
43
56
  if (!db.hasCredentials()) {
44
57
  console.error(AUTH_HELP);
@@ -48,7 +61,7 @@ function requireCredentials(db) {
48
61
  async function serve(db) {
49
62
  const server = new McpServer({
50
63
  name: "changebook-mcp-server",
51
- version: "0.2.0",
64
+ version: VERSION,
52
65
  });
53
66
  registerTools(server, db);
54
67
  if (!db.hasCredentials()) {
@@ -104,11 +117,17 @@ async function main() {
104
117
  case "login":
105
118
  await login();
106
119
  return;
107
- case "logout":
120
+ case "logout": {
121
+ // Revoke server-side first (best-effort), then forget the local file —
122
+ // otherwise the refresh token stays alive on the server after "logout".
123
+ const db = new Supabase();
124
+ if (db.hasCredentials())
125
+ await db.signOut();
108
126
  console.error(clearCredentials()
109
127
  ? `✓ Session removed from ${credentialsPath()}`
110
128
  : "No stored session.");
111
129
  return;
130
+ }
112
131
  case "analyze": {
113
132
  const db = new Supabase();
114
133
  requireCredentials(db);
@@ -121,6 +140,13 @@ async function main() {
121
140
  await importHistory(db, parseImportArgs(process.argv.slice(3)));
122
141
  return;
123
142
  }
143
+ case "guard": {
144
+ // No requireCredentials: a logged-out (or offline, or never-imported)
145
+ // repo must commit exactly as before — runGuard resolves every failure
146
+ // to exit 0 itself. The explicit exit also drops any fetch still racing
147
+ // the timeout, so the commit never waits on a dangling socket.
148
+ return process.exit(await runGuard(new Supabase(), arg ?? process.cwd()));
149
+ }
124
150
  case "hook": {
125
151
  const dir = process.argv[4] ?? process.cwd();
126
152
  if (arg === "install")
package/dist/supabase.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * refresh token when needed (refresh does not require a captcha).
7
7
  */
8
8
  import { loadCredentials, saveCredentials } from "./credentials.js";
9
+ import { slugifyProject } from "./guard.js";
9
10
  // Public defaults — the anon key is the same public key the web app ships.
10
11
  const DEFAULT_URL = "https://oyosihxkecspjkiligga.supabase.co";
11
12
  const DEFAULT_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
@@ -70,6 +71,14 @@ export class Supabase {
70
71
  if (this.projectFilterCache !== undefined)
71
72
  return this.projectFilterCache;
72
73
  if (!this.projectEnv) {
74
+ // Sin proyecto explícito: si la cuenta tiene VARIOS proyectos, negar la
75
+ // mezcla — la frontera por proyecto que el MCP hospedado ya aplica
76
+ // (auditoría M3: el stdio devolvía "" = todos los proyectos y mezclaba
77
+ // historia/módulos/contexto). Con 0 o 1 proyecto no hay ambigüedad.
78
+ const some = await this.rest("projects?select=id&limit=2");
79
+ if (some.length > 1) {
80
+ throw new SupabaseError("Esta cuenta tiene varios proyectos. Define CHANGEBOOK_PROJECT (nombre o slug del repo) o pasa `project` en la tool para no mezclar sus datos.", 400);
81
+ }
73
82
  this.projectFilterCache = "";
74
83
  return this.projectFilterCache;
75
84
  }
@@ -84,6 +93,111 @@ export class Supabase {
84
93
  this.projectFilterCache = `&project_id=eq.${rows[0].id}`;
85
94
  return this.projectFilterCache;
86
95
  }
96
+ /**
97
+ * Per-call variant of projectFilter(): resolves an explicit project (tool
98
+ * argument — the repo the agent is working in) by server-side slug first,
99
+ * then exact name; falls back to the env-based filter when absent. The
100
+ * atlas is per-project, so tools pass what the agent gave them here.
101
+ */
102
+ async projectFilterFor(project) {
103
+ const wanted = project?.trim();
104
+ if (!wanted)
105
+ return this.projectFilter();
106
+ const slug = slugifyProject(wanted);
107
+ let rows = slug
108
+ ? await this.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
109
+ : [];
110
+ if (rows.length === 0) {
111
+ rows = await this.rest(`projects?select=id&name=eq.${encodeURIComponent(wanted)}&limit=1`);
112
+ }
113
+ if (rows.length === 0) {
114
+ throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
115
+ }
116
+ return `&project_id=eq.${rows[0].id}`;
117
+ }
118
+ /**
119
+ * POST one row via PostgREST as the signed-in user (RLS applies). Used for
120
+ * best-effort metering inserts — callers typically fire-and-forget it.
121
+ */
122
+ async insertRow(table, row) {
123
+ if (!this.hasCredentials())
124
+ throw new SupabaseError(AUTH_HELP, 401);
125
+ if (!this.accessToken)
126
+ await this.refresh();
127
+ let res = await this.insertOnce(table, row);
128
+ if (res.status === 401 && this.refreshToken) {
129
+ await this.refresh();
130
+ res = await this.insertOnce(table, row);
131
+ }
132
+ if (!res.ok) {
133
+ throw new SupabaseError(`Insert into ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
134
+ }
135
+ }
136
+ /** POST /rest/v1/rpc/<fn> as the signed-in user (definer rules apply). */
137
+ async callRpc(fn, args) {
138
+ if (!this.hasCredentials())
139
+ throw new SupabaseError(AUTH_HELP, 401);
140
+ if (!this.accessToken)
141
+ await this.refresh();
142
+ let res = await this.rpcOnce(fn, args);
143
+ if (res.status === 401 && this.refreshToken) {
144
+ await this.refresh();
145
+ res = await this.rpcOnce(fn, args);
146
+ }
147
+ if (!res.ok) {
148
+ throw new SupabaseError(`RPC ${fn} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
149
+ }
150
+ return (await res.json());
151
+ }
152
+ rpcOnce(fn, args) {
153
+ return fetch(`${this.url}/rest/v1/rpc/${fn}`, {
154
+ method: "POST",
155
+ headers: {
156
+ apikey: this.anonKey,
157
+ Authorization: `Bearer ${this.accessToken}`,
158
+ "Content-Type": "application/json",
159
+ },
160
+ body: JSON.stringify(args),
161
+ signal: AbortSignal.timeout(15_000),
162
+ });
163
+ }
164
+ insertOnce(table, row) {
165
+ return fetch(`${this.url}/rest/v1/${table}`, {
166
+ method: "POST",
167
+ headers: {
168
+ apikey: this.anonKey,
169
+ Authorization: `Bearer ${this.accessToken}`,
170
+ "Content-Type": "application/json",
171
+ Prefer: "return=minimal",
172
+ },
173
+ body: JSON.stringify(row),
174
+ signal: AbortSignal.timeout(10_000),
175
+ });
176
+ }
177
+ /**
178
+ * Revoke the session server-side (GoTrue /logout). Best-effort: the caller
179
+ * still clears the local file afterwards. The extension already does this;
180
+ * without it a `changebook logout` left the refresh token alive on the
181
+ * server (audit B4).
182
+ */
183
+ async signOut() {
184
+ if (!this.accessToken) {
185
+ try {
186
+ await this.refresh();
187
+ }
188
+ catch {
189
+ return; // no valid token to revoke
190
+ }
191
+ }
192
+ await fetch(`${this.url}/auth/v1/logout?scope=global`, {
193
+ method: "POST",
194
+ headers: {
195
+ apikey: this.anonKey,
196
+ Authorization: `Bearer ${this.accessToken}`,
197
+ },
198
+ signal: AbortSignal.timeout(10_000),
199
+ }).catch(() => { });
200
+ }
87
201
  /** GET a PostgREST path (e.g. "changelog?select=...") as the signed-in user. */
88
202
  async rest(pathWithQuery) {
89
203
  if (!this.hasCredentials())
package/dist/sync.js CHANGED
@@ -12,6 +12,11 @@
12
12
  */
13
13
  import { readFile, writeFile } from "node:fs/promises";
14
14
  import path from "node:path";
15
+ /** The project identity of the synced repo (same rule as analyze/guard). */
16
+ function projectNameFor(targetDir) {
17
+ return (process.env.CHANGEBOOK_PROJECT?.trim() ||
18
+ path.basename(path.resolve(targetDir)));
19
+ }
15
20
  const START = "<!-- changebook:start -->";
16
21
  const END = "<!-- changebook:end -->";
17
22
  // DB text (notes, alert bodies, business impact) is AI-written and gets embedded
@@ -26,10 +31,12 @@ const MAX_MODULES = 15;
26
31
  const MAX_CHANGES = 5;
27
32
  const MAX_COUPLINGS = 5;
28
33
  // El bloque entra en CADA sesión de agente del usuario, así que tiene un
29
- // presupuesto fijo (~500 tokens) y nunca crece sin control. Cuando no cabe
30
- // todo, se recorta por prioridad: regresiones > acoplamientos > hotspots >
31
- // módulos > últimos cambios.
32
- const SYNC_BUDGET_CHARS = 2_000;
34
+ // presupuesto fijo (~750 tokens) y nunca crece sin control. Cuando no cabe
35
+ // todo, se recorta por prioridad: regresiones > encargos > acoplamientos >
36
+ // hotspots > módulos > últimos cambios. Subido de 2.000 a 3.000 el
37
+ // 2026-07-18: la cabecera ganó las reglas de frontera/aviso/file-context y
38
+ // con 2.000 expulsaba Módulos y Encargos enteros del mapa.
39
+ const SYNC_BUDGET_CHARS = 3_000;
33
40
  // Co-change pair thresholds — same spirit as the web's signals: at least 3
34
41
  // shared analyses and a ≥60% rate before we call it a dependency.
35
42
  const MIN_PAIR_COUNT = 3;
@@ -38,9 +45,23 @@ const MIN_PAIR_RATE = 0.6;
38
45
  const ALERT_WINDOW_DAYS = 14;
39
46
  const MAX_ALERTS = 3;
40
47
  export async function syncContextFiles(db, targetDir) {
41
- const projectFilter = await db.projectFilter();
48
+ const projectName = projectNameFor(targetDir);
49
+ // Frontera por proyecto también aquí (QA 2026-07-18): sin filtro, una
50
+ // cuenta con varios proyectos construiría el mapa de ESTE repo mezclando
51
+ // los datos de todos. Y si el proyecto aún no existe en el atlas, el mapa
52
+ // debe salir VACÍO — jamás el de otro proyecto.
53
+ let projectFilter;
54
+ let projectResolved = true;
55
+ try {
56
+ projectFilter = await db.projectFilterFor(projectName);
57
+ }
58
+ catch {
59
+ projectFilter = "&project_id=eq.00000000-0000-0000-0000-000000000000";
60
+ projectResolved = false;
61
+ }
62
+ const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
42
63
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
43
- const [moduleRows, changes, alerts] = await Promise.all([
64
+ const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
44
65
  db.rest("change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500" +
45
66
  projectFilter),
46
67
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -48,19 +69,47 @@ export async function syncContextFiles(db, targetDir) {
48
69
  // AI-detected regression warnings: best-effort — an error (older schema,
49
70
  // RLS hiccup) must not block the sync of the rest of the map.
50
71
  db
51
- .rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&order=created_at.desc&limit=${MAX_ALERTS}` +
72
+ .rest(
73
+ // resolved_at=is.null: a dismissed/auto-resolved alert inside the
74
+ // window must not resurface in every agent session as urgent.
75
+ `regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
52
76
  projectFilter)
53
77
  .catch(() => []),
78
+ // Auto-remediación fase 2: los encargos pendientes entran en el bloque
79
+ // para que CUALQUIER sesión arranque sabiéndolos y se ofrezca a atacarlos
80
+ // (proponer, no ejecutar — la aprobación sigue siendo del humano).
81
+ // Best-effort como las alertas.
82
+ projectResolved && projectId
83
+ ? db
84
+ .callRpc("list_agent_tasks", {
85
+ p_project_id: projectId,
86
+ })
87
+ .then((rows) => rows.filter((r) => r.status === "pending"))
88
+ .catch(() => [])
89
+ : Promise.resolve([]),
54
90
  ]);
55
- const section = buildSection(moduleRows, changes, alerts);
91
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
56
92
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
57
93
  const file = path.join(targetDir, name);
58
94
  const updated = await upsertSection(file, section);
59
95
  console.error(`${updated} ${name}`);
60
96
  }
97
+ // El sync ES una consulta del atlas — la más apalancada: el mapa que
98
+ // destila entra en CADA sesión de agente vía CLAUDE.md/AGENTS.md sin
99
+ // pagar tool calls. Cuenta como lectura (best-effort).
100
+ if (projectResolved && projectId) {
101
+ await db
102
+ .insertRow("atlas_reads", {
103
+ project_id: projectId,
104
+ tool: "sync_context_files",
105
+ source: "sync",
106
+ chars_served: section.length,
107
+ })
108
+ .catch(() => { });
109
+ }
61
110
  }
62
111
  /** Exported for tests. */
63
- export function buildSection(rows, changes, alerts = []) {
112
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
64
113
  // Newest-first rows: the first occurrence of a module is its latest state.
65
114
  const seen = new Map();
66
115
  for (const row of rows) {
@@ -79,7 +128,25 @@ export function buildSection(rows, changes, alerts = []) {
79
128
  // útiles ya van por entrada (regresiones, últimos cambios).
80
129
  "## Mapa del producto (ChangeBook · auto-generado)",
81
130
  "",
82
- "Este proyecto tiene memoria en ChangeBook. Antes de explorar código a mano, orienta con las tools MCP: `atlas_modules` (mapa), `atlas_recent_changes` (historia), `atlas_module_detail` (detalle + diffs).",
131
+ "Este proyecto tiene memoria en ChangeBook. Al EMPEZAR la sesión, oriéntate con UNA sola llamada a la tool MCP `atlas_project_brief` (mapa + alertas + encargos pendientes + últimos cambios); para profundizar: `atlas_module_detail` (detalle + diffs).",
132
+ "",
133
+ "Si vas a trabajar un encargo pendiente del atlas, anuncia primero al usuario en 2-3 líneas qué vas a hacer y por qué, antes de tocar código.",
134
+ "",
135
+ "Antes de tocar un archivo concreto, consulta `atlas_file_context` con sus rutas: te da su módulo, riesgo, alertas abiertas e historia por una llamada — más barato que releer el código y su git log.",
136
+ "",
137
+ // Frontera por proyecto (QA 2026-07-18): el atlas y la cola de encargos
138
+ // son POR PROYECTO; sin este dato el agente recibiría los de otros.
139
+ ...(projectName
140
+ ? [
141
+ `El proyecto de ESTE repo es \`${sanitizeCell(projectName)}\`. El atlas es por proyecto: pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\` en las tools del atlas (mapa, historia, detalle, encargos y registro). Los encargos o datos de otros proyectos no pertenecen a esta sesión.`,
142
+ "",
143
+ ]
144
+ : []),
145
+ // La instrucción de escritura vive AQUÍ y no en la doc de la tool: los
146
+ // agentes obedecen lo que el workspace les dice, no lo que una tool
147
+ // disponible insinúa (lección 2026-07-17: un agente con la tool conectada
148
+ // hizo 3 PRs y no registró ninguno hasta que se lo pidieron).
149
+ "Tras cada commit, registra el cambio con la tool MCP `atlas_record_change`: pásale el diff (`git show HEAD`), `commit_hash`, `committed_at` y SIEMPRE `summary` (2-5 frases tuyas sobre qué cambió y por qué — abarata mucho el análisis). Reenviar un commit ya registrado es un no-op gratuito.",
83
150
  "",
84
151
  ];
85
152
  if (modules.length === 0) {
@@ -118,14 +185,33 @@ export function buildSection(rows, changes, alerts = []) {
118
185
  return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ""}`;
119
186
  });
120
187
  const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? "").slice(0, 140))}`);
188
+ // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
189
+ // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
190
+ // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
191
+ // llevar la misma señal encolada varias veces).
192
+ const taskTitles = [
193
+ ...new Set(pendingTasks.map((t) => (t.title ?? "").trim()).filter(Boolean)),
194
+ ].slice(0, 3);
195
+ const taskLines = taskTitles.length > 0
196
+ ? [
197
+ `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Al empezar, propón al usuario cuál atacarías y por qué, y espera su OK antes de tocar código. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
198
+ ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
199
+ ]
200
+ : [];
121
201
  // El orden de renderizado (legibilidad) y la prioridad de presupuesto
122
202
  // (utilidad para el agente) son independientes: si no cabe todo, caen
123
203
  // primero los últimos cambios y los módulos, nunca las regresiones.
124
204
  const sections = [
125
- { key: "modules", priority: 3, title: "### Módulos", lines: moduleLines },
205
+ { key: "modules", priority: 4, title: "### Módulos", lines: moduleLines },
126
206
  {
127
- key: "couplings",
207
+ key: "tasks",
128
208
  priority: 1,
209
+ title: "### Encargos pendientes del dueño (proponte atacarlos)",
210
+ lines: taskLines,
211
+ },
212
+ {
213
+ key: "couplings",
214
+ priority: 2,
129
215
  title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
130
216
  lines: couplingLines,
131
217
  },
@@ -137,13 +223,13 @@ export function buildSection(rows, changes, alerts = []) {
137
223
  },
138
224
  {
139
225
  key: "hotspots",
140
- priority: 2,
226
+ priority: 3,
141
227
  title: "### Avisos abiertos (revisar antes de modificar)",
142
228
  lines: hotspotLines,
143
229
  },
144
230
  {
145
231
  key: "changes",
146
- priority: 4,
232
+ priority: 5,
147
233
  title: "### Últimos cambios",
148
234
  lines: changeLines,
149
235
  },
package/dist/tools.js CHANGED
@@ -55,6 +55,36 @@ function previewExcerpt(excerpt) {
55
55
  function day(iso) {
56
56
  return iso.slice(0, 10);
57
57
  }
58
+ // Pre-edit lookup helpers (mirror of the hosted scope.ts — the npm package
59
+ // must stay self-contained, so these three stay tiny and duplicated).
60
+ // Exported so test/mcpParity.test.ts can pin them equal to the hosted copies:
61
+ // drift would break atlas_file_context on stdio silently (audit M7).
62
+ export function normalizeRepoPath(path) {
63
+ return path.trim().replace(/^\.\//, "").replace(/^\/+/, "");
64
+ }
65
+ export function fileContainsFilter(path) {
66
+ return `files=cs.${encodeURIComponent(JSON.stringify([path]))}`;
67
+ }
68
+ export function quotedInList(values) {
69
+ return values
70
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
71
+ .join(",");
72
+ }
73
+ // Consultation metering (never billing): each successful read leaves a row in
74
+ // atlas_reads so the web can show "your agent consulted the atlas N times".
75
+ // Best-effort and non-blocking — metering must never break or slow a read.
76
+ // user_id is filled server-side (column default auth.uid()).
77
+ function recordRead(db, tool, projectFilter, charsServed) {
78
+ const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
79
+ void db
80
+ .insertRow("atlas_reads", {
81
+ project_id: projectId,
82
+ tool,
83
+ source: "stdio",
84
+ chars_served: charsServed,
85
+ })
86
+ .catch(() => { });
87
+ }
58
88
  /** PostgREST `or=(...ilike...)` needs the pattern URL-encoded once. */
59
89
  function ilikePattern(search) {
60
90
  return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
@@ -71,6 +101,7 @@ Args:
71
101
  - limit (1-50, default 10): entries to return.
72
102
  - offset (default 0): pagination offset.
73
103
  - search (optional): case-insensitive text filter over the business and technical summaries.
104
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
74
105
 
75
106
  Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, summary_tech, diff_chars, modules: [{ module, risk }] }] }
76
107
 
@@ -82,6 +113,8 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
82
113
  .describe("Pagination offset"),
83
114
  search: z.string().min(2).max(120).optional()
84
115
  .describe("Case-insensitive filter over summaries"),
116
+ project: z.string().min(1).max(120).optional()
117
+ .describe("Project to scope to (repo folder name or slug)"),
85
118
  },
86
119
  annotations: {
87
120
  readOnlyHint: true,
@@ -89,11 +122,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
89
122
  idempotentHint: true,
90
123
  openWorldHint: true,
91
124
  },
92
- }, async ({ limit, offset, search }) => {
125
+ }, async ({ limit, offset, search, project }) => {
93
126
  try {
127
+ const pf = await db.projectFilterFor(project);
94
128
  let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
95
129
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
96
- (await db.projectFilter());
130
+ pf;
97
131
  if (search) {
98
132
  const p = ilikePattern(search);
99
133
  query += `&or=(business_impact.ilike.${p},summary_tech.ilike.${p})`;
@@ -148,7 +182,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
148
182
  ? `No changes match "${search}". Try a broader search or omit it.`
149
183
  : "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
150
184
  }
151
- return toolResult(lines.join("\n"), output);
185
+ const changesText = lines.join("\n");
186
+ recordRead(db, "atlas_recent_changes", pf, changesText.length);
187
+ return toolResult(changesText, output);
152
188
  }
153
189
  catch (error) {
154
190
  return errorResult(error);
@@ -162,11 +198,14 @@ For each module: domain, category, latest risk level, number of analyzed changes
162
198
 
163
199
  Args:
164
200
  - domain (optional): filter by domain (e.g. "billing").
201
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
165
202
 
166
203
  Returns (structured): { count, modules: [{ module, domain, category, risk, changes, last_changed, files }] }`,
167
204
  inputSchema: {
168
205
  domain: z.string().min(1).max(80).optional()
169
206
  .describe("Only modules in this domain"),
207
+ project: z.string().min(1).max(120).optional()
208
+ .describe("Project to scope to (repo folder name or slug)"),
170
209
  },
171
210
  annotations: {
172
211
  readOnlyHint: true,
@@ -174,14 +213,15 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
174
213
  idempotentHint: true,
175
214
  openWorldHint: true,
176
215
  },
177
- }, async ({ domain }) => {
216
+ }, async ({ domain, project }) => {
178
217
  try {
218
+ const pf = await db.projectFilterFor(project);
179
219
  let query =
180
220
  // The aggregation below uses only these columns; note/tech/excerpt
181
221
  // (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
182
222
  `change_module?select=module,domain,category,risk,files,created_at` +
183
223
  `&order=created_at.desc&limit=1000` +
184
- (await db.projectFilter());
224
+ pf;
185
225
  if (domain)
186
226
  query += `&domain=eq.${encodeURIComponent(domain)}`;
187
227
  const rows = await db.rest(query);
@@ -219,7 +259,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
219
259
  ? `No modules in domain "${domain}". Call atlas_modules without a domain to see all.`
220
260
  : "No modules yet. Analyze a diff from the ChangeBook extension first.");
221
261
  }
222
- return toolResult(lines.join("\n"), output);
262
+ const modulesText = lines.join("\n");
263
+ recordRead(db, "atlas_modules", pf, modulesText.length);
264
+ return toolResult(modulesText, output);
223
265
  }
224
266
  catch (error) {
225
267
  return errorResult(error);
@@ -236,6 +278,7 @@ Args:
236
278
  - limit (1-20, default 5): number of recent changes to include.
237
279
  - include_excerpts (default true): include diff excerpts (short previews unless full is set).
238
280
  - full (default false): return the diff excerpts verbatim instead of the token-saving previews. Only pass it when you actually need the code lines.
281
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
239
282
 
240
283
  Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }`,
241
284
  inputSchema: {
@@ -247,6 +290,8 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
247
290
  .describe("Include diff excerpts (previews unless full)"),
248
291
  full: z.boolean().default(false)
249
292
  .describe("Verbatim excerpts instead of short previews"),
293
+ project: z.string().min(1).max(120).optional()
294
+ .describe("Project to scope to (repo folder name or slug)"),
250
295
  },
251
296
  annotations: {
252
297
  readOnlyHint: true,
@@ -254,11 +299,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
254
299
  idempotentHint: true,
255
300
  openWorldHint: true,
256
301
  },
257
- }, async ({ module, limit, include_excerpts, full }) => {
302
+ }, async ({ module, limit, include_excerpts, full, project }) => {
258
303
  try {
304
+ const pf = await db.projectFilterFor(project);
259
305
  const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
260
306
  `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
261
- (await db.projectFilter()));
307
+ pf);
262
308
  if (rows.length === 0) {
263
309
  return {
264
310
  content: [
@@ -328,7 +374,98 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
328
374
  }
329
375
  lines.push("");
330
376
  }
331
- return toolResult(lines.join("\n"), output);
377
+ const detailText = lines.join("\n");
378
+ recordRead(db, "atlas_module_detail", pf, detailText.length);
379
+ return toolResult(detailText, output);
380
+ }
381
+ catch (error) {
382
+ return errorResult(error);
383
+ }
384
+ });
385
+ server.registerTool("atlas_file_context", {
386
+ title: "Context of the files you are about to edit",
387
+ description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, and how often they changed recently.
388
+
389
+ Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
390
+
391
+ Args:
392
+ - files (required): 1-8 repo-relative paths.
393
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
394
+
395
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts }] }`,
396
+ inputSchema: {
397
+ files: z.array(z.string().min(1).max(300)).min(1).max(8)
398
+ .describe("Repo-relative paths you are about to edit"),
399
+ project: z.string().min(1).max(120).optional()
400
+ .describe("Project to scope to (repo folder name or slug)"),
401
+ },
402
+ annotations: {
403
+ readOnlyHint: true,
404
+ destructiveHint: false,
405
+ idempotentHint: true,
406
+ openWorldHint: true,
407
+ },
408
+ }, async ({ files, project }) => {
409
+ try {
410
+ const pf = await db.projectFilterFor(project);
411
+ const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
412
+ const perFile = await Promise.all(paths.map(async (file) => {
413
+ const rows = await db.rest(`change_module?select=module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
414
+ pf);
415
+ const byModule = new Map();
416
+ for (const row of rows) {
417
+ const name = (row.module ?? "").trim();
418
+ if (!name)
419
+ continue;
420
+ const existing = byModule.get(name);
421
+ if (existing)
422
+ existing.changes += 1;
423
+ else {
424
+ byModule.set(name, {
425
+ module: name,
426
+ risk: row.risk,
427
+ changes: 1,
428
+ last_changed: day(row.created_at),
429
+ last_note: row.note,
430
+ });
431
+ }
432
+ }
433
+ return { file, modules: [...byModule.values()] };
434
+ }));
435
+ const moduleNames = [
436
+ ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
437
+ ];
438
+ const alerts = moduleNames.length
439
+ ? await db.rest(`regression_alerts?select=module,plain&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
440
+ pf)
441
+ : [];
442
+ const alertsByModule = new Map();
443
+ for (const a of alerts) {
444
+ const m = (a.module ?? "").trim();
445
+ if (!m || !a.plain)
446
+ continue;
447
+ alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
448
+ }
449
+ const lines = [`# File context (${paths.length} file(s))`, ""];
450
+ for (const f of perFile) {
451
+ lines.push(`## ${f.file}`);
452
+ if (f.modules.length === 0) {
453
+ lines.push("No atlas history for this file yet (new or never analyzed).");
454
+ }
455
+ for (const m of f.modules) {
456
+ lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
457
+ (m.risk ? `, risk: ${m.risk}` : ""));
458
+ if (m.last_note)
459
+ lines.push(` - Latest note: ${m.last_note}`);
460
+ for (const plain of alertsByModule.get(m.module) ?? []) {
461
+ lines.push(` - ⚠ OPEN ALERT: ${plain}`);
462
+ }
463
+ }
464
+ lines.push("");
465
+ }
466
+ const contextText = lines.join("\n");
467
+ recordRead(db, "atlas_file_context", pf, contextText.length);
468
+ return toolResult(contextText, { files: perFile });
332
469
  }
333
470
  catch (error) {
334
471
  return errorResult(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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,14 +2,20 @@
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.3.1",
5
+ "version": "0.4.0",
6
6
  "websiteUrl": "https://changebook.dev",
7
+ "remotes": [
8
+ {
9
+ "type": "streamable-http",
10
+ "url": "https://mcp.changebook.app"
11
+ }
12
+ ],
7
13
  "packages": [
8
14
  {
9
15
  "registryType": "npm",
10
16
  "registryBaseUrl": "https://registry.npmjs.org",
11
17
  "identifier": "changebook",
12
- "version": "0.3.1",
18
+ "version": "0.4.0",
13
19
  "transport": {
14
20
  "type": "stdio"
15
21
  }