changebook 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -9
- package/dist/context.js +38 -6
- package/dist/feed.js +147 -0
- package/dist/git.js +14 -0
- package/dist/guard.js +30 -7
- package/dist/hook.js +20 -2
- package/dist/index.js +39 -6
- package/dist/supabase.js +21 -1
- package/dist/sync.js +41 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server that lets coding agents — Claude Code,
|
|
4
4
|
Codex, Cursor — query the **ChangeBook product memory** (the module map and the
|
|
5
|
-
analyzed change history
|
|
6
|
-
codebase, plus a CLI that feeds that memory from any terminal: sign in,
|
|
5
|
+
analyzed change history from your ChangeBook account) instead of re-reading
|
|
6
|
+
the codebase, plus a CLI that feeds that memory from any terminal: sign in,
|
|
7
7
|
analyze uncommitted changes, sync the product map. All MCP tools are
|
|
8
|
-
read-only, and
|
|
8
|
+
read-only, and every query is scoped to the signed-in user.
|
|
9
9
|
|
|
10
10
|
## Two ways to run it
|
|
11
11
|
|
|
@@ -96,11 +96,6 @@ env vars below override them for CI/headless setups.
|
|
|
96
96
|
| `CHANGEBOOK_ACCESS_TOKEN` | — | Short-lived JWT; refreshed automatically when it expires. |
|
|
97
97
|
| `CHANGEBOOK_PROJECT` | — | Scope every query to one project (matched by slug, then exact name — usually the workspace folder name). Unset = all projects. |
|
|
98
98
|
| `CHANGEBOOK_WEB_URL` | `https://changebook.app` | Web app used by `login`/`open` and printed after `analyze`. |
|
|
99
|
-
| `CHANGEBOOK_SUPABASE_URL` | production project | Override for other environments. |
|
|
100
|
-
| `CHANGEBOOK_SUPABASE_ANON_KEY` | production public key | Override for other environments. |
|
|
101
|
-
|
|
102
|
-
The embedded anon key is the same public key the web app ships — it grants
|
|
103
|
-
nothing by itself; your session token plus RLS decide what you can read.
|
|
104
99
|
|
|
105
100
|
## `sync`: product map inside CLAUDE.md / AGENTS.md
|
|
106
101
|
|
|
@@ -119,7 +114,7 @@ touched. Re-run after analyzing changes (or wire it to a git hook).
|
|
|
119
114
|
## Security notes
|
|
120
115
|
|
|
121
116
|
- MCP tools are read-only; only `analyze` writes (through the same audited
|
|
122
|
-
|
|
117
|
+
server endpoint as the extension, with the same quotas).
|
|
123
118
|
- `login` uses a loopback-only handoff: the web app asks for an explicit
|
|
124
119
|
Authorize click and redirects the tokens to `http://127.0.0.1:<port>` in
|
|
125
120
|
the URL fragment — they never leave your machine, and a `state` nonce ties
|
package/dist/context.js
CHANGED
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
* file that can go stale between commits: it is generated on the spot, per
|
|
9
9
|
* session.
|
|
10
10
|
*
|
|
11
|
-
* FAIL-OPEN, ABSOLUTE
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* FAIL-OPEN, ABSOLUTE, in the sense that matters: this runs on the critical
|
|
12
|
+
* path of opening a session, so nothing here may ever block it, slow it, or
|
|
13
|
+
* exit non-zero. The whole body races a short timeout; on timeout or any
|
|
14
|
+
* throw, we emit nothing and exit 0.
|
|
15
|
+
*
|
|
16
|
+
* What is NO LONGER absolute is the silence. Failing quietly used to mean the
|
|
17
|
+
* agent opened a session with no atlas AND no idea why — and the commonest
|
|
18
|
+
* cause, a dead CLI session, is precisely when it most needs to know, because
|
|
19
|
+
* everything the atlas told it about recent work is stale. So the feed pulse
|
|
20
|
+
* (feed.ts) is emitted even when the brief itself cannot be built. Quiet
|
|
21
|
+
* failure and invisible failure are different things; only the first one was
|
|
22
|
+
* ever the goal.
|
|
16
23
|
*/
|
|
17
24
|
import * as fs from "node:fs";
|
|
18
25
|
import path from "node:path";
|
|
26
|
+
import { feedWarningFor } from "./feed.js";
|
|
19
27
|
import { fetchBriefSection } from "./sync.js";
|
|
20
28
|
import { commitAliasesShort, derivaContraHead } from "./tools.js";
|
|
21
29
|
/** Hard ceiling on the critical path: past this, emit nothing and move on. */
|
|
@@ -136,11 +144,35 @@ export function uninstallContextHook(dir) {
|
|
|
136
144
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
137
145
|
return "removed";
|
|
138
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The pulse and the brief, composed. Separate from printContext so the WHOLE
|
|
149
|
+
* thing — including the pulse — stays inside the single timeout race: adding
|
|
150
|
+
* work outside it would erode the "never delays a session" guarantee.
|
|
151
|
+
*
|
|
152
|
+
* The two are gathered independently on purpose. Before this, a throw inside
|
|
153
|
+
* buildPayload took the whole payload down, and the most common cause of that
|
|
154
|
+
* throw is a dead session — which is exactly when the agent most needs to be
|
|
155
|
+
* told something. Now a broken brief still lets the pulse through.
|
|
156
|
+
*/
|
|
157
|
+
async function buildSessionContext(db, dir) {
|
|
158
|
+
// Local and offline: one small file plus a git rev-parse. It cannot fail
|
|
159
|
+
// because of the network or the session, which is the point.
|
|
160
|
+
const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
|
|
161
|
+
let brief = null;
|
|
162
|
+
try {
|
|
163
|
+
brief = await buildPayload(db, dir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
brief = null;
|
|
167
|
+
}
|
|
168
|
+
const parts = [pulse, brief].filter(Boolean);
|
|
169
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
170
|
+
}
|
|
139
171
|
export async function printContext(db, dir) {
|
|
140
172
|
try {
|
|
141
173
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), CONTEXT_TIMEOUT_MS));
|
|
142
174
|
const additionalContext = await Promise.race([
|
|
143
|
-
|
|
175
|
+
buildSessionContext(db, dir),
|
|
144
176
|
timeout,
|
|
145
177
|
]);
|
|
146
178
|
if (!additionalContext)
|
package/dist/feed.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The atlas' feed pulse: did the automatic ingestion actually land?
|
|
3
|
+
*
|
|
4
|
+
* The post-commit hook runs detached and never blocks the commit (see hook.ts),
|
|
5
|
+
* which is the right call — but it means that when it fails (expired session,
|
|
6
|
+
* offline, out of credits) NOTHING surfaces. The atlas just stops learning, and
|
|
7
|
+
* the only symptom is data that looks slightly old, which is indistinguishable
|
|
8
|
+
* from "nothing has happened". A tool whose whole job is to notice silent
|
|
9
|
+
* decay must not decay silently itself.
|
|
10
|
+
*
|
|
11
|
+
* Real case that motivated this (2026-07-25): the stored session died, two
|
|
12
|
+
* commits in a row fed nothing, and the failure was only found by reading a log
|
|
13
|
+
* nobody had a reason to open — by which point the first failure's evidence had
|
|
14
|
+
* already been overwritten by the second.
|
|
15
|
+
*
|
|
16
|
+
* The contract is deliberately one-directional and cheap:
|
|
17
|
+
* `analyze --commit` (the hook path) records the outcome here.
|
|
18
|
+
* `guard` (pre-commit, FOREGROUND, a human is watching) reads it and says it
|
|
19
|
+
* out loud at the next commit.
|
|
20
|
+
*
|
|
21
|
+
* Everything here is best-effort: a read-only .git, a corrupt file or a missing
|
|
22
|
+
* git must never turn into a failed commit. Losing the pulse is bad; breaking
|
|
23
|
+
* someone's commit to report it would be worse.
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { gitPath } from "./git.js";
|
|
28
|
+
const FEED_FILE = "changebook-feed.json";
|
|
29
|
+
/** Where the post-commit hook keeps the full output; cited in the warning. */
|
|
30
|
+
export const HOOK_LOG_FILE = "changebook-hook.log";
|
|
31
|
+
const MAX_REASON_CHARS = 200;
|
|
32
|
+
/**
|
|
33
|
+
* The first paragraph of an error, which is the part that says what went wrong.
|
|
34
|
+
* Several of our errors append a blank line and then the multi-line AUTH_HELP;
|
|
35
|
+
* collapsing all of that into one line produced a warning whose "Last error:"
|
|
36
|
+
* trailed off mid-sentence into the help text ("… Run: changebook login It
|
|
37
|
+
* opens https:"). The remedy line already tells the user what to do.
|
|
38
|
+
*/
|
|
39
|
+
function firstParagraph(reason) {
|
|
40
|
+
return reason.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim();
|
|
41
|
+
}
|
|
42
|
+
export async function feedStatusPath(dir) {
|
|
43
|
+
return gitPath(dir, FEED_FILE).catch(() => null);
|
|
44
|
+
}
|
|
45
|
+
export async function readFeedStatus(dir) {
|
|
46
|
+
try {
|
|
47
|
+
const file = await feedStatusPath(dir);
|
|
48
|
+
if (!file)
|
|
49
|
+
return null;
|
|
50
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
51
|
+
// A hand-edited or half-written file must not crash a commit: accept it
|
|
52
|
+
// only if the two fields the warning depends on are the right shape.
|
|
53
|
+
if (typeof data.ok !== "boolean" || typeof data.failures !== "number") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record how the automatic feed went. Failures accumulate a streak so the
|
|
64
|
+
* warning can say "the last 4 commits" instead of only ever "the last one" —
|
|
65
|
+
* the difference between a blip you can ignore and a week of silence you can't.
|
|
66
|
+
*/
|
|
67
|
+
export async function recordFeed(dir, result, now = new Date()) {
|
|
68
|
+
try {
|
|
69
|
+
const file = await feedStatusPath(dir);
|
|
70
|
+
if (!file)
|
|
71
|
+
return;
|
|
72
|
+
const previous = await readFeedStatus(dir);
|
|
73
|
+
const at = now.toISOString();
|
|
74
|
+
const status = result.ok
|
|
75
|
+
? { ok: true, at, failures: 0 }
|
|
76
|
+
: {
|
|
77
|
+
ok: false,
|
|
78
|
+
at,
|
|
79
|
+
failures: (previous?.ok === false ? previous.failures : 0) + 1,
|
|
80
|
+
since: previous?.ok === false ? (previous.since ?? at) : at,
|
|
81
|
+
reason: result.reason
|
|
82
|
+
? firstParagraph(result.reason).slice(0, MAX_REASON_CHARS)
|
|
83
|
+
: undefined,
|
|
84
|
+
};
|
|
85
|
+
fs.writeFileSync(file, JSON.stringify(status));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Best-effort: never let bookkeeping break a commit.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** A session problem is the one failure the user can fix in one command. */
|
|
92
|
+
function needsLogin(reason) {
|
|
93
|
+
return /session|refresh[_ ]token|logged out|no stored session|\b40[13]\b/i.test(reason ?? "");
|
|
94
|
+
}
|
|
95
|
+
function remedyFor(reason) {
|
|
96
|
+
return needsLogin(reason)
|
|
97
|
+
? "changebook login"
|
|
98
|
+
: "changebook analyze --commit HEAD (to see the error in full)";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The warning a human sees, as text. Built separately from printing it so it
|
|
102
|
+
* can be tested, same shape as guard.ts' findingsMessage. Empty string when
|
|
103
|
+
* there is nothing to say — a healthy feed must stay completely silent, or the
|
|
104
|
+
* warning becomes noise people learn to skip.
|
|
105
|
+
*/
|
|
106
|
+
export function feedWarning(status, opts = {}) {
|
|
107
|
+
const { logPath = `.git/${HOOK_LOG_FILE}`, audience = "human" } = opts;
|
|
108
|
+
if (!status || status.ok || status.failures < 1)
|
|
109
|
+
return "";
|
|
110
|
+
const commits = status.failures === 1
|
|
111
|
+
? "The last commit was not analyzed"
|
|
112
|
+
: `The last ${status.failures} commits were not analyzed`;
|
|
113
|
+
const since = status.since ? ` (since ${status.since.slice(0, 16).replace("T", " ")} UTC)` : "";
|
|
114
|
+
if (audience === "agent") {
|
|
115
|
+
const lines = [
|
|
116
|
+
"⚠ ChangeBook: this project's atlas is NOT being fed.",
|
|
117
|
+
`${commits}${since}, so anything it tells you about recent work is out of date — say so before you rely on it.`,
|
|
118
|
+
];
|
|
119
|
+
if (status.reason)
|
|
120
|
+
lines.push(`Last error: ${status.reason}`);
|
|
121
|
+
lines.push(needsLogin(status.reason)
|
|
122
|
+
? "Tell the user to run `changebook login` in their terminal. It opens a browser to authorize, so you cannot complete it yourself."
|
|
123
|
+
: `Tell the user their atlas stopped updating; the full error is in ${logPath}.`);
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
const lines = [
|
|
127
|
+
"\n⚠ ChangeBook: your atlas has stopped being fed.",
|
|
128
|
+
` ${commits}${since}, so what it tells you is going stale.`,
|
|
129
|
+
];
|
|
130
|
+
if (status.reason)
|
|
131
|
+
lines.push(` Last error: ${status.reason}`);
|
|
132
|
+
lines.push(` Fix: ${remedyFor(status.reason)}`);
|
|
133
|
+
lines.push(` Full output: ${logPath}\n`);
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
/** Convenience for callers that only want the text for a directory. */
|
|
137
|
+
export async function feedWarningFor(dir, opts = {}) {
|
|
138
|
+
const status = await readFeedStatus(dir);
|
|
139
|
+
if (!status || status.ok)
|
|
140
|
+
return "";
|
|
141
|
+
const file = await feedStatusPath(dir);
|
|
142
|
+
const logPath = file
|
|
143
|
+
? path.join(path.dirname(file), HOOK_LOG_FILE)
|
|
144
|
+
: `.git/${HOOK_LOG_FILE}`;
|
|
145
|
+
return feedWarning(status, { ...opts, logPath });
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=feed.js.map
|
package/dist/git.js
CHANGED
|
@@ -5,8 +5,22 @@
|
|
|
5
5
|
* compress differently across subcommands and break the server-side dedup.
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
|
+
import * as path from "node:path";
|
|
8
9
|
import { promisify } from "node:util";
|
|
9
10
|
export const execFileAsync = promisify(execFile);
|
|
11
|
+
/**
|
|
12
|
+
* Absolute path to a file inside this repo's git dir (the guard's cache, the
|
|
13
|
+
* feed pulse, the hook log). `--git-path` rather than building
|
|
14
|
+
* `<git-dir>/<name>` by hand: it resolves a linked worktree's common dir, so
|
|
15
|
+
* every process that shares the repo agrees on where these files live.
|
|
16
|
+
*/
|
|
17
|
+
export async function gitPath(dir, name) {
|
|
18
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], {
|
|
19
|
+
cwd: dir,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
});
|
|
22
|
+
return path.resolve(dir, stdout.trim());
|
|
23
|
+
}
|
|
10
24
|
export const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
11
25
|
// Mirror of the extension's default changebook.maxDiffCharacters (the server
|
|
12
26
|
// rejects anything above 60k anyway).
|
package/dist/guard.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { execFileSync } from "node:child_process";
|
|
18
|
-
import { execFileAsync } from "./git.js";
|
|
18
|
+
import { execFileAsync, gitPath } from "./git.js";
|
|
19
|
+
import { feedWarningFor } from "./feed.js";
|
|
19
20
|
/** Exit code that asks the pre-commit hook to abort the commit. */
|
|
20
21
|
export const EXIT_BLOCK = 3;
|
|
21
22
|
// A commit should never feel slow because of us: whatever the network hasn't
|
|
@@ -200,10 +201,6 @@ export function contarEnRepo(dir, simbolo) {
|
|
|
200
201
|
return code === 1 ? 0 : null;
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
|
-
async function gitPath(dir, name) {
|
|
204
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
|
|
205
|
-
return path.resolve(dir, stdout.trim());
|
|
206
|
-
}
|
|
207
204
|
export async function stagedFiles(dir) {
|
|
208
205
|
// -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
|
|
209
206
|
// (default quotepath would emit "m\303\263dulo.ts", which never matches the
|
|
@@ -273,11 +270,29 @@ async function fetchSignals(db, dir, env) {
|
|
|
273
270
|
}
|
|
274
271
|
return { alerts, filesByModule, projectId, fromCache: false };
|
|
275
272
|
}
|
|
276
|
-
|
|
273
|
+
// Un fallo intermitente no se diagnostica con la última foto: hace falta la
|
|
274
|
+
// racha. Este log guardaba SOLO la última corrida, y el 2026-07-25 eso costó un
|
|
275
|
+
// diagnóstico — la corrida que gastó la sesión ya había sido pisada por la
|
|
276
|
+
// siguiente cuando fui a mirar. Acotado por tamaño, no por olvido.
|
|
277
|
+
const GUARD_LOG_MAX_BYTES = 65_536;
|
|
278
|
+
const GUARD_LOG_KEEP_BYTES = 32_768;
|
|
279
|
+
/** Traza por corrida, en append acotado, para que "¿por qué no avisó?" tenga respuesta. */
|
|
277
280
|
async function logRun(dir, message) {
|
|
278
281
|
try {
|
|
279
282
|
const file = await gitPath(dir, "changebook-guard.log");
|
|
280
|
-
|
|
283
|
+
// Recortar ANTES de anexar: con appendFileSync no hay descriptor abierto de
|
|
284
|
+
// por medio, pero el orden mantiene el fichero por debajo del techo aunque
|
|
285
|
+
// esta corrida escriba una línea larga.
|
|
286
|
+
try {
|
|
287
|
+
if (fs.statSync(file).size > GUARD_LOG_MAX_BYTES) {
|
|
288
|
+
const keep = fs.readFileSync(file).subarray(-GUARD_LOG_KEEP_BYTES);
|
|
289
|
+
fs.writeFileSync(file, keep);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// El fichero aún no existe: nada que recortar.
|
|
294
|
+
}
|
|
295
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`);
|
|
281
296
|
}
|
|
282
297
|
catch {
|
|
283
298
|
// Best-effort only.
|
|
@@ -314,6 +329,14 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
314
329
|
const mode = (env.CHANGEBOOK_GUARD ?? "").trim().toLowerCase();
|
|
315
330
|
if (mode === "off")
|
|
316
331
|
return 0;
|
|
332
|
+
// The atlas' feed pulse, FIRST and offline. This is the one warning that has
|
|
333
|
+
// to survive a dead session — a dead session is its most common cause, and
|
|
334
|
+
// every check below this point either needs credentials or the network. It
|
|
335
|
+
// reads one small file, so it cannot slow a commit down, and like everything
|
|
336
|
+
// else here it only informs: the exit code is untouched.
|
|
337
|
+
const pulse = await feedWarningFor(dir);
|
|
338
|
+
if (pulse)
|
|
339
|
+
console.error(pulse);
|
|
317
340
|
if (!db.hasCredentials())
|
|
318
341
|
return 0;
|
|
319
342
|
let staged;
|
package/dist/hook.js
CHANGED
|
@@ -22,16 +22,34 @@ function cliEntry() {
|
|
|
22
22
|
}
|
|
23
23
|
const POST_COMMIT_MARKER = "# changebook post-commit hook";
|
|
24
24
|
const PRE_COMMIT_MARKER = "# changebook pre-commit guard";
|
|
25
|
+
// The hook log grows by one run per commit, so it needs a ceiling — but one
|
|
26
|
+
// that keeps enough history to diagnose a streak of failures, which is the
|
|
27
|
+
// whole reason it is worth keeping at all.
|
|
28
|
+
const LOG_MAX_BYTES = 262_144;
|
|
29
|
+
const LOG_TRIM_KEEP_BYTES = 131_072;
|
|
25
30
|
/** Exported for tests: the contract between this script and guard.ts. */
|
|
26
31
|
export function postCommitScript() {
|
|
27
32
|
// Background subshell + `|| true`: a broken analyze (offline, out of
|
|
28
33
|
// credits, logged out) must never make `git commit` fail or feel slow.
|
|
29
|
-
//
|
|
34
|
+
//
|
|
35
|
+
// The log APPENDS, with a UTC header per run. It used to keep only the last
|
|
36
|
+
// run to stay bounded, which cost us a real diagnosis on 2026-07-25: two
|
|
37
|
+
// commits failed in a row and the second overwrote the evidence of the first,
|
|
38
|
+
// so which process broke the session was no longer answerable. Bounded is
|
|
39
|
+
// still right, but by trimming — not by forgetting everything but the last
|
|
40
|
+
// line. The trim runs BEFORE the redirection opens on purpose: replacing the
|
|
41
|
+
// file while the append fd is already open would leave that fd pointing at
|
|
42
|
+
// the unlinked inode and silently throw the run's output away.
|
|
43
|
+
const trimmed = `${LOG_TRIM_KEEP_BYTES}`;
|
|
30
44
|
return `#!/bin/sh
|
|
31
45
|
${POST_COMMIT_MARKER} — analyzes each commit into your ChangeBook atlas.
|
|
32
46
|
# Runs in the background and never blocks the commit. Remove with:
|
|
33
47
|
# changebook hook uninstall
|
|
34
|
-
|
|
48
|
+
LOG="$(git rev-parse --git-path changebook-hook.log)"
|
|
49
|
+
if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt ${LOG_MAX_BYTES} ]; then
|
|
50
|
+
tail -c ${trimmed} "$LOG" > "$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG"
|
|
51
|
+
fi
|
|
52
|
+
( { date -u +'=== %Y-%m-%dT%H:%M:%SZ'; ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD; } >> "$LOG" 2>&1 & ) || true
|
|
35
53
|
`;
|
|
36
54
|
}
|
|
37
55
|
/** Exported for tests: the contract between this script and guard.ts. */
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
16
16
|
import { analyze } from "./analyze.js";
|
|
17
17
|
import { atlasWebUrl, openInBrowser } from "./browser.js";
|
|
18
18
|
import { clearCredentials, credentialsPath } from "./credentials.js";
|
|
19
|
+
import { recordFeed } from "./feed.js";
|
|
19
20
|
import { runGuard } from "./guard.js";
|
|
20
21
|
import { hookStatus, installHook, uninstallHook } from "./hook.js";
|
|
21
22
|
import { importHistory } from "./import.js";
|
|
@@ -136,9 +137,36 @@ async function main() {
|
|
|
136
137
|
}
|
|
137
138
|
case "analyze": {
|
|
138
139
|
const db = new Supabase();
|
|
139
|
-
requireCredentials(db);
|
|
140
140
|
const options = parseAnalyzeArgs(process.argv.slice(3));
|
|
141
|
-
|
|
141
|
+
const dir = options.dir ?? process.cwd();
|
|
142
|
+
// --commit IS the post-commit hook's path: the automatic feed, running
|
|
143
|
+
// detached where nobody reads its output. Record whether it landed so the
|
|
144
|
+
// next pre-commit can say it out loud (feed.ts). A manual `changebook
|
|
145
|
+
// analyze` is someone watching the terminal — no pulse needed, and
|
|
146
|
+
// recording one would let a hand-run failure warn about the hook.
|
|
147
|
+
const feeding = Boolean(options.commit);
|
|
148
|
+
if (!db.hasCredentials()) {
|
|
149
|
+
// Not requireCredentials(): it exits, and being logged out is exactly
|
|
150
|
+
// the failure the pulse exists to surface.
|
|
151
|
+
if (feeding)
|
|
152
|
+
await recordFeed(dir, { ok: false, reason: "No stored session." });
|
|
153
|
+
console.error(AUTH_HELP);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await analyze(db, options);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (feeding) {
|
|
161
|
+
await recordFeed(dir, {
|
|
162
|
+
ok: false,
|
|
163
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (feeding)
|
|
169
|
+
await recordFeed(dir, { ok: true });
|
|
142
170
|
// El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
|
|
143
171
|
// así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
|
|
144
172
|
// causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
|
|
@@ -147,9 +175,7 @@ async function main() {
|
|
|
147
175
|
// archivos ni resucita un bloque borrado. Best-effort: un sync caído no
|
|
148
176
|
// puede tumbar un análisis ya cobrado y registrado.
|
|
149
177
|
try {
|
|
150
|
-
await syncContextFiles(db,
|
|
151
|
-
refreshOnly: true,
|
|
152
|
-
});
|
|
178
|
+
await syncContextFiles(db, dir, { refreshOnly: true });
|
|
153
179
|
}
|
|
154
180
|
catch (error) {
|
|
155
181
|
console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -167,7 +193,14 @@ async function main() {
|
|
|
167
193
|
// repo must commit exactly as before — runGuard resolves every failure
|
|
168
194
|
// to exit 0 itself. The explicit exit also drops any fetch still racing
|
|
169
195
|
// the timeout, so the commit never waits on a dangling socket.
|
|
170
|
-
|
|
196
|
+
//
|
|
197
|
+
// allowRefresh: false — and it is that same exit that makes it necessary.
|
|
198
|
+
// A dropped fetch is harmless unless it happens to be the token refresh:
|
|
199
|
+
// Supabase rotates on receipt, so dying mid-flight burns the stored token
|
|
200
|
+
// without saving its replacement and logs the user out of everything.
|
|
201
|
+
// The guard is an optional warning with a 3.5s budget; it must never be
|
|
202
|
+
// able to cost a session. Expired access token → no warning this time.
|
|
203
|
+
return process.exit(await runGuard(new Supabase(process.env, { allowRefresh: false }), arg ?? process.cwd()));
|
|
171
204
|
}
|
|
172
205
|
case "hook": {
|
|
173
206
|
const dir = process.argv[4] ?? process.cwd();
|
package/dist/supabase.js
CHANGED
|
@@ -64,7 +64,21 @@ export class Supabase {
|
|
|
64
64
|
// True when the tokens came from ~/.changebook/credentials.json: rotated
|
|
65
65
|
// refresh tokens must be written back there or the stored one goes stale.
|
|
66
66
|
persistRotation = false;
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Whether this process is allowed to SPEND the rotating refresh token.
|
|
69
|
+
*
|
|
70
|
+
* False for callers that run under a hard deadline and end in process.exit()
|
|
71
|
+
* — today, the pre-commit guard. Supabase rotates the refresh token the
|
|
72
|
+
* moment it RECEIVES the request, not when we read the reply, so a process
|
|
73
|
+
* that is killed mid-refresh burns the stored token without ever persisting
|
|
74
|
+
* the new one. The next process replays a spent token, Supabase's reuse
|
|
75
|
+
* detection fires, and the whole session dies. That is a silent logout
|
|
76
|
+
* caused by an optional warning — a terrible trade (diagnosed 2026-07-25:
|
|
77
|
+
* the session died ~30 min after every login, always on a commit).
|
|
78
|
+
*/
|
|
79
|
+
allowRefresh = true;
|
|
80
|
+
constructor(env = process.env, opts = {}) {
|
|
81
|
+
this.allowRefresh = opts.allowRefresh ?? true;
|
|
68
82
|
this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, '');
|
|
69
83
|
this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
|
|
70
84
|
this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
|
|
@@ -288,6 +302,12 @@ export class Supabase {
|
|
|
288
302
|
return this.refreshing;
|
|
289
303
|
}
|
|
290
304
|
doRefresh() {
|
|
305
|
+
// Refusing BEFORE the request is the whole point: once it leaves, the token
|
|
306
|
+
// is spent whether or not we survive to store the replacement. Callers that
|
|
307
|
+
// opt out get a plain 401 they can treat as "no atlas this time".
|
|
308
|
+
if (!this.allowRefresh) {
|
|
309
|
+
return Promise.reject(new SupabaseError("The stored session needs renewing and this process is not allowed to spend it (it runs under a deadline). Skipping.", 401));
|
|
310
|
+
}
|
|
291
311
|
// Env-var sessions aren't shared through the credentials file, so there is
|
|
292
312
|
// nothing to coordinate between processes: refresh in place.
|
|
293
313
|
if (!this.persistRotation)
|
package/dist/sync.js
CHANGED
|
@@ -50,6 +50,27 @@ const SYNC_BUDGET_CHARS = 2_000;
|
|
|
50
50
|
// shared analyses and a ≥60% rate before we call it a dependency.
|
|
51
51
|
const MIN_PAIR_COUNT = 3;
|
|
52
52
|
const MIN_PAIR_RATE = 0.6;
|
|
53
|
+
/**
|
|
54
|
+
* Espejo de supabase/functions/mcp/scope.ts::summarizeHealth — el paquete npm
|
|
55
|
+
* es autocontenido. Paridad fijada en test/saludEnBrief.
|
|
56
|
+
*/
|
|
57
|
+
export function summarizeHealth(rows) {
|
|
58
|
+
let passed = 0;
|
|
59
|
+
const at_risk = [];
|
|
60
|
+
for (const r of rows) {
|
|
61
|
+
if (r.status === 'passed') {
|
|
62
|
+
passed += 1;
|
|
63
|
+
}
|
|
64
|
+
else if (r.status === 'at_risk') {
|
|
65
|
+
at_risk.push({
|
|
66
|
+
check: r.check_id,
|
|
67
|
+
evidence: (r.evidence ?? '').trim(),
|
|
68
|
+
since: r.updated_at ? r.updated_at.slice(0, 10) : null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { passed, at_risk };
|
|
73
|
+
}
|
|
53
74
|
// Same window/limit the web uses for the signals strip.
|
|
54
75
|
const ALERT_WINDOW_DAYS = 14;
|
|
55
76
|
const MAX_ALERTS = 3;
|
|
@@ -76,7 +97,7 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
76
97
|
}
|
|
77
98
|
const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
78
99
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
79
|
-
const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
|
|
100
|
+
const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
|
|
80
101
|
db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
|
|
81
102
|
projectFilter),
|
|
82
103
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
@@ -93,8 +114,14 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
93
114
|
.then((rows) => rows.filter((r) => r.status === 'pending'))
|
|
94
115
|
.catch(() => [])
|
|
95
116
|
: Promise.resolve([]),
|
|
117
|
+
// Salud (project_checks): tabla diminuta (≤8 filas/proyecto), en paralelo.
|
|
118
|
+
db
|
|
119
|
+
.rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
|
|
120
|
+
projectFilter)
|
|
121
|
+
.catch(() => []),
|
|
96
122
|
]);
|
|
97
|
-
const
|
|
123
|
+
const health = summarizeHealth(healthRows);
|
|
124
|
+
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk);
|
|
98
125
|
return { section, projectId, projectResolved };
|
|
99
126
|
}
|
|
100
127
|
export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
@@ -119,7 +146,7 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
/** Exported for tests. */
|
|
122
|
-
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
|
|
149
|
+
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = []) {
|
|
123
150
|
// Newest-first rows: the first occurrence of a module is its latest state.
|
|
124
151
|
const seen = new Map();
|
|
125
152
|
for (const row of rows) {
|
|
@@ -225,6 +252,11 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
225
252
|
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
|
|
226
253
|
});
|
|
227
254
|
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
|
|
255
|
+
// Salud en riesgo: los controles (auth, secretos, validación, límites…) que
|
|
256
|
+
// el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
|
|
257
|
+
// es seguridad. Solo los at_risk (lo accionable); el texto entero va a
|
|
258
|
+
// `atlas_project_brief`. Cap a 130 chars como las alertas.
|
|
259
|
+
const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
|
|
228
260
|
// Auto-remediación fase 2: la cola entra en cada sesión para que el agente
|
|
229
261
|
// se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
|
|
230
262
|
// nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
|
|
@@ -261,6 +293,12 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
261
293
|
title: '### Regresiones detectadas (resolver o verificar YA)',
|
|
262
294
|
lines: alertLines,
|
|
263
295
|
},
|
|
296
|
+
{
|
|
297
|
+
key: 'health',
|
|
298
|
+
priority: 1,
|
|
299
|
+
title: '### Salud en riesgo (verifica antes de tocar)',
|
|
300
|
+
lines: healthLines,
|
|
301
|
+
},
|
|
264
302
|
{
|
|
265
303
|
key: 'hotspots',
|
|
266
304
|
priority: 4,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.8",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.4.
|
|
18
|
+
"version": "0.4.8",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|