changebook 0.4.7 → 0.4.9
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 +7 -9
- package/dist/analyze.js +4 -2
- package/dist/context.js +114 -35
- package/dist/credentials.js +42 -0
- package/dist/feed.js +147 -0
- package/dist/git.js +47 -1
- package/dist/guard.js +30 -7
- package/dist/hook.js +20 -2
- package/dist/impact.js +512 -0
- package/dist/index.js +93 -7
- package/dist/login.js +61 -1
- package/dist/supabase.js +69 -5
- package/dist/sync.js +59 -6
- package/dist/tools.js +65 -1
- 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
|
|
|
@@ -50,6 +50,9 @@ your other servers.
|
|
|
50
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. |
|
|
51
51
|
| `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
|
|
52
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 hook-context install\|uninstall\|status [dir]` | Claude Code `SessionStart` hook: pushes the fresh atlas map into **every** session at turn 0 — no tool call to remember, and generated on the spot so it can't go stale. Writes to `.claude/settings.json`; running the command **is** the consent, and it refuses to touch a config it can't parse. |
|
|
54
|
+
| `changebook hook-impact install\|uninstall\|status [dir]` | Claude Code `PreToolUse` hook: **before every edit**, tells the agent which modules depend on the file it is about to touch, plus any open alert and repeat-offender history. Never blocks an edit, never touches the network on the critical path (reads a short-lived cache in `.git/` and refreshes it out of band), warns once per file per session, and stays **silent** when there is nothing to say. |
|
|
55
|
+
| `changebook impact` / `changebook context [dir]` | What those two hooks run. Both read from stdin/disk, print a JSON payload (or nothing) and always exit 0 — you don't call them by hand. |
|
|
53
56
|
| `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. |
|
|
54
57
|
| `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
|
|
55
58
|
| `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
|
|
@@ -96,11 +99,6 @@ env vars below override them for CI/headless setups.
|
|
|
96
99
|
| `CHANGEBOOK_ACCESS_TOKEN` | — | Short-lived JWT; refreshed automatically when it expires. |
|
|
97
100
|
| `CHANGEBOOK_PROJECT` | — | Scope every query to one project (matched by slug, then exact name — usually the workspace folder name). Unset = all projects. |
|
|
98
101
|
| `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
102
|
|
|
105
103
|
## `sync`: product map inside CLAUDE.md / AGENTS.md
|
|
106
104
|
|
|
@@ -119,7 +117,7 @@ touched. Re-run after analyzing changes (or wire it to a git hook).
|
|
|
119
117
|
## Security notes
|
|
120
118
|
|
|
121
119
|
- MCP tools are read-only; only `analyze` writes (through the same audited
|
|
122
|
-
|
|
120
|
+
server endpoint as the extension, with the same quotas).
|
|
123
121
|
- `login` uses a loopback-only handoff: the web app asks for an explicit
|
|
124
122
|
Authorize click and redirects the tokens to `http://127.0.0.1:<port>` in
|
|
125
123
|
the URL fragment — they never leave your machine, and a `state` nonce ties
|
package/dist/analyze.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
12
|
export async function analyze(db, options = {}) {
|
|
@@ -124,7 +124,9 @@ async function commitMeta(cwd, ref) {
|
|
|
124
124
|
}
|
|
125
125
|
async function gitDiffHead(cwd) {
|
|
126
126
|
try {
|
|
127
|
-
|
|
127
|
+
// Mismos ficheros excluidos que en commitDiff: el atlas no se analiza a sí
|
|
128
|
+
// mismo (ver FICHEROS_GENERADOS).
|
|
129
|
+
const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0", "--", ...FICHEROS_GENERADOS], {
|
|
128
130
|
cwd,
|
|
129
131
|
encoding: "utf8",
|
|
130
132
|
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
package/dist/context.js
CHANGED
|
@@ -8,18 +8,58 @@
|
|
|
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. */
|
|
22
30
|
const CONTEXT_TIMEOUT_MS = 2_000;
|
|
31
|
+
/**
|
|
32
|
+
* A timeout that can lose the race without punishing the winner.
|
|
33
|
+
*
|
|
34
|
+
* The naive form — `new Promise(r => setTimeout(r, ms))` raced against the real
|
|
35
|
+
* work — has a bug that measurement makes obvious and reading never does: when
|
|
36
|
+
* the work wins, the timer is still pending, and Node will not exit until it
|
|
37
|
+
* fires. The process is done and just sits there. Measured 2026-07-26:
|
|
38
|
+
* `changebook context` took 2.18s wall clock to produce a payload it already had
|
|
39
|
+
* in ~200ms, on the session-start path, under a doc comment promising it would
|
|
40
|
+
* "never block it, slow it, or exit non-zero". It had been slowing every session
|
|
41
|
+
* by two seconds.
|
|
42
|
+
*
|
|
43
|
+
* `unref()` is the fix: the timer still fires if anything else is keeping the
|
|
44
|
+
* loop alive, but it never keeps the loop alive by itself. `cancelar()` is belt
|
|
45
|
+
* and braces for the long-lived case (the MCP server), where an unref'd timer
|
|
46
|
+
* would still fire pointlessly.
|
|
47
|
+
*
|
|
48
|
+
* Shared by both hooks on purpose. The defect existed independently in two
|
|
49
|
+
* places, which is the usual sign that the shape — not the instance — was wrong.
|
|
50
|
+
*/
|
|
51
|
+
export function presupuestoDeTiempo(ms) {
|
|
52
|
+
let cancelar = () => { };
|
|
53
|
+
const vencido = new Promise((resolve) => {
|
|
54
|
+
const t = setTimeout(() => resolve(null), ms);
|
|
55
|
+
t.unref?.();
|
|
56
|
+
cancelar = () => {
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
resolve(null);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return { vencido, cancelar };
|
|
62
|
+
}
|
|
23
63
|
async function buildPayload(db, dir) {
|
|
24
64
|
// Logged out → silent no-op. A fresh clone with the hook installed but no
|
|
25
65
|
// session must open exactly as fast as before.
|
|
@@ -52,24 +92,20 @@ async function buildPayload(db, dir) {
|
|
|
52
92
|
}
|
|
53
93
|
return drift ? `${section}\n\n${drift}` : section;
|
|
54
94
|
}
|
|
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
95
|
// `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
|
|
62
96
|
// shell error is swallowed and the hook exits 0 — a missing binary must not
|
|
63
|
-
// make a
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
// make a hook look failed. Both commands emit only the JSON payload (or
|
|
98
|
+
// nothing) on stdout.
|
|
99
|
+
export const CONTEXT_HOOK = {
|
|
100
|
+
event: "SessionStart",
|
|
101
|
+
command: "changebook context 2>/dev/null || true",
|
|
102
|
+
marker: "changebook context",
|
|
103
|
+
};
|
|
68
104
|
function settingsPath(dir) {
|
|
69
105
|
return path.join(path.resolve(dir), ".claude", "settings.json");
|
|
70
106
|
}
|
|
71
|
-
function groupHasMarker(g) {
|
|
72
|
-
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(
|
|
107
|
+
function groupHasMarker(g, marker) {
|
|
108
|
+
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(marker));
|
|
73
109
|
}
|
|
74
110
|
function readSettings(file) {
|
|
75
111
|
let raw;
|
|
@@ -89,60 +125,103 @@ function readSettings(file) {
|
|
|
89
125
|
return null; // unparseable → caller must refuse, never clobber
|
|
90
126
|
}
|
|
91
127
|
}
|
|
92
|
-
export function
|
|
128
|
+
export function settingsHookInstalled(dir, spec) {
|
|
93
129
|
const s = readSettings(settingsPath(dir));
|
|
94
|
-
return Boolean(s?.hooks?.
|
|
130
|
+
return Boolean(s?.hooks?.[spec.event]?.some((g) => groupHasMarker(g, spec.marker)));
|
|
95
131
|
}
|
|
96
|
-
export function
|
|
132
|
+
export function installSettingsHook(dir, spec) {
|
|
97
133
|
const file = settingsPath(dir);
|
|
98
134
|
const settings = readSettings(file);
|
|
99
135
|
if (settings === null) {
|
|
100
136
|
throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
|
|
101
137
|
}
|
|
102
138
|
const hooks = (settings.hooks ??= {});
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
139
|
+
const list = (hooks[spec.event] ??= []);
|
|
140
|
+
if (list.some((g) => groupHasMarker(g, spec.marker)))
|
|
105
141
|
return "already";
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
// El matcher solo se escribe si lo hay: un `matcher` presente y vacío es
|
|
143
|
+
// "todas las tools" en Claude Code, y en SessionStart no significa nada.
|
|
144
|
+
// Escribir la clave igualmente dejaría un campo mudo en un archivo que la
|
|
145
|
+
// gente lee y commitea.
|
|
146
|
+
list.push({
|
|
147
|
+
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
148
|
+
hooks: [{ type: "command", command: spec.command }],
|
|
108
149
|
});
|
|
109
150
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
110
151
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
111
152
|
return "installed";
|
|
112
153
|
}
|
|
113
|
-
export function
|
|
154
|
+
export function uninstallSettingsHook(dir, spec) {
|
|
114
155
|
const file = settingsPath(dir);
|
|
115
156
|
const settings = readSettings(file);
|
|
116
|
-
if (!settings
|
|
157
|
+
if (!settings?.hooks?.[spec.event])
|
|
117
158
|
return "absent";
|
|
118
|
-
const before = settings.hooks.
|
|
159
|
+
const before = settings.hooks[spec.event];
|
|
119
160
|
// Drop our command from every group, then drop groups left empty. Foreign
|
|
120
161
|
// hooks in the same group (unusual, but possible) are preserved.
|
|
121
162
|
const after = before
|
|
122
163
|
.map((g) => ({
|
|
123
164
|
...g,
|
|
124
|
-
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(
|
|
165
|
+
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(spec.marker)),
|
|
125
166
|
}))
|
|
126
167
|
.filter((g) => (g.hooks ?? []).length > 0);
|
|
127
|
-
if (after.length === before.length &&
|
|
168
|
+
if (after.length === before.length &&
|
|
169
|
+
before.every((g) => !groupHasMarker(g, spec.marker))) {
|
|
128
170
|
return "absent";
|
|
129
171
|
}
|
|
130
172
|
if (after.length > 0)
|
|
131
|
-
settings.hooks.
|
|
173
|
+
settings.hooks[spec.event] = after;
|
|
132
174
|
else
|
|
133
|
-
delete settings.hooks.
|
|
175
|
+
delete settings.hooks[spec.event];
|
|
134
176
|
if (Object.keys(settings.hooks).length === 0)
|
|
135
177
|
delete settings.hooks;
|
|
136
178
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
137
179
|
return "removed";
|
|
138
180
|
}
|
|
181
|
+
// Envoltorios del canal SessionStart. Se mantienen con su nombre porque son la
|
|
182
|
+
// superficie que ya consume index.ts (y, a través de él, `init`): generalizar
|
|
183
|
+
// por dentro no es motivo para renombrar por fuera.
|
|
184
|
+
export function contextHookInstalled(dir) {
|
|
185
|
+
return settingsHookInstalled(dir, CONTEXT_HOOK);
|
|
186
|
+
}
|
|
187
|
+
export function installContextHook(dir) {
|
|
188
|
+
return installSettingsHook(dir, CONTEXT_HOOK);
|
|
189
|
+
}
|
|
190
|
+
export function uninstallContextHook(dir) {
|
|
191
|
+
return uninstallSettingsHook(dir, CONTEXT_HOOK);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The pulse and the brief, composed. Separate from printContext so the WHOLE
|
|
195
|
+
* thing — including the pulse — stays inside the single timeout race: adding
|
|
196
|
+
* work outside it would erode the "never delays a session" guarantee.
|
|
197
|
+
*
|
|
198
|
+
* The two are gathered independently on purpose. Before this, a throw inside
|
|
199
|
+
* buildPayload took the whole payload down, and the most common cause of that
|
|
200
|
+
* throw is a dead session — which is exactly when the agent most needs to be
|
|
201
|
+
* told something. Now a broken brief still lets the pulse through.
|
|
202
|
+
*/
|
|
203
|
+
async function buildSessionContext(db, dir) {
|
|
204
|
+
// Local and offline: one small file plus a git rev-parse. It cannot fail
|
|
205
|
+
// because of the network or the session, which is the point.
|
|
206
|
+
const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
|
|
207
|
+
let brief = null;
|
|
208
|
+
try {
|
|
209
|
+
brief = await buildPayload(db, dir);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
brief = null;
|
|
213
|
+
}
|
|
214
|
+
const parts = [pulse, brief].filter(Boolean);
|
|
215
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
216
|
+
}
|
|
139
217
|
export async function printContext(db, dir) {
|
|
140
218
|
try {
|
|
141
|
-
const
|
|
219
|
+
const { vencido, cancelar } = presupuestoDeTiempo(CONTEXT_TIMEOUT_MS);
|
|
142
220
|
const additionalContext = await Promise.race([
|
|
143
|
-
|
|
144
|
-
|
|
221
|
+
buildSessionContext(db, dir),
|
|
222
|
+
vencido,
|
|
145
223
|
]);
|
|
224
|
+
cancelar();
|
|
146
225
|
if (!additionalContext)
|
|
147
226
|
return;
|
|
148
227
|
// The SessionStart contract: stdout JSON whose additionalContext is
|
package/dist/credentials.js
CHANGED
|
@@ -61,6 +61,48 @@ export function clearCredentials() {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
const INFLIGHT_FILE = path.join(DIR, "refresh-inflight.json");
|
|
65
|
+
/**
|
|
66
|
+
* Deja constancia en disco de que ALGUIEN va a gastar el refresh token.
|
|
67
|
+
*
|
|
68
|
+
* Supabase lo rota **al recibir la peticion**, asi que entre "sale la peticion"
|
|
69
|
+
* y "guardamos el token nuevo" hay una ventana en la que el token del disco ya
|
|
70
|
+
* esta muerto sin que nadie lo sepa. Si el proceso no sobrevive a esa ventana
|
|
71
|
+
* —el `analyze` post-commit corre desacoplado en segundo plano, y basta con
|
|
72
|
+
* apagar el equipo o matar la terminal— el siguiente arranque reenvia el viejo,
|
|
73
|
+
* Supabase lo trata como robo y cierra la sesion.
|
|
74
|
+
*
|
|
75
|
+
* Esta marca no PUEDE evitarlo: nada del lado del cliente sobrevive a un
|
|
76
|
+
* `kill -9`. Lo que evita es que sea inexplicable. Sin ella, el usuario ve
|
|
77
|
+
* "Invalid Refresh Token: Already Used" y no tiene forma de saber por que; con
|
|
78
|
+
* ella se le puede decir cuando se quedo a medias la renovacion.
|
|
79
|
+
*/
|
|
80
|
+
export function markRefreshInFlight(now = new Date()) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
83
|
+
fs.writeFileSync(INFLIGHT_FILE, JSON.stringify({ at: now.toISOString(), pid: process.pid }), { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Es un diagnostico, no una garantia: si no se puede escribir, seguimos.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function clearRefreshInFlight() {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(INFLIGHT_FILE, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// idem
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function readRefreshInFlight() {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(fs.readFileSync(INFLIGHT_FILE, "utf8"));
|
|
100
|
+
return typeof data.at === "string" ? data : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
64
106
|
const LOCK_FILE = path.join(DIR, "refresh.lock");
|
|
65
107
|
function sleep(ms) {
|
|
66
108
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
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).
|
|
@@ -46,12 +60,44 @@ export function usableSummary(message) {
|
|
|
46
60
|
return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
|
|
47
61
|
}
|
|
48
62
|
/** `git show <hash> -U0` — the unified diff of a single commit, no context. */
|
|
63
|
+
/**
|
|
64
|
+
* Ficheros que ESCRIBE el propio ChangeBook y que por tanto no puede analizar.
|
|
65
|
+
*
|
|
66
|
+
* `sync` regenera el bloque del mapa dentro de CLAUDE.md/AGENTS.md, y `analyze`
|
|
67
|
+
* lo dispara en cada commit. Sin excluirlos se cierra un bucle: commiteas el
|
|
68
|
+
* mapa regenerado -> el análisis lo ve -> nace/actualiza el módulo "Mapeo de
|
|
69
|
+
* módulos" -> pasa a ser el más reciente -> el mapa se reordena -> vuelve a
|
|
70
|
+
* estar sucio. Cuesta un diff de una línea cada dos por tres y el 2026-07-25
|
|
71
|
+
* costó un conflicto de merge real.
|
|
72
|
+
*
|
|
73
|
+
* No es una preferencia de ruido: una herramienta que se mide a sí misma
|
|
74
|
+
* inventa actividad que no existe. Los 6 "cambios" del módulo del mapa son
|
|
75
|
+
* exactamente eso.
|
|
76
|
+
*
|
|
77
|
+
* Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
|
|
78
|
+
* salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
|
|
79
|
+
* mapa no es un cambio de producto.
|
|
80
|
+
*/
|
|
81
|
+
export const FICHEROS_GENERADOS = [
|
|
82
|
+
":(exclude)CLAUDE.md",
|
|
83
|
+
":(exclude)AGENTS.md",
|
|
84
|
+
":(exclude)**/CLAUDE.md",
|
|
85
|
+
":(exclude)**/AGENTS.md",
|
|
86
|
+
];
|
|
49
87
|
export async function commitDiff(cwd, hash) {
|
|
50
88
|
try {
|
|
51
89
|
const { stdout } = await execFileAsync("git",
|
|
52
90
|
// --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
|
|
53
91
|
// as a revision, never as a git option (argument injection).
|
|
54
|
-
[
|
|
92
|
+
[
|
|
93
|
+
"show",
|
|
94
|
+
"--pretty=format:",
|
|
95
|
+
"-U0",
|
|
96
|
+
"--end-of-options",
|
|
97
|
+
hash,
|
|
98
|
+
"--",
|
|
99
|
+
...FICHEROS_GENERADOS,
|
|
100
|
+
], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
55
101
|
return stdout;
|
|
56
102
|
}
|
|
57
103
|
catch (error) {
|
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. */
|