changebook 0.4.5 → 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.
@@ -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/index.js CHANGED
@@ -20,6 +20,7 @@ import { runGuard } from "./guard.js";
20
20
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
21
21
  import { importHistory } from "./import.js";
22
22
  import { registerAgents } from "./init.js";
23
+ import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
23
24
  import { login } from "./login.js";
24
25
  import { AUTH_HELP, Supabase } from "./supabase.js";
25
26
  import { syncContextFiles } from "./sync.js";
@@ -45,6 +46,11 @@ Usage:
45
46
  changebook guard [dir] Check staged files against open atlas alerts
46
47
  (what the pre-commit hook runs; exit 3 = block)
47
48
  changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
49
+ changebook context [dir] Print the fresh atlas brief as a Claude Code
50
+ SessionStart hook payload (used by the push hook)
51
+ changebook hook-context install|uninstall|status [dir]
52
+ Push the atlas into EVERY Claude Code session at turn 0
53
+ via a SessionStart hook in .claude/settings.json
48
54
  changebook init [dir] login + register MCP in every agent found + hook + sync
49
55
  changebook open Open the web atlas in the browser
50
56
  changebook serve Run the MCP server on stdio (default with no arguments)
@@ -179,6 +185,34 @@ async function main() {
179
185
  await syncContextFiles(db, arg ?? process.cwd());
180
186
  return;
181
187
  }
188
+ case "context": {
189
+ // Runs on the SessionStart critical path: NEVER requireCredentials (it
190
+ // would exit 1), NEVER print help. printContext fails open — logged out,
191
+ // offline or slow all resolve to zero output and exit 0.
192
+ await printContext(new Supabase(), arg ?? process.cwd());
193
+ return;
194
+ }
195
+ case "hook-context": {
196
+ const dir = process.argv[4] ?? process.cwd();
197
+ if (arg === "install") {
198
+ const r = installContextHook(dir);
199
+ console.error(r === "installed"
200
+ ? `✓ SessionStart hook installed (${dir}/.claude/settings.json). Every Claude Code session now opens with the fresh atlas map.`
201
+ : "SessionStart hook already installed.");
202
+ }
203
+ else if (arg === "uninstall") {
204
+ const r = uninstallContextHook(dir);
205
+ console.error(r === "removed"
206
+ ? "✓ SessionStart hook removed."
207
+ : "No ChangeBook SessionStart hook found.");
208
+ }
209
+ else {
210
+ console.error(contextHookInstalled(dir)
211
+ ? `✓ ChangeBook SessionStart hook installed (${dir}/.claude/settings.json).`
212
+ : "✗ No ChangeBook SessionStart hook. Install with: changebook hook-context install");
213
+ }
214
+ return;
215
+ }
182
216
  case "init": {
183
217
  let db = new Supabase();
184
218
  if (!db.hasCredentials()) {
@@ -197,6 +231,14 @@ async function main() {
197
231
  console.error(error instanceof Error ? error.message : String(error));
198
232
  }
199
233
  await syncContextFiles(db, dir);
234
+ // Ofrecer el push, NUNCA instalarlo en silencio: .claude/settings.json se
235
+ // suele commitear y compartir, y meter un hook en el arranque de sesiones
236
+ // ajenas sin permiso explícito no se hace. Se ofrece el comando; correrlo
237
+ // ES el consentimiento.
238
+ if (!contextHookInstalled(dir)) {
239
+ console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
240
+ "(no tool call needed, never stale):\n changebook hook-context install");
241
+ }
200
242
  console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
201
243
  return;
202
244
  }
package/dist/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
- export async function syncContextFiles(db, targetDir, opts = {}) {
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 también aquí (QA 2026-07-18): sin filtro, una
59
- // cuenta con varios proyectos construiría el mapa de ESTE repo mezclando
60
- // los datos de todos. Y si el proyecto aún no existe en el atlas, el mapa
61
- // debe salir VACÍO — jamás el de otro proyecto.
65
+ // Frontera por proyecto (QA 2026-07-18): sin filtro, una cuenta con varios
66
+ // proyectos construiría el mapa de ESTE repo mezclando los datos de todos.
67
+ // Y si el proyecto aún no existe en el atlas, el mapa debe salir VACÍO.
62
68
  let projectFilter;
63
69
  let projectResolved = true;
64
70
  try {
65
- // strict: el respaldo de proyecto único (0.4.2) es para tools que
66
- // conversan con un agente; sync escribe este archivo en silencio y no
67
- // puede adivinar — si el nombre no casa, mapa vacío y punto.
68
71
  projectFilter = await db.projectFilterFor(projectName, { strict: true });
69
72
  }
70
73
  catch {
@@ -78,19 +81,10 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
78
81
  projectFilter),
79
82
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
80
83
  projectFilter),
81
- // AI-detected regression warnings: best-effort — an error (older schema,
82
- // RLS hiccup) must not block the sync of the rest of the map.
83
84
  db
84
- .rest(
85
- // resolved_at=is.null: a dismissed/auto-resolved alert inside the
86
- // window must not resurface in every agent session as urgent.
87
- `regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
85
+ .rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
88
86
  projectFilter)
89
87
  .catch(() => []),
90
- // Auto-remediación fase 2: los encargos pendientes entran en el bloque
91
- // para que CUALQUIER sesión arranque sabiéndolos y se ofrezca a atacarlos
92
- // (proponer, no ejecutar — la aprobación sigue siendo del humano).
93
- // Best-effort como las alertas.
94
88
  projectResolved && projectId
95
89
  ? db
96
90
  .callRpc('list_agent_tasks', {
@@ -101,6 +95,10 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
101
95
  : Promise.resolve([]),
102
96
  ]);
103
97
  const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
98
+ return { section, projectId, projectResolved };
99
+ }
100
+ export async function syncContextFiles(db, targetDir, opts = {}) {
101
+ const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
104
102
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
105
103
  const file = path.join(targetDir, name);
106
104
  const updated = await upsertSection(file, section, opts);
package/dist/tools.js CHANGED
@@ -138,6 +138,13 @@ export function commitLabel(hash, aliasesRaw) {
138
138
  const aliases = commitAliasesShort(aliasesRaw);
139
139
  return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
140
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
+ }
141
148
  // Consultation metering (never billing): each successful read leaves a row in
142
149
  // atlas_reads so the web can show "your agent consulted the atlas N times".
143
150
  // Best-effort and non-blocking — metering must never break or slow a read.
@@ -616,7 +623,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
616
623
  });
617
624
  server.registerTool("atlas_file_context", {
618
625
  title: "Context of the files you are about to edit",
619
- description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, how often they changed recently, and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
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).
620
627
 
621
628
  Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
622
629
 
@@ -624,7 +631,7 @@ Args:
624
631
  - files (required): 1-8 repo-relative paths.
625
632
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
626
633
 
627
- 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 }] }] }`,
628
635
  inputSchema: {
629
636
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
630
637
  .describe("Repo-relative paths you are about to edit"),
@@ -643,10 +650,18 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
643
650
  const pf = await db.projectFilterFor(project);
644
651
  const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
645
652
  const perFile = await Promise.all(paths.map(async (file) => {
646
- const rows = await db.rest(`change_module?select=module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
653
+ const rows = await db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
647
654
  pf);
648
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 = [];
649
661
  for (const row of rows) {
662
+ if (!changelogIds.includes(row.changelog_id)) {
663
+ changelogIds.push(row.changelog_id);
664
+ }
650
665
  const name = (row.module ?? "").trim();
651
666
  if (!name)
652
667
  continue;
@@ -663,11 +678,38 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
663
678
  });
664
679
  }
665
680
  }
666
- return { file, modules: [...byModule.values()] };
681
+ return { file, modules: [...byModule.values()], changelogIds };
667
682
  }));
668
683
  const moduleNames = [
669
684
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
670
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
+ }
671
713
  const [alerts, watched] = await Promise.all([
672
714
  moduleNames.length
673
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` +
@@ -752,6 +794,13 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
752
794
  ? ` (as of commit ${w.commit_hash.slice(0, 7)})`
753
795
  : ""));
754
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
+ }
755
804
  lines.push("");
756
805
  }
757
806
  if (!ancla)
@@ -761,7 +810,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
761
810
  // descripción lo prometía y solo viajaba en el texto (bug cazado por
762
811
  // el verificador adversarial del benchmark).
763
812
  const salida = toolResult(contextText, {
764
- files: perFile.map((f) => ({
813
+ files: perFile.map(({ changelogIds: _drop, ...f }) => ({
765
814
  ...f,
766
815
  open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
767
816
  module: m.module,
@@ -772,6 +821,8 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
772
821
  value: w.value,
773
822
  commit: w.commit_hash?.slice(0, 7) ?? null,
774
823
  })),
824
+ recent_commits: commitsByFile.get(f.file)?.commits ?? [],
825
+ recent_commits_more: commitsByFile.get(f.file)?.more ?? 0,
775
826
  })),
776
827
  });
777
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.5",
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",
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",
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.5",
18
+ "version": "0.4.6",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }