changebook 0.4.4 → 0.4.5

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/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
- ":!docs/",
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
@@ -131,7 +131,23 @@ async function main() {
131
131
  case "analyze": {
132
132
  const db = new Supabase();
133
133
  requireCredentials(db);
134
- await analyze(db, parseAnalyzeArgs(process.argv.slice(3)));
134
+ const options = parseAnalyzeArgs(process.argv.slice(3));
135
+ await analyze(db, options);
136
+ // El mapa de CLAUDE.md/AGENTS.md se regeneraba solo en `init`/`sync`,
137
+ // así que se congelaba el día que lo instalabas (estudio 2026-07-20: la
138
+ // causa nº 1 de que el agente desconfíe del atlas). analyze es el camino
139
+ // del hook post-commit, o sea CADA commit: refrescar aquí hace el mapa
140
+ // fresco por construcción, como watched_values. refreshOnly: jamás crea
141
+ // archivos ni resucita un bloque borrado. Best-effort: un sync caído no
142
+ // puede tumbar un análisis ya cobrado y registrado.
143
+ try {
144
+ await syncContextFiles(db, options.dir ?? process.cwd(), {
145
+ refreshOnly: true,
146
+ });
147
+ }
148
+ catch (error) {
149
+ console.error(`Map refresh failed (analysis itself succeeded): ${error instanceof Error ? error.message : String(error)}`);
150
+ }
135
151
  return;
136
152
  }
137
153
  case "import": {
package/dist/sync.js CHANGED
@@ -53,7 +53,7 @@ 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) {
56
+ export async function syncContextFiles(db, targetDir, opts = {}) {
57
57
  const projectName = projectNameFor(targetDir);
58
58
  // Frontera por proyecto también aquí (QA 2026-07-18): sin filtro, una
59
59
  // cuenta con varios proyectos construiría el mapa de ESTE repo mezclando
@@ -103,7 +103,7 @@ export async function syncContextFiles(db, targetDir) {
103
103
  const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
104
104
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
105
105
  const file = path.join(targetDir, name);
106
- const updated = await upsertSection(file, section);
106
+ const updated = await upsertSection(file, section, opts);
107
107
  console.error(`${updated} ${name}`);
108
108
  }
109
109
  // El sync ES una consulta del atlas — la más apalancada: el mapa que
@@ -159,15 +159,28 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
159
159
  // · cargar las tools diferidas de UNA vez (benchmark 2026-07-20: 3 de 7
160
160
  // llamadas del agente CON atlas eran ToolSearch cargando esquemas de
161
161
  // uno en uno — cada una relee ~45k tokens de contexto)
162
- 'Memoria del proyecto en ChangeBook. Oriéntate con UNA llamada a `atlas_project_brief`. Antes de tocar un archivo: `atlas_file_context` con su ruta. Tras cada commit: `atlas_record_change` con el diff, `commit_hash`, `committed_at` y un `summary` tuyo (abarata el análisis; reenviar lo ya registrado es gratis). Tools diferidas: una sola llamada a ToolSearch con todas, nunca de una en una.',
162
+ //
163
+ // La FORMA de las instrucciones es disparador → tool, no prosa. Medido en
164
+ // la tanda 8808ebe (adopcion por celda, 2026-07-21): la adopcion sigue al
165
+ // matching LITERAL entre la tarea y el disparador escrito — "vas a editar
166
+ // el archivo" casaba con el disparador de file_context y adopto 5/5; el
167
+ // generico de orientacion 3/5; "investiga la funcion X", sin disparador,
168
+ // 0/5. Un agente no infiere que una tool aplica: reconoce su tarea en el
169
+ // texto o no la usa. Por eso cada tool lleva delante la formulacion de
170
+ // tarea que debe capturar, y el ToolSearch va con el select completo
171
+ // listo para copiar (cargar esquemas de uno en uno costaba 3 turnos).
172
+ // El disparador de symbol_lookup se RETIRÓ el 2026-07-21 con medición en
173
+ // la mano (tanda 957372c): llevó la adopción de t3 de 0/5 a 3/5, pero los
174
+ // adoptantes gastaron 607k tok/9 turnos vs 254k/4 del brazo sin atlas —
175
+ // la tool solo cubre definiciones y el agente acababa pagando atlas Y
176
+ // grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
177
+ // hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
178
+ '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).',
179
+ '',
180
+ 'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
163
181
  '',
164
- ...(projectName
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.',
182
+ // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
183
+ `${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
184
  '',
172
185
  ];
173
186
  if (modules.length === 0) {
@@ -345,7 +358,7 @@ export function coChangePairs(rows) {
345
358
  return pairs.sort((x, y) => y.rate - x.rate).slice(0, MAX_COUPLINGS);
346
359
  }
347
360
  /** Exported for tests. */
348
- export async function upsertSection(file, section) {
361
+ export async function upsertSection(file, section, opts = {}) {
349
362
  let content = null;
350
363
  try {
351
364
  content = await readFile(file, 'utf8');
@@ -354,6 +367,8 @@ export async function upsertSection(file, section) {
354
367
  content = null;
355
368
  }
356
369
  if (content === null) {
370
+ if (opts.refreshOnly)
371
+ return 'skipped';
357
372
  await writeFile(file, section + '\n');
358
373
  return 'created';
359
374
  }
@@ -362,6 +377,14 @@ export async function upsertSection(file, section) {
362
377
  // nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
363
378
  const LEGACY_START = '<!-- appatlas:start -->';
364
379
  const LEGACY_END = '<!-- appatlas:end -->';
380
+ // Un archivo sin NINGÚN bloque (ni actual ni legado) en modo refreshOnly es
381
+ // una decisión del dueño, no un hueco que rellenar: borró el mapa y el
382
+ // refresco automático no puede volver a pegárselo en cada commit.
383
+ if (opts.refreshOnly &&
384
+ !(content.includes(START) && content.includes(END)) &&
385
+ !(content.includes(LEGACY_START) && content.includes(LEGACY_END))) {
386
+ return 'skipped';
387
+ }
365
388
  const legacyStart = content.indexOf(LEGACY_START);
366
389
  const legacyEnd = content.indexOf(LEGACY_END);
367
390
  if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
package/dist/tools.js CHANGED
@@ -121,6 +121,23 @@ 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
+ }
124
141
  // Consultation metering (never billing): each successful read leaves a row in
125
142
  // atlas_reads so the web can show "your agent consulted the atlas N times".
126
143
  // Best-effort and non-blocking — metering must never break or slow a read.
@@ -294,7 +311,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
294
311
  try {
295
312
  const t0 = Date.now();
296
313
  const pf = await db.projectFilterFor(project);
297
- let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash` +
314
+ let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
298
315
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
299
316
  pf;
300
317
  if (search) {
@@ -332,6 +349,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
332
349
  ...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
333
350
  diff_chars: r.diff_character_count ?? null,
334
351
  commit: r.commit_hash?.slice(0, 7) ?? null,
352
+ // Post-squash: el mismo cambio bajo otro hash (rama vs main). Si
353
+ // `commit` no existe en tu repo, uno de estos sí.
354
+ commit_aliases: commitAliasesShort(r.hash_aliases),
335
355
  files: filesByChange.get(r.id)?.files ?? [],
336
356
  files_more: filesByChange.get(r.id)?.more ?? 0,
337
357
  modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
@@ -350,7 +370,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
350
370
  const mods = c.modules
351
371
  .map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
352
372
  .join(", ");
353
- lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}` : ""} — ${c.business_impact}`);
373
+ lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""} — ${c.business_impact}`);
354
374
  if ((include_tech || search) && c.summary_tech)
355
375
  lines.push(`- Tech: ${c.summary_tech}`);
356
376
  if (mods)
@@ -499,7 +519,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
499
519
  };
500
520
  }
501
521
  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})`);
522
+ 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
523
  const logById = new Map(logs.map((l) => [l.id, l]));
504
524
  const changes = rows.map((r) => {
505
525
  let excerpt = null;
@@ -519,6 +539,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
519
539
  // El commit del que salió cada entrada: la nota deja de flotar en
520
540
  // el tiempo y se puede cuadrar contra git.
521
541
  commit: logById.get(r.changelog_id)?.commit_hash?.slice(0, 7) ?? null,
542
+ commit_aliases: commitAliasesShort(logById.get(r.changelog_id)?.hash_aliases),
522
543
  risk: r.risk,
523
544
  note: r.note,
524
545
  tech: r.tech,
@@ -530,12 +551,24 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
530
551
  });
531
552
  // Ancla temporal de la respuesta entera: el commit más nuevo servido,
532
553
  // comparado con el HEAD del árbol — solo si este árbol ES el proyecto
533
- // (misma puerta que la refutación de alertas).
534
- const ancla = cwdEsElProyecto(project)
535
- ? await derivaContraHead(process.cwd(), rows
536
- .map((r) => logById.get(r.changelog_id)?.commit_hash)
537
- .find(Boolean) ?? null)
538
- : null;
554
+ // (misma puerta que la refutación de alertas). Post-squash, el hash
555
+ // primario puede no existir en este árbol: se prueban sus aliases
556
+ // antes de rendirse.
557
+ let ancla = null;
558
+ if (cwdEsElProyecto(project)) {
559
+ const log = rows
560
+ .map((r) => logById.get(r.changelog_id))
561
+ .find((l) => l?.commit_hash);
562
+ const candidatos = [
563
+ log?.commit_hash ?? null,
564
+ ...commitAliasesShort(log?.hash_aliases),
565
+ ];
566
+ for (const c of candidatos) {
567
+ ancla = await derivaContraHead(process.cwd(), c);
568
+ if (ancla)
569
+ break;
570
+ }
571
+ }
539
572
  const latest = rows[0];
540
573
  const output = {
541
574
  module,
@@ -553,7 +586,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
553
586
  lines.push(ancla);
554
587
  lines.push("");
555
588
  for (const c of changes) {
556
- lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
589
+ lines.push(`## ${c.date}${c.commit ? ` · commit ${c.commit}${c.commit_aliases.length > 0 ? ` (=${c.commit_aliases.join(",")})` : ""}` : ""}${c.risk ? ` — risk: ${c.risk}` : ""}`);
557
590
  if (c.business_impact)
558
591
  lines.push(`- Impact: ${c.business_impact}`);
559
592
  if (c.note)
@@ -678,10 +711,20 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
678
711
  let ancla = null;
679
712
  if (cwdEsElProyecto(project)) {
680
713
  const ultimo = await db
681
- .rest(`changelog?select=commit_hash&commit_hash=not.is.null&order=created_at.desc&limit=1` +
714
+ .rest(`changelog?select=commit_hash,hash_aliases&commit_hash=not.is.null&order=created_at.desc&limit=1` +
682
715
  pf)
683
716
  .catch(() => []);
684
- ancla = await derivaContraHead(process.cwd(), ultimo[0]?.commit_hash ?? null);
717
+ // Post-squash: si el hash primario no existe en este árbol (era el
718
+ // de la rama), sus aliases sí pueden — probar antes de rendirse.
719
+ const candidatos = [
720
+ ultimo[0]?.commit_hash ?? null,
721
+ ...commitAliasesShort(ultimo[0]?.hash_aliases),
722
+ ];
723
+ for (const c of candidatos) {
724
+ ancla = await derivaContraHead(process.cwd(), c);
725
+ if (ancla)
726
+ break;
727
+ }
685
728
  }
686
729
  const lines = [`# File context (${paths.length} file(s))`];
687
730
  if (ancla)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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.4",
5
+ "version": "0.4.5",
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.4",
18
+ "version": "0.4.5",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }