changebook 0.4.4 → 0.4.6
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/guard.js +5 -1
- package/dist/index.js +59 -1
- package/dist/sync.js +49 -28
- package/dist/tools.js +111 -17
- package/package.json +2 -2
- 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/guard.js
CHANGED
|
@@ -181,7 +181,11 @@ export function contarEnRepo(dir, simbolo) {
|
|
|
181
181
|
// aviso de tipo "esto sigue usandose" encontraria su propia cita y no
|
|
182
182
|
// podria refutarse jamas. Lo cazo el test, no el diseno.
|
|
183
183
|
":!*.md",
|
|
184
|
-
"
|
|
184
|
+
// Con comodín a propósito: un pathspec sin comodín ("docs/") hace
|
|
185
|
+
// abortar a git grep si la carpeta no existe — en cualquier repo de
|
|
186
|
+
// usuario sin docs/ el buscador devolvía null y la refutación moría
|
|
187
|
+
// en silencio. Lo cazó el fixture hermético del test (2026-07-21).
|
|
188
|
+
":!docs/**",
|
|
185
189
|
], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
|
|
186
190
|
// Una linea "fichero:N" por fichero con coincidencias.
|
|
187
191
|
return out
|
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)
|
|
@@ -131,7 +137,23 @@ async function main() {
|
|
|
131
137
|
case "analyze": {
|
|
132
138
|
const db = new Supabase();
|
|
133
139
|
requireCredentials(db);
|
|
134
|
-
|
|
140
|
+
const options = parseAnalyzeArgs(process.argv.slice(3));
|
|
141
|
+
await analyze(db, options);
|
|
142
|
+
// El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
|
|
143
|
+
// así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
|
|
144
|
+
// causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
|
|
145
|
+
// del hook post-commit, o sea CADA commit: refrescar aquí hace el mapa
|
|
146
|
+
// fresco por construcción, como watched_values. refreshOnly: jamás crea
|
|
147
|
+
// archivos ni resucita un bloque borrado. Best-effort: un sync caído no
|
|
148
|
+
// puede tumbar un análisis ya cobrado y registrado.
|
|
149
|
+
try {
|
|
150
|
+
await syncContextFiles(db, options.dir ?? process.cwd(), {
|
|
151
|
+
refreshOnly: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
}
|
|
135
157
|
return;
|
|
136
158
|
}
|
|
137
159
|
case "import": {
|
|
@@ -163,6 +185,34 @@ async function main() {
|
|
|
163
185
|
await syncContextFiles(db, arg ?? process.cwd());
|
|
164
186
|
return;
|
|
165
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
|
+
}
|
|
166
216
|
case "init": {
|
|
167
217
|
let db = new Supabase();
|
|
168
218
|
if (!db.hasCredentials()) {
|
|
@@ -181,6 +231,14 @@ async function main() {
|
|
|
181
231
|
console.error(error instanceof Error ? error.message : String(error));
|
|
182
232
|
}
|
|
183
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
|
+
}
|
|
184
242
|
console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
|
|
185
243
|
return;
|
|
186
244
|
}
|
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) {
|
|
|
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,9 +95,13 @@ export async function syncContextFiles(db, targetDir) {
|
|
|
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
|
-
const updated = await upsertSection(file, section);
|
|
104
|
+
const updated = await upsertSection(file, section, opts);
|
|
107
105
|
console.error(`${updated} ${name}`);
|
|
108
106
|
}
|
|
109
107
|
// El sync ES una consulta del atlas — la más apalancada: el mapa que
|
|
@@ -159,15 +157,28 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
159
157
|
// · cargar las tools diferidas de UNA vez (benchmark 2026-07-20: 3 de 7
|
|
160
158
|
// llamadas del agente CON atlas eran ToolSearch cargando esquemas de
|
|
161
159
|
// uno en uno — cada una relee ~45k tokens de contexto)
|
|
162
|
-
|
|
160
|
+
//
|
|
161
|
+
// La FORMA de las instrucciones es disparador → tool, no prosa. Medido en
|
|
162
|
+
// la tanda 8808ebe (adopcion por celda, 2026-07-21): la adopcion sigue al
|
|
163
|
+
// matching LITERAL entre la tarea y el disparador escrito — "vas a editar
|
|
164
|
+
// el archivo" casaba con el disparador de file_context y adopto 5/5; el
|
|
165
|
+
// generico de orientacion 3/5; "investiga la funcion X", sin disparador,
|
|
166
|
+
// 0/5. Un agente no infiere que una tool aplica: reconoce su tarea en el
|
|
167
|
+
// texto o no la usa. Por eso cada tool lleva delante la formulacion de
|
|
168
|
+
// tarea que debe capturar, y el ToolSearch va con el select completo
|
|
169
|
+
// listo para copiar (cargar esquemas de uno en uno costaba 3 turnos).
|
|
170
|
+
// El disparador de symbol_lookup se RETIRÓ el 2026-07-21 con medición en
|
|
171
|
+
// la mano (tanda 957372c): llevó la adopción de t3 de 0/5 a 3/5, pero los
|
|
172
|
+
// adoptantes gastaron 607k tok/9 turnos vs 254k/4 del brazo sin atlas —
|
|
173
|
+
// la tool solo cubre definiciones y el agente acababa pagando atlas Y
|
|
174
|
+
// grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
|
|
175
|
+
// hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
|
|
176
|
+
'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · editar un archivo → `atlas_file_context` con su ruta · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
|
|
177
|
+
'',
|
|
178
|
+
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
|
|
163
179
|
'',
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
`Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`: el atlas es por proyecto y los datos de otros no son de esta sesión.`,
|
|
167
|
-
'',
|
|
168
|
-
]
|
|
169
|
-
: []),
|
|
170
|
-
'Habla: anuncia en 2-3 líneas qué vas a hacer antes de atacar un encargo, y cuéntale al usuario cualquier riesgo que el atlas te enseñe — él no ve esos avisos.',
|
|
180
|
+
// Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
|
|
181
|
+
`${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Anuncia antes de atacar un encargo, y DILE al usuario los riesgos que el atlas te enseñe — él no los ve.`,
|
|
171
182
|
'',
|
|
172
183
|
];
|
|
173
184
|
if (modules.length === 0) {
|
|
@@ -345,7 +356,7 @@ export function coChangePairs(rows) {
|
|
|
345
356
|
return pairs.sort((x, y) => y.rate - x.rate).slice(0, MAX_COUPLINGS);
|
|
346
357
|
}
|
|
347
358
|
/** Exported for tests. */
|
|
348
|
-
export async function upsertSection(file, section) {
|
|
359
|
+
export async function upsertSection(file, section, opts = {}) {
|
|
349
360
|
let content = null;
|
|
350
361
|
try {
|
|
351
362
|
content = await readFile(file, 'utf8');
|
|
@@ -354,6 +365,8 @@ export async function upsertSection(file, section) {
|
|
|
354
365
|
content = null;
|
|
355
366
|
}
|
|
356
367
|
if (content === null) {
|
|
368
|
+
if (opts.refreshOnly)
|
|
369
|
+
return 'skipped';
|
|
357
370
|
await writeFile(file, section + '\n');
|
|
358
371
|
return 'created';
|
|
359
372
|
}
|
|
@@ -362,6 +375,14 @@ export async function upsertSection(file, section) {
|
|
|
362
375
|
// nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
|
|
363
376
|
const LEGACY_START = '<!-- appatlas:start -->';
|
|
364
377
|
const LEGACY_END = '<!-- appatlas:end -->';
|
|
378
|
+
// Un archivo sin NINGÚN bloque (ni actual ni legado) en modo refreshOnly es
|
|
379
|
+
// una decisión del dueño, no un hueco que rellenar: borró el mapa y el
|
|
380
|
+
// refresco automático no puede volver a pegárselo en cada commit.
|
|
381
|
+
if (opts.refreshOnly &&
|
|
382
|
+
!(content.includes(START) && content.includes(END)) &&
|
|
383
|
+
!(content.includes(LEGACY_START) && content.includes(LEGACY_END))) {
|
|
384
|
+
return 'skipped';
|
|
385
|
+
}
|
|
365
386
|
const legacyStart = content.indexOf(LEGACY_START);
|
|
366
387
|
const legacyEnd = content.indexOf(LEGACY_END);
|
|
367
388
|
if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
|
package/dist/tools.js
CHANGED
|
@@ -121,6 +121,30 @@ export function filesUnionByChange(rows, cap = FILES_CAP) {
|
|
|
121
121
|
}
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
|
+
// Espejo de supabase/functions/mcp/scope.ts (paridad en
|
|
125
|
+
// test/espejoDeCommits.test.ts): aliases post-squash del mismo contenido,
|
|
126
|
+
// para que el hash citado exista en el main del consultante.
|
|
127
|
+
export function commitAliasesShort(raw) {
|
|
128
|
+
if (!Array.isArray(raw))
|
|
129
|
+
return [];
|
|
130
|
+
return raw
|
|
131
|
+
.filter((h) => typeof h === "string" && /^[0-9a-f]{7,64}$/i.test(h))
|
|
132
|
+
.map((h) => h.slice(0, 7));
|
|
133
|
+
}
|
|
134
|
+
export function commitLabel(hash, aliasesRaw) {
|
|
135
|
+
const corto = hash?.slice(0, 7) ?? null;
|
|
136
|
+
if (!corto)
|
|
137
|
+
return null;
|
|
138
|
+
const aliases = commitAliasesShort(aliasesRaw);
|
|
139
|
+
return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
|
|
140
|
+
}
|
|
141
|
+
export const FILE_COMMITS_CAP = 5;
|
|
142
|
+
export function recentCommitsForFile(changelogIds, commitById, cap = FILE_COMMITS_CAP) {
|
|
143
|
+
const all = changelogIds
|
|
144
|
+
.map((id) => commitById.get(id))
|
|
145
|
+
.filter((c) => Boolean(c));
|
|
146
|
+
return { commits: all.slice(0, cap), more: Math.max(0, all.length - cap) };
|
|
147
|
+
}
|
|
124
148
|
// Consultation metering (never billing): each successful read leaves a row in
|
|
125
149
|
// atlas_reads so the web can show "your agent consulted the atlas N times".
|
|
126
150
|
// Best-effort and non-blocking — metering must never break or slow a read.
|
|
@@ -294,7 +318,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
294
318
|
try {
|
|
295
319
|
const t0 = Date.now();
|
|
296
320
|
const pf = await db.projectFilterFor(project);
|
|
297
|
-
let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash` +
|
|
321
|
+
let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
|
|
298
322
|
`&order=created_at.desc&limit=${limit}&offset=${offset}` +
|
|
299
323
|
pf;
|
|
300
324
|
if (search) {
|
|
@@ -332,6 +356,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
332
356
|
...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
|
|
333
357
|
diff_chars: r.diff_character_count ?? null,
|
|
334
358
|
commit: r.commit_hash?.slice(0, 7) ?? null,
|
|
359
|
+
// Post-squash: el mismo cambio bajo otro hash (rama vs main). Si
|
|
360
|
+
// `commit` no existe en tu repo, uno de estos sí.
|
|
361
|
+
commit_aliases: commitAliasesShort(r.hash_aliases),
|
|
335
362
|
files: filesByChange.get(r.id)?.files ?? [],
|
|
336
363
|
files_more: filesByChange.get(r.id)?.more ?? 0,
|
|
337
364
|
modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
|
|
@@ -350,7 +377,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
|
|
|
350
377
|
const mods = c.modules
|
|
351
378
|
.map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
|
|
352
379
|
.join(", ");
|
|
353
|
-
lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}` : ""} — ${c.business_impact}`);
|
|
380
|
+
lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""} — ${c.business_impact}`);
|
|
354
381
|
if ((include_tech || search) && c.summary_tech)
|
|
355
382
|
lines.push(`- Tech: ${c.summary_tech}`);
|
|
356
383
|
if (mods)
|
|
@@ -499,7 +526,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
499
526
|
};
|
|
500
527
|
}
|
|
501
528
|
const ids = [...new Set(rows.map((r) => r.changelog_id))].join(",");
|
|
502
|
-
const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash&id=in.(${ids})`);
|
|
529
|
+
const logs = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases&id=in.(${ids})`);
|
|
503
530
|
const logById = new Map(logs.map((l) => [l.id, l]));
|
|
504
531
|
const changes = rows.map((r) => {
|
|
505
532
|
let excerpt = null;
|
|
@@ -519,6 +546,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
519
546
|
// El commit del que salió cada entrada: la nota deja de flotar en
|
|
520
547
|
// el tiempo y se puede cuadrar contra git.
|
|
521
548
|
commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
|
|
549
|
+
commit_aliases: commitAliasesShort(logById.get(r.changelog_id)?.hash_aliases),
|
|
522
550
|
risk: r.risk,
|
|
523
551
|
note: r.note,
|
|
524
552
|
tech: r.tech,
|
|
@@ -530,12 +558,24 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
530
558
|
});
|
|
531
559
|
// Ancla temporal de la respuesta entera: el commit más nuevo servido,
|
|
532
560
|
// comparado con el HEAD del árbol — solo si este árbol ES el proyecto
|
|
533
|
-
// (misma puerta que la refutación de alertas).
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
561
|
+
// (misma puerta que la refutación de alertas). Post-squash, el hash
|
|
562
|
+
// primario puede no existir en este árbol: se prueban sus aliases
|
|
563
|
+
// antes de rendirse.
|
|
564
|
+
let ancla = null;
|
|
565
|
+
if (cwdEsElProyecto(project)) {
|
|
566
|
+
const log = rows
|
|
567
|
+
.map((r) => logById.get(r.changelog_id))
|
|
568
|
+
.find((l) => l?.commit_hash);
|
|
569
|
+
const candidatos = [
|
|
570
|
+
log?.commit_hash ?? null,
|
|
571
|
+
...commitAliasesShort(log?.hash_aliases),
|
|
572
|
+
];
|
|
573
|
+
for (const c of candidatos) {
|
|
574
|
+
ancla = await derivaContraHead(process.cwd(), c);
|
|
575
|
+
if (ancla)
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
539
579
|
const latest = rows[0];
|
|
540
580
|
const output = {
|
|
541
581
|
module,
|
|
@@ -553,7 +593,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
553
593
|
lines.push(ancla);
|
|
554
594
|
lines.push("");
|
|
555
595
|
for (const c of changes) {
|
|
556
|
-
lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
|
|
596
|
+
lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
|
|
557
597
|
if (c.business_impact)
|
|
558
598
|
lines.push(`- Impact: ${c.business_impact}`);
|
|
559
599
|
if (c.note)
|
|
@@ -583,7 +623,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
583
623
|
});
|
|
584
624
|
server.registerTool("atlas_file_context", {
|
|
585
625
|
title: "Context of the files you are about to edit",
|
|
586
|
-
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).
|
|
626
|
+
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).
|
|
587
627
|
|
|
588
628
|
Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
|
|
589
629
|
|
|
@@ -591,7 +631,7 @@ Args:
|
|
|
591
631
|
- files (required): 1-8 repo-relative paths.
|
|
592
632
|
- project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
|
|
593
633
|
|
|
594
|
-
Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, watched_values: [{ name, value, commit }] }] }`,
|
|
634
|
+
Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
|
|
595
635
|
inputSchema: {
|
|
596
636
|
files: z.array(z.string().min(1).max(300)).min(1).max(8)
|
|
597
637
|
.describe("Repo-relative paths you are about to edit"),
|
|
@@ -610,10 +650,18 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
610
650
|
const pf = await db.projectFilterFor(project);
|
|
611
651
|
const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
|
|
612
652
|
const perFile = await Promise.all(paths.map(async (file) => {
|
|
613
|
-
const rows = await db.rest(`change_module?select=module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
|
|
653
|
+
const rows = await db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
|
|
614
654
|
pf);
|
|
615
655
|
const byModule = new Map();
|
|
656
|
+
// Commits que tocaron ESTE archivo, del más nuevo al más viejo,
|
|
657
|
+
// deduplicados (un mismo análisis puede traer varias filas de
|
|
658
|
+
// change_module para el mismo archivo). El hash lo resuelve la
|
|
659
|
+
// consulta batcheada de abajo; aquí solo se guarda el orden.
|
|
660
|
+
const changelogIds = [];
|
|
616
661
|
for (const row of rows) {
|
|
662
|
+
if (!changelogIds.includes(row.changelog_id)) {
|
|
663
|
+
changelogIds.push(row.changelog_id);
|
|
664
|
+
}
|
|
617
665
|
const name = (row.module ?? "").trim();
|
|
618
666
|
if (!name)
|
|
619
667
|
continue;
|
|
@@ -630,11 +678,38 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
630
678
|
});
|
|
631
679
|
}
|
|
632
680
|
}
|
|
633
|
-
return { file, modules: [...byModule.values()] };
|
|
681
|
+
return { file, modules: [...byModule.values()], changelogIds };
|
|
634
682
|
}));
|
|
635
683
|
const moduleNames = [
|
|
636
684
|
...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
|
|
637
685
|
];
|
|
686
|
+
// Commits recientes por archivo (T2: el agente que va a editar dejaba
|
|
687
|
+
// de necesitar `git log -- <archivo>`). UNA consulta batcheada para
|
|
688
|
+
// TODOS los archivos, no una por archivo: es la tool que corre antes
|
|
689
|
+
// de cada edición. hash_aliases entra por el espejo post-squash — el
|
|
690
|
+
// hash servido tiene que existir en el main del consultante.
|
|
691
|
+
const allChangelogIds = [
|
|
692
|
+
...new Set(perFile.flatMap((f) => f.changelogIds)),
|
|
693
|
+
];
|
|
694
|
+
const commitById = new Map();
|
|
695
|
+
if (allChangelogIds.length > 0) {
|
|
696
|
+
const commitRows = await db
|
|
697
|
+
.rest(`changelog?select=id,commit_hash,created_at,hash_aliases&id=in.(${allChangelogIds.join(",")})`)
|
|
698
|
+
.catch(() => []);
|
|
699
|
+
for (const r of commitRows) {
|
|
700
|
+
if (!r.commit_hash)
|
|
701
|
+
continue;
|
|
702
|
+
commitById.set(r.id, {
|
|
703
|
+
commit: r.commit_hash.slice(0, 7),
|
|
704
|
+
commit_aliases: commitAliasesShort(r.hash_aliases),
|
|
705
|
+
date: day(r.created_at),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const commitsByFile = new Map();
|
|
710
|
+
for (const f of perFile) {
|
|
711
|
+
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
712
|
+
}
|
|
638
713
|
const [alerts, watched] = await Promise.all([
|
|
639
714
|
moduleNames.length
|
|
640
715
|
? 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` +
|
|
@@ -678,10 +753,20 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
678
753
|
let ancla = null;
|
|
679
754
|
if (cwdEsElProyecto(project)) {
|
|
680
755
|
const ultimo = await db
|
|
681
|
-
.rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
|
|
756
|
+
.rest(`changelog?select=commit_hash,hash_aliases&commit_hash=not.is.null&order=created_at.desc&limit=1` +
|
|
682
757
|
pf)
|
|
683
758
|
.catch(() => []);
|
|
684
|
-
|
|
759
|
+
// Post-squash: si el hash primario no existe en este árbol (era el
|
|
760
|
+
// de la rama), sus aliases sí pueden — probar antes de rendirse.
|
|
761
|
+
const candidatos = [
|
|
762
|
+
ultimo[0]?.commit_hash ?? null,
|
|
763
|
+
...commitAliasesShort(ultimo[0]?.hash_aliases),
|
|
764
|
+
];
|
|
765
|
+
for (const c of candidatos) {
|
|
766
|
+
ancla = await derivaContraHead(process.cwd(), c);
|
|
767
|
+
if (ancla)
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
685
770
|
}
|
|
686
771
|
const lines = [`# File context (${paths.length} file(s))`];
|
|
687
772
|
if (ancla)
|
|
@@ -709,6 +794,13 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
709
794
|
? ` (as of commit ${w.commit_hash.slice(0, 7)})`
|
|
710
795
|
: ""));
|
|
711
796
|
}
|
|
797
|
+
// Commits recientes que tocaron este archivo: cita estos hashes en
|
|
798
|
+
// vez de correr `git log -- <archivo>`.
|
|
799
|
+
const rc = commitsByFile.get(f.file);
|
|
800
|
+
if (rc && rc.commits.length > 0) {
|
|
801
|
+
const parts = rc.commits.map((c) => `${c.commit}${c.commit_aliases.length ? ` (=${c.commit_aliases.join(",")})` : ""} (${c.date})`);
|
|
802
|
+
lines.push(`- Recent commits: ${parts.join(", ")}${rc.more > 0 ? ` (+${rc.more} more)` : ""}`);
|
|
803
|
+
}
|
|
712
804
|
lines.push("");
|
|
713
805
|
}
|
|
714
806
|
if (!ancla)
|
|
@@ -718,7 +810,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
718
810
|
// descripción lo prometía y solo viajaba en el texto (bug cazado por
|
|
719
811
|
// el verificador adversarial del benchmark).
|
|
720
812
|
const salida = toolResult(contextText, {
|
|
721
|
-
files: perFile.map((f) => ({
|
|
813
|
+
files: perFile.map(({ changelogIds: _drop, ...f }) => ({
|
|
722
814
|
...f,
|
|
723
815
|
open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
|
|
724
816
|
module: m.module,
|
|
@@ -729,6 +821,8 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
729
821
|
value: w.value,
|
|
730
822
|
commit: w.commit_hash?.slice(0, 7) ?? null,
|
|
731
823
|
})),
|
|
824
|
+
recent_commits: commitsByFile.get(f.file)?.commits ?? [],
|
|
825
|
+
recent_commits_more: commitsByFile.get(f.file)?.more ?? 0,
|
|
732
826
|
})),
|
|
733
827
|
});
|
|
734
828
|
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.6",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"start": "node dist/index.js",
|
|
32
|
-
"build": "tsc",
|
|
32
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"clean": "rm -rf dist",
|
|
35
35
|
"prepublishOnly": "npm run build"
|
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.6",
|
|
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.6",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|