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 +23 -3
- package/dist/analyze.js +4 -0
- package/dist/canonical.js +44 -0
- package/dist/guard.js +254 -0
- package/dist/hook.js +128 -34
- package/dist/import.js +2 -0
- package/dist/index.js +30 -4
- package/dist/supabase.js +114 -0
- package/dist/sync.js +100 -14
- package/dist/tools.js +146 -9
- package/package.json +1 -1
- package/server.json +8 -2
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
|
-
##
|
|
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
|
|
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
|
|
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]` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
${
|
|
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(
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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:
|
|
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 (~
|
|
30
|
-
// todo, se recorta por prioridad: regresiones >
|
|
31
|
-
// módulos > últimos cambios.
|
|
32
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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:
|
|
205
|
+
{ key: "modules", priority: 4, title: "### Módulos", lines: moduleLines },
|
|
126
206
|
{
|
|
127
|
-
key: "
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
18
|
+
"version": "0.4.0",
|
|
13
19
|
"transport": {
|
|
14
20
|
"type": "stdio"
|
|
15
21
|
}
|