changebook 0.3.2 → 0.4.1
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 +5 -2
- package/dist/analyze.js +26 -4
- package/dist/canonical.js +44 -0
- package/dist/git.js +34 -0
- package/dist/guard.js +276 -0
- package/dist/hook.js +128 -34
- package/dist/import.js +32 -7
- 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 +2 -2
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.1",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.1",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.4.1",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|