changebook 0.4.5 → 0.4.7
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/dist/context.js +162 -0
- package/dist/credentials.js +71 -0
- package/dist/index.js +42 -0
- package/dist/supabase.js +50 -22
- package/dist/sync.js +16 -18
- package/dist/tools.js +106 -7
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/context.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook context [dir]` — prints the FRESH atlas brief to stdout as a
|
|
3
|
+
* Claude Code SessionStart hook payload
|
|
4
|
+
* (`hookSpecificOutput.additionalContext`). Wired by `changebook init` into
|
|
5
|
+
* `.claude/settings.json` (with the user's consent), it pushes the map into
|
|
6
|
+
* the agent's context at turn 0 of EVERY session — headless included — so the
|
|
7
|
+
* brief stops being a tool the agent must remember to call and stops being a
|
|
8
|
+
* file that can go stale between commits: it is generated on the spot, per
|
|
9
|
+
* session.
|
|
10
|
+
*
|
|
11
|
+
* FAIL-OPEN, ABSOLUTE. This runs on the critical path of opening a session.
|
|
12
|
+
* Every failure mode — logged out, offline, no project, or merely slow — must
|
|
13
|
+
* print NOTHING and exit 0. A broken or slow atlas can never block or delay a
|
|
14
|
+
* user's session start. The whole body races a short timeout; on timeout or
|
|
15
|
+
* any throw, we emit nothing.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fetchBriefSection } from "./sync.js";
|
|
20
|
+
import { commitAliasesShort, derivaContraHead } from "./tools.js";
|
|
21
|
+
/** Hard ceiling on the critical path: past this, emit nothing and move on. */
|
|
22
|
+
const CONTEXT_TIMEOUT_MS = 2_000;
|
|
23
|
+
async function buildPayload(db, dir) {
|
|
24
|
+
// Logged out → silent no-op. A fresh clone with the hook installed but no
|
|
25
|
+
// session must open exactly as fast as before.
|
|
26
|
+
if (!db.hasCredentials())
|
|
27
|
+
return null;
|
|
28
|
+
const targetDir = path.resolve(dir);
|
|
29
|
+
const { section, projectId } = await fetchBriefSection(db, targetDir);
|
|
30
|
+
if (!section)
|
|
31
|
+
return null;
|
|
32
|
+
// Drift line vs local HEAD: the anti-staleness signal. The brief itself is
|
|
33
|
+
// fresh (built now), but this tells the agent how far the working tree has
|
|
34
|
+
// moved past the last analyzed commit. Best-effort — never blocks the brief.
|
|
35
|
+
let drift = null;
|
|
36
|
+
if (projectId) {
|
|
37
|
+
try {
|
|
38
|
+
const latest = await db.rest(`changelog?select=commit_hash,hash_aliases&commit_hash=not.is.null&order=created_at.desc&limit=1&project_id=eq.${projectId}`);
|
|
39
|
+
const candidatos = [
|
|
40
|
+
latest[0]?.commit_hash ?? null,
|
|
41
|
+
...commitAliasesShort(latest[0]?.hash_aliases),
|
|
42
|
+
];
|
|
43
|
+
for (const c of candidatos) {
|
|
44
|
+
drift = await derivaContraHead(targetDir, c);
|
|
45
|
+
if (drift)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
drift = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return drift ? `${section}\n\n${drift}` : section;
|
|
54
|
+
}
|
|
55
|
+
// ── SessionStart hook install (opt-in, per repo) ─────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// The push channel lives in `.claude/settings.json` under hooks.SessionStart.
|
|
58
|
+
// Consent is the act of running `changebook hook-context install` (or saying
|
|
59
|
+
// yes to init's offer) — this file is often committed and shared, so we NEVER
|
|
60
|
+
// write it silently and NEVER clobber a config we can't parse.
|
|
61
|
+
// `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
|
|
62
|
+
// shell error is swallowed and the hook exits 0 — a missing binary must not
|
|
63
|
+
// make a session-start hook look failed. printContext already emits only the
|
|
64
|
+
// JSON payload (or nothing) on stdout.
|
|
65
|
+
const HOOK_COMMAND = "changebook context 2>/dev/null || true";
|
|
66
|
+
// Recognisable substring to find/remove our own entry without touching others.
|
|
67
|
+
const HOOK_MARKER = "changebook context";
|
|
68
|
+
function settingsPath(dir) {
|
|
69
|
+
return path.join(path.resolve(dir), ".claude", "settings.json");
|
|
70
|
+
}
|
|
71
|
+
function groupHasMarker(g) {
|
|
72
|
+
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(HOOK_MARKER));
|
|
73
|
+
}
|
|
74
|
+
function readSettings(file) {
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = fs.readFileSync(file, "utf8");
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return {}; // missing file → empty settings
|
|
81
|
+
}
|
|
82
|
+
if (!raw.trim())
|
|
83
|
+
return {};
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null; // unparseable → caller must refuse, never clobber
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function contextHookInstalled(dir) {
|
|
93
|
+
const s = readSettings(settingsPath(dir));
|
|
94
|
+
return Boolean(s?.hooks?.SessionStart?.some(groupHasMarker));
|
|
95
|
+
}
|
|
96
|
+
export function installContextHook(dir) {
|
|
97
|
+
const file = settingsPath(dir);
|
|
98
|
+
const settings = readSettings(file);
|
|
99
|
+
if (settings === null) {
|
|
100
|
+
throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
|
|
101
|
+
}
|
|
102
|
+
const hooks = (settings.hooks ??= {});
|
|
103
|
+
const sessionStart = (hooks.SessionStart ??= []);
|
|
104
|
+
if (sessionStart.some(groupHasMarker))
|
|
105
|
+
return "already";
|
|
106
|
+
sessionStart.push({
|
|
107
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }],
|
|
108
|
+
});
|
|
109
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
111
|
+
return "installed";
|
|
112
|
+
}
|
|
113
|
+
export function uninstallContextHook(dir) {
|
|
114
|
+
const file = settingsPath(dir);
|
|
115
|
+
const settings = readSettings(file);
|
|
116
|
+
if (!settings || !settings.hooks?.SessionStart)
|
|
117
|
+
return "absent";
|
|
118
|
+
const before = settings.hooks.SessionStart;
|
|
119
|
+
// Drop our command from every group, then drop groups left empty. Foreign
|
|
120
|
+
// hooks in the same group (unusual, but possible) are preserved.
|
|
121
|
+
const after = before
|
|
122
|
+
.map((g) => ({
|
|
123
|
+
...g,
|
|
124
|
+
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(HOOK_MARKER)),
|
|
125
|
+
}))
|
|
126
|
+
.filter((g) => (g.hooks ?? []).length > 0);
|
|
127
|
+
if (after.length === before.length && before.every((g) => !groupHasMarker(g))) {
|
|
128
|
+
return "absent";
|
|
129
|
+
}
|
|
130
|
+
if (after.length > 0)
|
|
131
|
+
settings.hooks.SessionStart = after;
|
|
132
|
+
else
|
|
133
|
+
delete settings.hooks.SessionStart;
|
|
134
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
135
|
+
delete settings.hooks;
|
|
136
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
137
|
+
return "removed";
|
|
138
|
+
}
|
|
139
|
+
export async function printContext(db, dir) {
|
|
140
|
+
try {
|
|
141
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CONTEXT_TIMEOUT_MS));
|
|
142
|
+
const additionalContext = await Promise.race([
|
|
143
|
+
buildPayload(db, dir),
|
|
144
|
+
timeout,
|
|
145
|
+
]);
|
|
146
|
+
if (!additionalContext)
|
|
147
|
+
return;
|
|
148
|
+
// The SessionStart contract: stdout JSON whose additionalContext is
|
|
149
|
+
// injected into the agent's context. Anything else on stdout would be
|
|
150
|
+
// treated as context too, so we emit ONLY this object.
|
|
151
|
+
process.stdout.write(JSON.stringify({
|
|
152
|
+
hookSpecificOutput: {
|
|
153
|
+
hookEventName: "SessionStart",
|
|
154
|
+
additionalContext,
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Swallow everything: opening a session must never fail because of us.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=context.js.map
|
package/dist/credentials.js
CHANGED
|
@@ -61,4 +61,75 @@ export function clearCredentials() {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
const LOCK_FILE = path.join(DIR, "refresh.lock");
|
|
65
|
+
function sleep(ms) {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run `fn` while holding an exclusive, cross-process lock.
|
|
70
|
+
*
|
|
71
|
+
* Supabase rotates the refresh token on every use, and its reuse-detection
|
|
72
|
+
* revokes the WHOLE token family if a rotated token is ever replayed. The
|
|
73
|
+
* post-commit `analyze` runs detached in the background, so it can still be
|
|
74
|
+
* refreshing when the next commit's pre-commit `guard` (or another analyze)
|
|
75
|
+
* starts — two processes then spend the same stored refresh token and the
|
|
76
|
+
* account is logged out silently. A lockfile serializes the refresh across
|
|
77
|
+
* every process that shares the credentials file, so only one spends the token.
|
|
78
|
+
*
|
|
79
|
+
* Falls through and runs `fn` unlocked if the lock can't be taken within
|
|
80
|
+
* `timeoutMs` — a hung holder must never block a commit forever; that only
|
|
81
|
+
* degrades to the previous lock-less behavior. A stale lock left by a crashed
|
|
82
|
+
* holder (older than `staleMs`) is stolen.
|
|
83
|
+
*/
|
|
84
|
+
export async function withCredentialsLock(fn, opts = {}) {
|
|
85
|
+
const lockPath = opts.lockPath ?? LOCK_FILE;
|
|
86
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
87
|
+
const staleMs = opts.staleMs ?? 60_000;
|
|
88
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
let held = false;
|
|
91
|
+
for (;;) {
|
|
92
|
+
try {
|
|
93
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
94
|
+
fs.writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
|
|
95
|
+
fs.closeSync(fd);
|
|
96
|
+
held = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err.code !== "EEXIST")
|
|
101
|
+
throw err;
|
|
102
|
+
// Held by another process. Steal it only if it is stale (crashed holder).
|
|
103
|
+
let stolen = false;
|
|
104
|
+
try {
|
|
105
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
|
|
106
|
+
fs.rmSync(lockPath, { force: true });
|
|
107
|
+
stolen = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// The lock vanished between open and stat: retry immediately.
|
|
112
|
+
stolen = true;
|
|
113
|
+
}
|
|
114
|
+
if (stolen)
|
|
115
|
+
continue;
|
|
116
|
+
if (Date.now() >= deadline)
|
|
117
|
+
break; // give up waiting; proceed unlocked
|
|
118
|
+
await sleep(50);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (held) {
|
|
126
|
+
try {
|
|
127
|
+
fs.rmSync(lockPath, { force: true });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Best-effort release; a leftover lock is stolen once it goes stale.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
64
135
|
//# sourceMappingURL=credentials.js.map
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { runGuard } from "./guard.js";
|
|
|
20
20
|
import { hookStatus, installHook, uninstallHook } from "./hook.js";
|
|
21
21
|
import { importHistory } from "./import.js";
|
|
22
22
|
import { registerAgents } from "./init.js";
|
|
23
|
+
import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
|
|
23
24
|
import { login } from "./login.js";
|
|
24
25
|
import { AUTH_HELP, Supabase } from "./supabase.js";
|
|
25
26
|
import { syncContextFiles } from "./sync.js";
|
|
@@ -45,6 +46,11 @@ Usage:
|
|
|
45
46
|
changebook guard [dir] Check staged files against open atlas alerts
|
|
46
47
|
(what the pre-commit hook runs; exit 3 = block)
|
|
47
48
|
changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
|
|
49
|
+
changebook context [dir] Print the fresh atlas brief as a Claude Code
|
|
50
|
+
SessionStart hook payload (used by the push hook)
|
|
51
|
+
changebook hook-context install|uninstall|status [dir]
|
|
52
|
+
Push the atlas into EVERY Claude Code session at turn 0
|
|
53
|
+
via a SessionStart hook in .claude/settings.json
|
|
48
54
|
changebook init [dir] login + register MCP in every agent found + hook + sync
|
|
49
55
|
changebook open Open the web atlas in the browser
|
|
50
56
|
changebook serve Run the MCP server on stdio (default with no arguments)
|
|
@@ -179,6 +185,34 @@ async function main() {
|
|
|
179
185
|
await syncContextFiles(db, arg ?? process.cwd());
|
|
180
186
|
return;
|
|
181
187
|
}
|
|
188
|
+
case "context": {
|
|
189
|
+
// Runs on the SessionStart critical path: NEVER requireCredentials (it
|
|
190
|
+
// would exit 1), NEVER print help. printContext fails open — logged out,
|
|
191
|
+
// offline or slow all resolve to zero output and exit 0.
|
|
192
|
+
await printContext(new Supabase(), arg ?? process.cwd());
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
case "hook-context": {
|
|
196
|
+
const dir = process.argv[4] ?? process.cwd();
|
|
197
|
+
if (arg === "install") {
|
|
198
|
+
const r = installContextHook(dir);
|
|
199
|
+
console.error(r === "installed"
|
|
200
|
+
? `✓ SessionStart hook installed (${dir}/.claude/settings.json). Every Claude Code session now opens with the fresh atlas map.`
|
|
201
|
+
: "SessionStart hook already installed.");
|
|
202
|
+
}
|
|
203
|
+
else if (arg === "uninstall") {
|
|
204
|
+
const r = uninstallContextHook(dir);
|
|
205
|
+
console.error(r === "removed"
|
|
206
|
+
? "✓ SessionStart hook removed."
|
|
207
|
+
: "No ChangeBook SessionStart hook found.");
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.error(contextHookInstalled(dir)
|
|
211
|
+
? `✓ ChangeBook SessionStart hook installed (${dir}/.claude/settings.json).`
|
|
212
|
+
: "✗ No ChangeBook SessionStart hook. Install with: changebook hook-context install");
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
182
216
|
case "init": {
|
|
183
217
|
let db = new Supabase();
|
|
184
218
|
if (!db.hasCredentials()) {
|
|
@@ -197,6 +231,14 @@ async function main() {
|
|
|
197
231
|
console.error(error instanceof Error ? error.message : String(error));
|
|
198
232
|
}
|
|
199
233
|
await syncContextFiles(db, dir);
|
|
234
|
+
// Ofrecer el push, NUNCA instalarlo en silencio: .claude/settings.json se
|
|
235
|
+
// suele commitear y compartir, y meter un hook en el arranque de sesiones
|
|
236
|
+
// ajenas sin permiso explícito no se hace. Se ofrece el comando; correrlo
|
|
237
|
+
// ES el consentimiento.
|
|
238
|
+
if (!contextHookInstalled(dir)) {
|
|
239
|
+
console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
|
|
240
|
+
"(no tool call needed, never stale):\n changebook hook-context install");
|
|
241
|
+
}
|
|
200
242
|
console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
|
|
201
243
|
return;
|
|
202
244
|
}
|
package/dist/supabase.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* every query to the signed-in user. Refreshes the access token with the
|
|
6
6
|
* refresh token when needed (refresh does not require a captcha).
|
|
7
7
|
*/
|
|
8
|
-
import { loadCredentials, saveCredentials } from './credentials.js';
|
|
8
|
+
import { loadCredentials, saveCredentials, withCredentialsLock, } from './credentials.js';
|
|
9
9
|
import { slugifyProject } from './guard.js';
|
|
10
10
|
// Public defaults — the anon key is the same public key the web app ships.
|
|
11
11
|
const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
|
|
@@ -22,6 +22,29 @@ variable (and optionally CHANGEBOOK_ACCESS_TOKEN). To extract it manually, sign
|
|
|
22
22
|
in at https://changebook.app, open the browser console and run:
|
|
23
23
|
|
|
24
24
|
JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.endsWith("-auth-token")))).refresh_token`;
|
|
25
|
+
/**
|
|
26
|
+
* Decide what to do at the start of a refresh, given the refresh token this
|
|
27
|
+
* process held before it took the lock and whatever is on disk now.
|
|
28
|
+
*
|
|
29
|
+
* If another process refreshed while we waited for the lock, the stored refresh
|
|
30
|
+
* token differs from ours AND comes with a fresh access token: adopt those and
|
|
31
|
+
* never spend our own. Replaying a rotated token trips Supabase's
|
|
32
|
+
* reuse-detection and revokes the whole family — the silent logout this guards
|
|
33
|
+
* against. Otherwise spend the freshest refresh token available.
|
|
34
|
+
*/
|
|
35
|
+
export function decideRefresh(priorRefreshToken, stored) {
|
|
36
|
+
if (stored?.refresh_token &&
|
|
37
|
+
stored.refresh_token !== priorRefreshToken &&
|
|
38
|
+
stored.access_token) {
|
|
39
|
+
return {
|
|
40
|
+
kind: 'adopt',
|
|
41
|
+
access_token: stored.access_token,
|
|
42
|
+
refresh_token: stored.refresh_token,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const token = stored?.refresh_token ?? priorRefreshToken;
|
|
46
|
+
return token ? { kind: 'spend', refresh_token: token } : { kind: 'none' };
|
|
47
|
+
}
|
|
25
48
|
export class SupabaseError extends Error {
|
|
26
49
|
status;
|
|
27
50
|
constructor(message, status) {
|
|
@@ -264,30 +287,35 @@ export class Supabase {
|
|
|
264
287
|
});
|
|
265
288
|
return this.refreshing;
|
|
266
289
|
}
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
doRefresh() {
|
|
291
|
+
// Env-var sessions aren't shared through the credentials file, so there is
|
|
292
|
+
// nothing to coordinate between processes: refresh in place.
|
|
293
|
+
if (!this.persistRotation)
|
|
294
|
+
return this.doRefreshInner();
|
|
295
|
+
// Disk-backed sessions (from `changebook login`) can be refreshed by
|
|
296
|
+
// several processes at once — the detached post-commit analyze, the
|
|
297
|
+
// pre-commit guard, a second editor window. Serialize under a cross-process
|
|
298
|
+
// lock so only one spends the rotating token; the others adopt its result
|
|
299
|
+
// instead of replaying a token Supabase has already rotated (which would
|
|
300
|
+
// trip reuse-detection and log the whole account out silently).
|
|
301
|
+
return withCredentialsLock(() => this.doRefreshInner());
|
|
302
|
+
}
|
|
303
|
+
async doRefreshInner() {
|
|
304
|
+
// Under the lock: re-read disk so a refresh another process just completed
|
|
305
|
+
// is picked up before we spend anything.
|
|
306
|
+
const stored = this.persistRotation ? loadCredentials() : null;
|
|
307
|
+
const plan = decideRefresh(this.refreshToken, stored);
|
|
308
|
+
if (plan.kind === 'none') {
|
|
278
309
|
throw new SupabaseError(`Session expired. ${AUTH_HELP}`, 401);
|
|
279
310
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const stored = loadCredentials();
|
|
286
|
-
if (stored?.refresh_token && stored.refresh_token !== this.refreshToken) {
|
|
287
|
-
this.refreshToken = stored.refresh_token;
|
|
288
|
-
res = await this.refreshOnce(this.refreshToken);
|
|
289
|
-
}
|
|
311
|
+
if (plan.kind === 'adopt') {
|
|
312
|
+
// Another process refreshed while we waited: use its fresh tokens.
|
|
313
|
+
this.accessToken = plan.access_token;
|
|
314
|
+
this.refreshToken = plan.refresh_token;
|
|
315
|
+
return;
|
|
290
316
|
}
|
|
317
|
+
this.refreshToken = plan.refresh_token;
|
|
318
|
+
const res = await this.refreshOnce(this.refreshToken);
|
|
291
319
|
if (!res.ok) {
|
|
292
320
|
const body = (await res.text()).slice(0, 300);
|
|
293
321
|
throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
|
package/dist/sync.js
CHANGED
|
@@ -53,18 +53,21 @@ const MIN_PAIR_RATE = 0.6;
|
|
|
53
53
|
// Same window/limit the web uses for the signals strip.
|
|
54
54
|
const ALERT_WINDOW_DAYS = 14;
|
|
55
55
|
const MAX_ALERTS = 3;
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Trae del atlas los datos del bloque y los destila con buildSection, SIN
|
|
58
|
+
* escribir nada. Es el corazón compartido de `sync` (que lo escribe en
|
|
59
|
+
* CLAUDE.md/AGENTS.md) y de `context` (que lo empuja por stdout al hook
|
|
60
|
+
* SessionStart). Misma frontera por proyecto estricta que sync: proyecto que
|
|
61
|
+
* no casa → bloque vacío, jamás el de otro.
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchBriefSection(db, targetDir) {
|
|
57
64
|
const projectName = projectNameFor(targetDir);
|
|
58
|
-
// Frontera por proyecto
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// debe salir VACÍO — jamás el de otro proyecto.
|
|
65
|
+
// Frontera por proyecto (QA 2026-07-18): sin filtro, una cuenta con varios
|
|
66
|
+
// proyectos construiría el mapa de ESTE repo mezclando los datos de todos.
|
|
67
|
+
// Y si el proyecto aún no existe en el atlas, el mapa debe salir VACÍO.
|
|
62
68
|
let projectFilter;
|
|
63
69
|
let projectResolved = true;
|
|
64
70
|
try {
|
|
65
|
-
// strict: el respaldo de proyecto único (0.4.2) es para tools que
|
|
66
|
-
// conversan con un agente; sync escribe este archivo en silencio y no
|
|
67
|
-
// puede adivinar — si el nombre no casa, mapa vacío y punto.
|
|
68
71
|
projectFilter = await db.projectFilterFor(projectName, { strict: true });
|
|
69
72
|
}
|
|
70
73
|
catch {
|
|
@@ -78,19 +81,10 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
|
78
81
|
projectFilter),
|
|
79
82
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
80
83
|
projectFilter),
|
|
81
|
-
// AI-detected regression warnings: best-effort — an error (older schema,
|
|
82
|
-
// RLS hiccup) must not block the sync of the rest of the map.
|
|
83
84
|
db
|
|
84
|
-
.rest(
|
|
85
|
-
// resolved_at=is.null: a dismissed/auto-resolved alert inside the
|
|
86
|
-
// window must not resurface in every agent session as urgent.
|
|
87
|
-
`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
|
|
85
|
+
.rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
|
|
88
86
|
projectFilter)
|
|
89
87
|
.catch(() => []),
|
|
90
|
-
// Auto-remediación fase 2: los encargos pendientes entran en el bloque
|
|
91
|
-
// para que CUALQUIER sesión arranque sabiéndolos y se ofrezca a atacarlos
|
|
92
|
-
// (proponer, no ejecutar — la aprobación sigue siendo del humano).
|
|
93
|
-
// Best-effort como las alertas.
|
|
94
88
|
projectResolved && projectId
|
|
95
89
|
? db
|
|
96
90
|
.callRpc('list_agent_tasks', {
|
|
@@ -101,6 +95,10 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
|
101
95
|
: Promise.resolve([]),
|
|
102
96
|
]);
|
|
103
97
|
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
|
|
98
|
+
return { section, projectId, projectResolved };
|
|
99
|
+
}
|
|
100
|
+
export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
101
|
+
const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
|
|
104
102
|
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
105
103
|
const file = path.join(targetDir, name);
|
|
106
104
|
const updated = await upsertSection(file, section, opts);
|
package/dist/tools.js
CHANGED
|
@@ -96,6 +96,31 @@ export function quotedInList(values) {
|
|
|
96
96
|
.join(",");
|
|
97
97
|
}
|
|
98
98
|
export const FILES_CAP = 8;
|
|
99
|
+
/**
|
|
100
|
+
* Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
|
|
101
|
+
* paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
|
|
102
|
+
* regresión DISTINTOS (count(distinct plain), all-time), solo los con
|
|
103
|
+
* antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
|
|
104
|
+
* infle. No se refuta: cuenta historia, no vigencia.
|
|
105
|
+
*/
|
|
106
|
+
export function computeRecidivism(rows) {
|
|
107
|
+
const plainsByModule = new Map();
|
|
108
|
+
for (const r of rows) {
|
|
109
|
+
const m = (r.module ?? "").trim();
|
|
110
|
+
const p = (r.plain ?? "").trim();
|
|
111
|
+
if (!m || !p)
|
|
112
|
+
continue;
|
|
113
|
+
const set = plainsByModule.get(m) ?? new Set();
|
|
114
|
+
set.add(p);
|
|
115
|
+
plainsByModule.set(m, set);
|
|
116
|
+
}
|
|
117
|
+
const out = new Map();
|
|
118
|
+
for (const [m, set] of plainsByModule) {
|
|
119
|
+
if (set.size >= 2)
|
|
120
|
+
out.set(m, set.size);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
99
124
|
export function filesUnionByChange(rows, cap = FILES_CAP) {
|
|
100
125
|
const acc = new Map();
|
|
101
126
|
for (const r of rows) {
|
|
@@ -138,6 +163,13 @@ export function commitLabel(hash, aliasesRaw) {
|
|
|
138
163
|
const aliases = commitAliasesShort(aliasesRaw);
|
|
139
164
|
return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
|
|
140
165
|
}
|
|
166
|
+
export const FILE_COMMITS_CAP = 5;
|
|
167
|
+
export function recentCommitsForFile(changelogIds, commitById, cap = FILE_COMMITS_CAP) {
|
|
168
|
+
const all = changelogIds
|
|
169
|
+
.map((id) => commitById.get(id))
|
|
170
|
+
.filter((c) => Boolean(c));
|
|
171
|
+
return { commits: all.slice(0, cap), more: Math.max(0, all.length - cap) };
|
|
172
|
+
}
|
|
141
173
|
// Consultation metering (never billing): each successful read leaves a row in
|
|
142
174
|
// atlas_reads so the web can show "your agent consulted the atlas N times".
|
|
143
175
|
// Best-effort and non-blocking — metering must never break or slow a read.
|
|
@@ -616,7 +648,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
616
648
|
});
|
|
617
649
|
server.registerTool("atlas_file_context", {
|
|
618
650
|
title: "Context of the files you are about to edit",
|
|
619
|
-
description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, how often they changed recently, and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
|
|
651
|
+
description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, how often they changed recently, the recent commits that touched each file (cite these instead of running git log), and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
|
|
620
652
|
|
|
621
653
|
Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
|
|
622
654
|
|
|
@@ -624,7 +656,7 @@ Args:
|
|
|
624
656
|
- files (required): 1-8 repo-relative paths.
|
|
625
657
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
626
658
|
|
|
627
|
-
Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, watched_values: [{ name, value, commit }] }] }`,
|
|
659
|
+
Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recidivism: [{ module, prior_regressions }], recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
|
|
628
660
|
inputSchema: {
|
|
629
661
|
files: z.array(z.string().min(1).max(300)).min(1).max(8)
|
|
630
662
|
.describe("Repo-relative paths you are about to edit"),
|
|
@@ -643,10 +675,18 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
643
675
|
const pf = await db.projectFilterFor(project);
|
|
644
676
|
const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
|
|
645
677
|
const perFile = await Promise.all(paths.map(async (file) => {
|
|
646
|
-
const rows = await db.rest(`change_module?select=module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
|
|
678
|
+
const rows = await db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
|
|
647
679
|
pf);
|
|
648
680
|
const byModule = new Map();
|
|
681
|
+
// Commits que tocaron ESTE archivo, del más nuevo al más viejo,
|
|
682
|
+
// deduplicados (un mismo análisis puede traer varias filas de
|
|
683
|
+
// change_module para el mismo archivo). El hash lo resuelve la
|
|
684
|
+
// consulta batcheada de abajo; aquí solo se guarda el orden.
|
|
685
|
+
const changelogIds = [];
|
|
649
686
|
for (const row of rows) {
|
|
687
|
+
if (!changelogIds.includes(row.changelog_id)) {
|
|
688
|
+
changelogIds.push(row.changelog_id);
|
|
689
|
+
}
|
|
650
690
|
const name = (row.module ?? "").trim();
|
|
651
691
|
if (!name)
|
|
652
692
|
continue;
|
|
@@ -663,12 +703,39 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
663
703
|
});
|
|
664
704
|
}
|
|
665
705
|
}
|
|
666
|
-
return { file, modules: [...byModule.values()] };
|
|
706
|
+
return { file, modules: [...byModule.values()], changelogIds };
|
|
667
707
|
}));
|
|
668
708
|
const moduleNames = [
|
|
669
709
|
...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
|
|
670
710
|
];
|
|
671
|
-
|
|
711
|
+
// Commits recientes por archivo (T2: el agente que va a editar dejaba
|
|
712
|
+
// de necesitar `git log -- <archivo>`). UNA consulta batcheada para
|
|
713
|
+
// TODOS los archivos, no una por archivo: es la tool que corre antes
|
|
714
|
+
// de cada edición. hash_aliases entra por el espejo post-squash — el
|
|
715
|
+
// hash servido tiene que existir en el main del consultante.
|
|
716
|
+
const allChangelogIds = [
|
|
717
|
+
...new Set(perFile.flatMap((f) => f.changelogIds)),
|
|
718
|
+
];
|
|
719
|
+
const commitById = new Map();
|
|
720
|
+
if (allChangelogIds.length > 0) {
|
|
721
|
+
const commitRows = await db
|
|
722
|
+
.rest(`changelog?select=id,commit_hash,created_at,hash_aliases&id=in.(${allChangelogIds.join(",")})`)
|
|
723
|
+
.catch(() => []);
|
|
724
|
+
for (const r of commitRows) {
|
|
725
|
+
if (!r.commit_hash)
|
|
726
|
+
continue;
|
|
727
|
+
commitById.set(r.id, {
|
|
728
|
+
commit: r.commit_hash.slice(0, 7),
|
|
729
|
+
commit_aliases: commitAliasesShort(r.hash_aliases),
|
|
730
|
+
date: day(r.created_at),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const commitsByFile = new Map();
|
|
735
|
+
for (const f of perFile) {
|
|
736
|
+
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
737
|
+
}
|
|
738
|
+
const [alerts, watched, recidivismRows] = await Promise.all([
|
|
672
739
|
moduleNames.length
|
|
673
740
|
? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
674
741
|
pf)
|
|
@@ -681,6 +748,17 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
681
748
|
.rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
|
|
682
749
|
pf)
|
|
683
750
|
.catch(() => []),
|
|
751
|
+
// Reincidencia (edit-time, espejo del hospedado): problemas de
|
|
752
|
+
// regresión de TODO el tiempo (sin filtro resolved) de los módulos
|
|
753
|
+
// tocados. Depende de moduleNames como las alertas → mismo Promise.all,
|
|
754
|
+
// sin ronda extra. Se cuenta distinct plain abajo (mismo criterio que
|
|
755
|
+
// el RPC del brief).
|
|
756
|
+
moduleNames.length
|
|
757
|
+
? db
|
|
758
|
+
.rest(`regression_alerts?select=module,plain&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&limit=500` +
|
|
759
|
+
pf)
|
|
760
|
+
.catch(() => [])
|
|
761
|
+
: Promise.resolve([]),
|
|
684
762
|
]);
|
|
685
763
|
const watchedByFile = new Map();
|
|
686
764
|
for (const w of watched) {
|
|
@@ -705,6 +783,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
705
783
|
continue;
|
|
706
784
|
alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
|
|
707
785
|
}
|
|
786
|
+
// Reincidencia: nº de problemas de regresión DISTINTOS por módulo
|
|
787
|
+
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
|
788
|
+
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
789
|
+
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
708
790
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
709
791
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
710
792
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -736,8 +818,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
736
818
|
lines.push("No atlas history for this file yet (new or never analyzed).");
|
|
737
819
|
}
|
|
738
820
|
for (const m of f.modules) {
|
|
821
|
+
const previas = recidivismByModule.get(m.module);
|
|
739
822
|
lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
|
|
740
|
-
(m.risk ? `, risk: ${m.risk}` : "")
|
|
823
|
+
(m.risk ? `, risk: ${m.risk}` : "") +
|
|
824
|
+
(previas ? ` · ⚠ ${previas} prior regressions` : ""));
|
|
741
825
|
if (m.last_note) {
|
|
742
826
|
// Con fecha: una nota es una observación fechada, no estado.
|
|
743
827
|
lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
|
|
@@ -752,6 +836,13 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
752
836
|
? ` (as of commit ${w.commit_hash.slice(0, 7)})`
|
|
753
837
|
: ""));
|
|
754
838
|
}
|
|
839
|
+
// Commits recientes que tocaron este archivo: cita estos hashes en
|
|
840
|
+
// vez de correr `git log -- <archivo>`.
|
|
841
|
+
const rc = commitsByFile.get(f.file);
|
|
842
|
+
if (rc && rc.commits.length > 0) {
|
|
843
|
+
const parts = rc.commits.map((c) => `${c.commit}${c.commit_aliases.length ? ` (=${c.commit_aliases.join(",")})` : ""} (${c.date})`);
|
|
844
|
+
lines.push(`- Recent commits: ${parts.join(", ")}${rc.more > 0 ? ` (+${rc.more} more)` : ""}`);
|
|
845
|
+
}
|
|
755
846
|
lines.push("");
|
|
756
847
|
}
|
|
757
848
|
if (!ancla)
|
|
@@ -761,17 +852,25 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
761
852
|
// descripción lo prometía y solo viajaba en el texto (bug cazado por
|
|
762
853
|
// el verificador adversarial del benchmark).
|
|
763
854
|
const salida = toolResult(contextText, {
|
|
764
|
-
files: perFile.map((f) => ({
|
|
855
|
+
files: perFile.map(({ changelogIds: _drop, ...f }) => ({
|
|
765
856
|
...f,
|
|
766
857
|
open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
|
|
767
858
|
module: m.module,
|
|
768
859
|
plain,
|
|
769
860
|
}))),
|
|
861
|
+
recidivism: f.modules
|
|
862
|
+
.map((m) => ({
|
|
863
|
+
module: m.module,
|
|
864
|
+
prior_regressions: recidivismByModule.get(m.module) ?? 0,
|
|
865
|
+
}))
|
|
866
|
+
.filter((x) => x.prior_regressions >= 2),
|
|
770
867
|
watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
|
|
771
868
|
name: w.name,
|
|
772
869
|
value: w.value,
|
|
773
870
|
commit: w.commit_hash?.slice(0, 7) ?? null,
|
|
774
871
|
})),
|
|
872
|
+
recent_commits: commitsByFile.get(f.file)?.commits ?? [],
|
|
873
|
+
recent_commits_more: commitsByFile.get(f.file)?.more ?? 0,
|
|
775
874
|
})),
|
|
776
875
|
});
|
|
777
876
|
recordRead(db, "atlas_file_context", pf, servedCharsOf(salida), Date.now() - t0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.7",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.4.
|
|
18
|
+
"version": "0.4.7",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|