changebook 0.7.0 → 0.8.0

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/tools.js CHANGED
@@ -6,312 +6,19 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  import { execFileAsync, projectNameFor } from "./git.js";
9
- import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
10
- import { SupabaseError } from "./supabase.js";
9
+ import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, fueraDelCambio, fichaDelSimbolo, lineasEnRepo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
10
+ import { canonicalDiffHash } from "./canonical.js";
11
+ import { registrarActionPlan } from "./toolActionPlan.js";
12
+ import { registrarUsage } from "./toolUsage.js";
13
+ import { registrarProjectBrief } from "./toolProjectBrief.js";
11
14
  import { coChangePairs } from "./sync.js";
12
15
  import { aliasesFor, canonicalizeModuleRows, canonicalModule, etiquetaPorSlug, expandModuleNames, projectIdOf, slugModule, } from "./aliasDeModulo.js";
13
- const CHARACTER_LIMIT = 25_000;
14
- /**
15
- * El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
16
- * agente afirmó un valor revertido porque la nota hablaba en presente). El
17
- * ancla de deriva de derivaContraHead lo dice con precisión cuando hay árbol
18
- * y hash; esta línea fija cubre el resto de los casos — nunca las dos a la
19
- * vez.
20
- */
21
- const TEMPORAL_CONTRACT = "Notes describe the code AS OF their date — concrete values (numbers, limits, names) may have changed since. Verify in the code before asserting them.";
22
- // ── Helpers ───────────────────────────────────────────────────────────────────
23
- /**
24
- * Lo que de VERDAD llega al modelo. Espejo de servedCharsOf en
25
- * supabase/functions/mcp/index.ts.
26
- *
27
- * `chars_served` contaba el markdown, y el markdown casi nunca se usa: los
28
- * tool_result llegan al modelo como el JSON del structuredContent. Solo cuando
29
- * la respuesta revienta CHARACTER_LIMIT deja de haber JSON y queda el texto.
30
- * Contar el markdown infravaloraba el gasto un 35%.
31
- */
32
- function servedCharsOf(result) {
33
- return "structuredContent" in result && result.structuredContent
34
- ? JSON.stringify(result.structuredContent).length
35
- : (result.content?.[0]?.text?.length ?? 0);
36
- }
37
- function errorResult(error) {
38
- const message = error instanceof SupabaseError || error instanceof Error
39
- ? error.message
40
- : String(error);
41
- // isError lets the MCP client (and the agent) tell a failed call from a valid
42
- // result whose text happens to start with "Error:".
43
- return {
44
- isError: true,
45
- content: [{ type: "text", text: `Error: ${message}` }],
46
- };
47
- }
48
- function truncate(text) {
49
- if (text.length <= CHARACTER_LIMIT)
50
- return text;
51
- return (text.slice(0, CHARACTER_LIMIT) +
52
- "\n\n[Truncated. Use a smaller `limit`, an `offset`, or filters to narrow the result.]");
53
- }
54
- // Build a tool result. structuredContent duplicates the rendered text, so when
55
- // the text has to be truncated we drop it — otherwise the full, unbounded object
56
- // would be sent anyway and defeat the very CHARACTER_LIMIT that truncated it.
57
- function toolResult(text, structured) {
58
- if (text.length <= CHARACTER_LIMIT) {
59
- return {
60
- content: [{ type: "text", text }],
61
- structuredContent: structured,
62
- };
63
- }
64
- return { content: [{ type: "text", text: truncate(text) }] };
65
- }
66
- function fileList(files) {
67
- return Array.isArray(files) ? files.map(String) : [];
68
- }
69
- // Summary-first excerpts: the stored diff excerpts are the most expensive part
70
- // of atlas_module_detail (~1.5k chars each), and most calls only need to know
71
- // WHAT changed. Default to a short preview; `full: true` returns them verbatim.
72
- const EXCERPT_PREVIEW_LINES = 4;
73
- const EXCERPT_PREVIEW_CHARS = 320;
74
- function previewExcerpt(excerpt) {
75
- let text = excerpt.split("\n").slice(0, EXCERPT_PREVIEW_LINES).join("\n");
76
- if (text.length > EXCERPT_PREVIEW_CHARS) {
77
- text = text.slice(0, EXCERPT_PREVIEW_CHARS);
78
- }
79
- return { text: text.trimEnd(), truncated: text.length < excerpt.length };
80
- }
81
- function day(iso) {
82
- return iso.slice(0, 10);
83
- }
84
- // Pre-edit lookup helpers (mirror of the hosted scope.ts — the npm package
85
- // must stay self-contained, so these three stay tiny and duplicated).
86
- // Exported so test/mcpParity.test.ts can pin them equal to the hosted copies:
87
- // drift would break atlas_file_context on stdio silently (audit M7).
88
- export function normalizeRepoPath(path) {
89
- return path.trim().replace(/^\.\//, "").replace(/^\/+/, "");
90
- }
91
- export function fileContainsFilter(path) {
92
- return `files=cs.${encodeURIComponent(JSON.stringify([path]))}`;
93
- }
94
- export function quotedInList(values) {
95
- return values
96
- .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
97
- .join(",");
98
- }
99
- export const FILES_CAP = 8;
100
- const MAX_DEPENDENTS = 6;
101
- /** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
102
- * El mapa de la web dibuja las flechas sobre esta misma ventana: dos
103
- * ventanas distintas darían dependencias distintas según se mire el dibujo
104
- * o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
105
- const MODULE_GRAPH_WINDOW_ROWS = 1000;
106
- export function dependentsOf(targets, rows) {
107
- // POR SLUG, NO POR toLowerCase(): media normalizacion se lee igual que una
108
- // entera (invariante 16). Espejo de supabase/functions/mcp/scope.ts.
109
- const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
110
- // Memo de slugModule: se llama una vez por fila y por dep. Espejo de
111
- // supabase/functions/mcp/scope.ts (medido: 0,510 ms -> 2,698 ms sin esto).
112
- const memo = new Map();
113
- const slug = (s) => {
114
- let v = memo.get(s);
115
- if (v === undefined) {
116
- v = slugModule(s);
117
- memo.set(s, v);
118
- }
119
- return v;
120
- };
121
- const graph = new Map();
122
- for (const r of rows) {
123
- const label = (r.module ?? "").trim();
124
- if (!label || graph.has(slug(label)))
125
- continue;
126
- const deps = Array.isArray(r.deps)
127
- ? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
128
- : [];
129
- graph.set(slug(label), deps);
130
- }
131
- const reverse = new Map();
132
- for (const r of rows) {
133
- const crudo = (r.module ?? "").trim();
134
- if (!crudo)
135
- continue;
136
- const slugFrom = slug(crudo);
137
- const from = etiqueta.get(slugFrom) ?? crudo;
138
- for (const to of graph.get(slugFrom) ?? []) {
139
- const key = slug(to);
140
- if (key === slugFrom)
141
- continue;
142
- const set = reverse.get(key) ?? new Set();
143
- set.add(from);
144
- reverse.set(key, set);
145
- }
146
- }
147
- const out = new Map();
148
- for (const t of targets) {
149
- const label = (t ?? "").trim();
150
- if (!label)
151
- continue;
152
- const found = reverse.get(slug(label));
153
- if (!found || found.size === 0)
154
- continue;
155
- out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
156
- }
157
- return out;
158
- }
159
- /**
160
- * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
161
- * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
162
- * regresión DISTINTOS (count(distinct plain), all-time), solo los con
163
- * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
164
- * infle. No se refuta: cuenta historia, no vigencia.
165
- */
166
- /**
167
- * ESPEJO de `RecidivismMap` en supabase/functions/mcp/scope.ts. Un Map de
168
- * verdad —se itera y se muestra con la etiqueta buena— cuyo `get`/`has`
169
- * normalizan la clave por slug antes de buscar, para que ningún consumidor
170
- * tenga que acordarse de canonicalizar. Ver allí el razonamiento entero.
171
- */
172
- export class RecidivismMap extends Map {
173
- #porSlug = new Map();
174
- set(clave, valor) {
175
- this.#porSlug.set(slugModule(clave), valor);
176
- return super.set(clave, valor);
177
- }
178
- get(clave) {
179
- return super.get(clave) ?? this.#porSlug.get(slugModule(clave));
180
- }
181
- has(clave) {
182
- return super.has(clave) || this.#porSlug.has(slugModule(clave));
183
- }
184
- }
185
- export function computeRecidivism(rows) {
186
- // Agrupado por slug y presentado con la etiqueta representante: si las
187
- // alertas de un módulo partido están escritas con las dos grafías, sus
188
- // regresiones distintas son las de UN módulo, no las de dos.
189
- const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
190
- const plainsByModule = new Map();
191
- for (const r of rows) {
192
- // SOLO LAS CONFIRMADAS. Hasta el 2026-08-02 esto contaba TODA fila de
193
- // `regression_alerts`, o sea avisos que el propio atlas se generó: sumaba
194
- // los confirmados, los que el dueño refutó, los que se cerraron solos al
195
- // re-tocar el módulo y los 79 que son anteriores al campo `resolution`.
196
- // Medido en producción: decía 110 y las confirmadas eran 20 — «Análisis de
197
- // cambios» salía con 13 y tenía CERO.
198
- //
199
- // La migración que añadió `resolution` (20260725100000) dice en su propio
200
- // comentario que existe para impedir «el número bonito y falso». Este
201
- // contador nunca lo adoptó. Ahora sí.
202
- //
203
- // `fixed` lo escribe el agente que actuó sobre el aviso, no un oráculo
204
- // independiente, así que sigue siendo autocertificado — pero es la mejor
205
- // etiqueta que hay, y desde luego mejor que no mirar.
206
- if ((r.resolution ?? "").trim().toLowerCase() !== "fixed")
207
- continue;
208
- const crudo = (r.module ?? "").trim();
209
- const p = (r.plain ?? "").trim();
210
- if (!crudo || !p)
211
- continue;
212
- const m = etiqueta.get(slugModule(crudo)) ?? crudo;
213
- const set = plainsByModule.get(m) ?? new Set();
214
- set.add(p);
215
- plainsByModule.set(m, set);
216
- }
217
- const out = new RecidivismMap();
218
- for (const [m, set] of plainsByModule) {
219
- if (set.size >= 2)
220
- out.set(m, set.size);
221
- }
222
- return out;
223
- }
224
- export function filesUnionByChange(rows, cap = FILES_CAP) {
225
- const acc = new Map();
226
- for (const r of rows) {
227
- if (!Array.isArray(r.files))
228
- continue;
229
- let list = acc.get(r.changelog_id);
230
- if (!list) {
231
- list = [];
232
- acc.set(r.changelog_id, list);
233
- }
234
- for (const f of r.files) {
235
- if (typeof f === "string" && f.length > 0 && !list.includes(f)) {
236
- list.push(f);
237
- }
238
- }
239
- }
240
- const out = new Map();
241
- for (const [id, list] of acc) {
242
- out.set(id, {
243
- files: list.slice(0, cap),
244
- more: Math.max(0, list.length - cap),
245
- });
246
- }
247
- return out;
248
- }
249
- // Espejo de supabase/functions/mcp/scope.ts (paridad en
250
- // test/espejoDeCommits.test.ts): aliases post-squash del mismo contenido,
251
- // para que el hash citado exista en el main del consultante.
252
- export function commitAliasesShort(raw) {
253
- if (!Array.isArray(raw))
254
- return [];
255
- return raw
256
- .filter((h) => typeof h === "string" && /^[0-9a-f]{7,64}$/i.test(h))
257
- .map((h) => h.slice(0, 7));
258
- }
259
- export function commitLabel(hash, aliasesRaw) {
260
- const corto = hash?.slice(0, 7) ?? null;
261
- if (!corto)
262
- return null;
263
- const aliases = commitAliasesShort(aliasesRaw);
264
- return aliases.length > 0 ? `${corto} (=${aliases.join(",")})` : corto;
265
- }
266
- export const FILE_COMMITS_CAP = 5;
267
- export function recentCommitsForFile(changelogIds, commitById, cap = FILE_COMMITS_CAP) {
268
- const all = changelogIds
269
- .map((id) => commitById.get(id))
270
- .filter((c) => Boolean(c));
271
- return { commits: all.slice(0, cap), more: Math.max(0, all.length - cap) };
272
- }
273
- // Consultation metering (never billing): each successful read leaves a row in
274
- // atlas_reads so the web can show "your agent consulted the atlas N times".
275
- // Best-effort and non-blocking — metering must never break or slow a read.
276
- // user_id is filled server-side (column default auth.uid()).
277
- function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
278
- const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
279
- void db
280
- .insertRow("atlas_reads", {
281
- project_id: projectId,
282
- tool,
283
- source: "stdio",
284
- chars_served: charsServed,
285
- // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
286
- // metering, jamás contrato, y un servidor viejo simplemente no la manda.
287
- ...(latencyMs !== undefined
288
- ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
289
- : {}),
290
- })
291
- .catch(() => { });
292
- }
293
- /** PostgREST `or=(...ilike...)` needs the pattern URL-encoded once. */
294
- function ilikePattern(search) {
295
- return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
296
- }
297
- /**
298
- * ¿El árbol de trabajo donde corre este servidor ES el proyecto consultado?
299
- *
300
- * Solo entonces un grep local puede refutar una alerta: la tool recibe
301
- * `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
302
- * En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
303
- * avisoRefutado con expect='present' lo tomaría como refutación — silenciar
304
- * una alerta verdadera por mirar donde no era es peor que el ruido.
305
- *
306
- * La identidad sale de `projectNameFor`, la MISMA que usan el guardián, el hook
307
- * y sync: `CHANGEBOOK_PROJECT`, si no el nombre del árbol PRINCIPAL, y solo como
308
- * último recurso el basename del cwd. Usar `path.basename(cwd)` a secas era el
309
- * fallo: en un worktree enlazado el basename es el del worktree (`seo-wt`,
310
- * `wt-3`…), NO el del repo, así que el grep de refutación y el ancla temporal se
311
- * apagaban en todo worktree aunque fuera el mismo proyecto. `projectNameFor` ya
312
- * resolvió esto para el resto del CLI el 2026-07-25; este gate se había quedado
313
- * atrás.
314
- */
16
+ import { RO, TEMPORAL_CONTRACT, day, errorResult, fileList, ilikePattern, previewExcerpt, servedCharsOf, toolResult, } from "./respuestas.js";
17
+ import { MODULE_GRAPH_WINDOW_ROWS, aggregateFileContext, commitAliasesShort, computeRecidivism, dependentsOf, fileContainsFilter, filesUnionByChange, normalizeRepoPath, projectIdFromFilter, quotedInList, recentCommitsForFile, recordRead, resolveAlertFilter, } from "./agregados.js";
18
+ // Los agregados puros viven en `agregados.ts` desde el 09/08 (ver su cabecera).
19
+ // Se RE-EXPORTAN aqui porque `tools.ts` sigue siendo la superficie publica que
20
+ // importan los contratos: mover el codigo no tiene por que mover sus imports.
21
+ export * from "./agregados.js";
315
22
  function cwdEsElProyecto(project) {
316
23
  return (slugifyProject(projectNameFor(process.cwd())) ===
317
24
  slugifyProject(project.trim()));
@@ -343,6 +50,12 @@ export async function derivaContraHead(dir, hash) {
343
50
  }
344
51
  // ── Registration ──────────────────────────────────────────────────────────────
345
52
  export function registerTools(server, db) {
53
+ // El brief vive en su propio fichero (son 300 lineas y `tools.ts` ya iba
54
+ // por 1.900). Se registra desde aqui para que `registerTools` siga siendo
55
+ // el UNICO sitio donde se decide que herramientas sirve este servidor.
56
+ registrarProjectBrief(server, db);
57
+ registrarActionPlan(server, db);
58
+ registrarUsage(server, db);
346
59
  server.registerTool("atlas_recent_changes", {
347
60
  title: "Recent ChangeBook changes",
348
61
  description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
@@ -766,33 +479,13 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
766
479
  // canonicalizar después dejaría el mismo módulo partido en dos
767
480
  // entradas con la mitad de los cambios cada una.
768
481
  const rows = canonicalizeModuleRows(rowsCrudas, aliases);
769
- const byModule = new Map();
770
- // Commits que tocaron ESTE archivo, del más nuevo al más viejo,
771
- // deduplicados (un mismo análisis puede traer varias filas de
772
- // change_module para el mismo archivo). El hash lo resuelve la
773
- // consulta batcheada de abajo; aquí solo se guarda el orden.
774
- const changelogIds = [];
775
- for (const row of rows) {
776
- if (!changelogIds.includes(row.changelog_id)) {
777
- changelogIds.push(row.changelog_id);
778
- }
779
- const name = (row.module ?? "").trim();
780
- if (!name)
781
- continue;
782
- const existing = byModule.get(name);
783
- if (existing)
784
- existing.changes += 1;
785
- else {
786
- byModule.set(name, {
787
- module: name,
788
- risk: row.risk,
789
- changes: 1,
790
- last_changed: day(row.created_at),
791
- last_note: row.note,
792
- });
793
- }
794
- }
795
- return { file, modules: [...byModule.values()], changelogIds };
482
+ // ESTE BUCLE VIVÍA AQUÍ ESCRITO A MANO y agrupaba por nombre crudo,
483
+ // así que `Núcleo` y `Nucleo` salían como dos módulos distintos del
484
+ // mismo fichero, con la mitad de los cambios cada uno. Ahora lo hace
485
+ // `aggregateFileContext`, que agrupa por slug igual que el
486
+ // hospedado y que el brief. Los `changelogIds` (orden de commits,
487
+ // del más nuevo al más viejo, deduplicados) salen de ahí también.
488
+ return aggregateFileContext(file, rows);
796
489
  }));
797
490
  // Los nombres YA canonicalizados: es lo que se enseña y con lo que se
798
491
  // agrupa. Pero las consultas de abajo filtran EN EL SERVIDOR por
@@ -837,7 +530,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
837
530
  // "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
838
531
  // HERE, que es la única forma de que un aviso sobre producción
839
532
  // no se compruebe con un grep del repo. Ver guard.ts.
840
- `regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=10` +
533
+ `regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line,evidence_in_diff&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=10` +
841
534
  pf)
842
535
  : Promise.resolve([]),
843
536
  // Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
@@ -928,6 +621,11 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
928
621
  const localizada = enElProyecto
929
622
  ? (a) => dondeApareceElSimbolo(a, (s) => ficherosEnRepo(process.cwd(), s))
930
623
  : () => null;
624
+ // Mismo gate `enElProyecto` que la de arriba, y por lo mismo: en un repo
625
+ // ajeno el grep contestaría sobre un árbol que no es el del aviso.
626
+ const fichada = enElProyecto
627
+ ? (a) => fichaDelSimbolo(a, (s) => lineasEnRepo(process.cwd(), s))
628
+ : () => null;
931
629
  // Se construye la lista ANTES de renderizar para poder dejar una linea
932
630
  // por texto: dos avisos con la misma frase se leen como una repeticion
933
631
  // aunque por dentro sean afirmaciones opuestas, y sobre dos frases
@@ -947,6 +645,19 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
947
645
  `\n → CHECKED NOW: "${(a.evidence_symbol ?? "").trim()}" still appears in ${donde.join(", ")}` +
948
646
  ` — alert expects it gone: either a reference was missed, or the alert is stale`;
949
647
  }
648
+ // ANTES que la ficha, y el orden es la mitad del mensaje: primero que
649
+ // el modelo habló de código que no vio, después dónde vive ese código
650
+ // de verdad. Al revés se lee como un dato de localización suelto.
651
+ const fuera2 = fueraDelCambio(a);
652
+ if (fuera2)
653
+ extra += `\n → NOT IN THE DIFF: ${fuera2}`;
654
+ // La otra mitad, y la que cubre el caso DOMINANTE: un aviso 'present'
655
+ // cuyo símbolo sigue ahí (el 81% de los de FacelessOS) no se puede
656
+ // refutar con un conteo, pero sí se puede CONTESTAR diciendo dónde
657
+ // vive y si se exporta. Ver `fichaDelSimbolo`.
658
+ const ficha = fichada(a);
659
+ if (ficha)
660
+ extra += `\n → CHECKED NOW: ${ficha}`;
950
661
  // El repo no puede contestar esta afirmacion: se dice, en vez de
951
662
  // dejar que se compruebe donde no era.
952
663
  if (fuera)
@@ -1085,5 +796,294 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
1085
796
  return errorResult(error);
1086
797
  }
1087
798
  });
799
+ // ── atlas_resolve_alert ─────────────────────────────────────────────────────
800
+ //
801
+ // PORTADA DEL HOSPEDADO EL 08/08. Existía allí desde el 24/07 (`5237eb1`) y
802
+ // nunca llegó aquí, mientras `sync.ts` —de este mismo paquete— escribía en el
803
+ // bloque de CLAUDE.md «al comprobar o arreglar un aviso: `atlas_resolve_alert`».
804
+ // Quien instalaba por npm leía la orden y no tenía con qué cumplirla: ni esta
805
+ // tool ni subcomando de CLI equivalente. Resultado: los avisos solo podían
806
+ // quedarse abiertos, y una cuenta sin refutaciones se lee igual que una cuenta
807
+ // cuyos avisos eran todos buenos (invariante 17).
808
+ //
809
+ // La descripción y el esquema son los del hospedado a propósito, incluido el
810
+ // `verdict` SIN DEFECTO: cerrar sin juzgar contaría como acierto del generador
811
+ // e inflaría la precisión que el producto publica.
812
+ server.registerTool("atlas_resolve_alert", {
813
+ title: "Resolve a regression alert",
814
+ description: "Close this project's open regression alert(s) once you have judged them, so the analysis stops re-raising them and stops re-queuing the owner's task. Call it with `verdict:\"fixed\"` when the alert was RIGHT and you fixed or confirmed the risk, and with `verdict:\"dismissed\"` when you CHECKED IT AND IT WAS WRONG — a false alarm, already handled, or about code that is deliberately that way. Refuting is as valuable as confirming: the accuracy this product reports needs both, and an alert you silently confirm to get rid of it makes that number a lie. Identify them by `module` (closes every open alert on that module) and optionally narrow with `symbol` (the evidence symbol the alert hangs on). Judge only what you actually looked at; it is reversible from the web. " +
815
+ "project: the repo you are working in (folder name or slug).",
816
+ inputSchema: {
817
+ project: z.string().min(1).max(120),
818
+ module: z.string().min(1).max(200),
819
+ symbol: z.string().min(1).max(120).optional(),
820
+ verdict: z
821
+ .enum(["fixed", "dismissed"])
822
+ .describe("REQUIRED. fixed = the alert was right and you fixed or confirmed it. " +
823
+ "dismissed = you checked it and it was wrong (false alarm, already " +
824
+ "handled, or deliberate). There is no default on purpose: closing an " +
825
+ "alert without judging it would count as the generator being right " +
826
+ "and inflate the accuracy this product publishes."),
827
+ },
828
+ annotations: {
829
+ readOnlyHint: false,
830
+ destructiveHint: false,
831
+ idempotentHint: true,
832
+ openWorldHint: true,
833
+ },
834
+ }, async ({ project, module, symbol, verdict }) => {
835
+ try {
836
+ const pf = await db.projectFilterFor(project);
837
+ const projectId = projectIdFromFilter(pf);
838
+ if (!projectId) {
839
+ // Sin uuid no se construye un filtro acotado, y un PATCH mal acotado
840
+ // sobre esta tabla es justo lo que el grant por columna existe para
841
+ // que no pase. Se para antes de escribir.
842
+ return toolResult(`Could not resolve which project "${project}" is. Pass the repo folder name or its ChangeBook slug.`, { resolved: 0 });
843
+ }
844
+ const filtro = resolveAlertFilter({ projectId, module, symbol });
845
+ // SE LEE ANTES DE ESCRIBIR, y no por cortesía. Es lo único que permite
846
+ // distinguir después «no había nada abierto» de «no me dejaron
847
+ // escribir»: las dos cosas terminan en cero filas escritas y son
848
+ // diagnósticos opuestos. El 25/07 esa confusión dejó tres caminos
849
+ // fallando en silencio con 42501 durante días.
850
+ const abiertas = await db.rest(`regression_alerts?select=id,plain&${filtro}&order=created_at.desc&limit=50`);
851
+ if (abiertas.length === 0) {
852
+ return toolResult(`No open alert on "${module}"${symbol ? ` with symbol "${symbol}"` : ""}. Nothing to resolve.`, { resolved: 0, module, symbol: symbol ?? null, items: [] });
853
+ }
854
+ const escritas = await db.patchRowsReturning("regression_alerts", `${filtro}&id=in.(${abiertas.map((a) => a.id).join(",")})`, {
855
+ resolved_at: new Date().toISOString(),
856
+ resolution: verdict,
857
+ // QUIÉN lo dicta. El agente tiene interés en cerrar —el aviso le
858
+ // quita el encargo de encima— así que su juicio no puede contarse
859
+ // junto al de una persona ni al del grep. Ver la migración
860
+ // 20260802120000, que además es la que mete esta columna en el grant.
861
+ resolution_by: "agent",
862
+ });
863
+ if (escritas.length === 0) {
864
+ // Había filas abiertas y no se escribió ninguna: eso NO es "nada que
865
+ // cerrar", es una escritura rechazada. Se devuelve como error para
866
+ // que el agente lo vea, en vez de un {resolved: 0} tranquilizador.
867
+ return errorResult(new Error(`Found ${abiertas.length} open alert(s) on "${module}" but the update wrote none. ` +
868
+ `This is a refused write, not an empty match — most likely the column grant on ` +
869
+ `regression_alerts (resolved_at, resolution, resolution_by) or an RLS policy. ` +
870
+ `Nothing was closed; resolve it from the web and check the grant.`));
871
+ }
872
+ const parcial = escritas.length < abiertas.length
873
+ ? ` (${abiertas.length - escritas.length} of ${abiertas.length} could not be written — check the column grant)`
874
+ : "";
875
+ return toolResult(`${verdict === "dismissed" ? "Dismissed" : "Confirmed and closed"} ${escritas.length} alert(s) on "${module}"${symbol ? ` (symbol ${symbol})` : ""} — recorded as ${verdict}${parcial}. They will stop being re-raised, and the owner's auto-task for them will stop re-queuing.`, {
876
+ resolved: escritas.length,
877
+ matched: abiertas.length,
878
+ module,
879
+ symbol: symbol ?? null,
880
+ items: escritas.map((r) => r.plain),
881
+ });
882
+ }
883
+ catch (error) {
884
+ return errorResult(error);
885
+ }
886
+ });
887
+ // ── atlas_record_change ─────────────────────────────────────────────────────
888
+ //
889
+ // PORTADA DEL HOSPEDADO EL 09/08. Existía allí desde el 16/07 (`1832a13`) y el
890
+ // bloque de CLAUDE.md la pide después de cada commit, pero aquí no estaba.
891
+ //
892
+ // ⚠ EN UNA INSTALACIÓN CON HOOK ESTO ES REDUNDANTE, y conviene decirlo en vez
893
+ // de venderlo: el `post-commit` ya registra cada commit solo, leyendo el diff
894
+ // de git, y ese camino NO puede descasar del hash. Esta tool existe para el
895
+ // caso sin hook y para cambios sin commit. La descripción del hospedado se
896
+ // mantiene palabra por palabra —los dos servidores tienen que pedir lo mismo—
897
+ // pero el `summary` importa aquí igual: enruta el análisis a un modelo mucho
898
+ // más barato.
899
+ const MAX_TOOL_DIFF = 60_000; // espejo de MAX_DIFF_CHARACTERS del servidor
900
+ server.registerTool("atlas_record_change", {
901
+ title: "Record a change in the atlas",
902
+ description: "Call this right AFTER you make or commit a change, so the atlas stays current. Send the unified diff (e.g. `git show <ref>` or `git diff`) plus the commit hash and ISO date when available. ChangeBook's own AI analyzes it (business impact, modules, risk) — you only deliver the diff. ALWAYS pass `project` = the repo folder name you are working in (the atlas is per-project; a new name creates the project). ALWAYS include `summary`: 2-5 sentences in your own words on what changed and why (intent, user-visible effect, risks) — you already know this, and it routes the analysis to a much cheaper model. Re-sending the same commit is a free no-op.",
903
+ inputSchema: {
904
+ diff: z.string().min(20).max(MAX_TOOL_DIFF),
905
+ commit_hash: z
906
+ .string()
907
+ .regex(/^[0-9a-f]{7,64}$/i)
908
+ .optional(),
909
+ committed_at: z.string().datetime({ offset: true }).optional(),
910
+ project: z.string().min(1).max(120),
911
+ summary: z.string().min(20).max(2_400).optional(),
912
+ },
913
+ annotations: {
914
+ readOnlyHint: false,
915
+ destructiveHint: false,
916
+ idempotentHint: true,
917
+ openWorldHint: true,
918
+ },
919
+ }, async ({ diff, commit_hash, committed_at, project, summary }) => {
920
+ try {
921
+ const { status, body } = await db.invokeFunction("analyze-diff", {
922
+ compressedDiff: diff,
923
+ rawDiffChars: diff.length,
924
+ // El diff del agente llega crudo (git show): su hash canónico es la
925
+ // identidad que alinea el dedup con el hook y el webhook. Si esto
926
+ // divergiera, el mismo commit entraría dos veces por dos caminos.
927
+ rawContentHash: canonicalDiffHash(diff),
928
+ commitHash: commit_hash,
929
+ committedAt: committed_at,
930
+ projectName: project,
931
+ agentSummary: summary,
932
+ });
933
+ if (status >= 400) {
934
+ return errorResult(body.error ?? `analysis failed (${status})`);
935
+ }
936
+ const modules = Array.isArray(body.modules) ? body.modules.length : 0;
937
+ // CANAL PUSH: los avisos que ESTE cambio acaba de levantar vuelven en el
938
+ // acto, sin esperar a que alguien los pida. Reportas un cambio y, si toca
939
+ // algo que rompe un contrato del atlas, te enteras aquí mismo.
940
+ const warnings = Array.isArray(body.regressionWarnings)
941
+ ? body.regressionWarnings.filter((w) => w && typeof w.plain === "string")
942
+ : [];
943
+ const warnBlock = warnings.length > 0
944
+ ? `\n\n⚠ Este cambio levantó ${warnings.length} aviso${warnings.length === 1 ? "" : "s"} de regresión — DÍSELO al usuario y verifícalo:\n` +
945
+ warnings
946
+ .map((w) => ` · ${w.module ? `[${w.module}] ` : ""}${w.plain}`)
947
+ .join("\n")
948
+ : "";
949
+ return toolResult(`Recorded in the atlas.\n` +
950
+ `- Impact: ${body.summary ?? "(already imported — deduped)"}\n` +
951
+ `- Modules touched: ${modules}` +
952
+ (body.credits
953
+ ? `\n- Analyses used: ${body.credits.used}/${body.credits.limit}`
954
+ : "") +
955
+ warnBlock, {
956
+ recorded: true,
957
+ summary: body.summary ?? null,
958
+ modules_touched: modules,
959
+ regression_warnings: warnings,
960
+ });
961
+ }
962
+ catch (error) {
963
+ return errorResult(error);
964
+ }
965
+ });
966
+ // ── atlas_pending_tasks ─────────────────────────────────────────────────────
967
+ //
968
+ // PORTADA DEL HOSPEDADO EL 09/08. La cola de encargos ya se leía en este
969
+ // paquete —`fetchBriefSection` la mete en el bloque de CLAUDE.md— pero el
970
+ // agente no tenía forma de pedir el CUERPO de uno, que es lo único que
971
+ // permite atacarlo. El bloque decía «cola viva: `atlas_pending_tasks`» y esa
972
+ // tool no existía aquí.
973
+ //
974
+ // Se usa la RPC `list_agent_tasks` y no un select: es `security definer` y ya
975
+ // acota por dueño, así que no hay que replicar aquí la autorización.
976
+ server.registerTool("atlas_pending_tasks", {
977
+ title: "Tasks the owner queued for the agent",
978
+ description: "Pending tasks the owner queued for YOU. Returns id, title and a short excerpt; pass `task_id` for one task's full body, context and done-criterion. Tell the user which one you picked and wait for their OK before starting; close it with atlas_complete_task. " +
979
+ "project: the repo you are working in (folder name or slug).",
980
+ inputSchema: {
981
+ project: z.string().min(1).max(120),
982
+ limit: z.number().int().min(1).max(20).default(10),
983
+ task_id: z.string().uuid().optional(),
984
+ },
985
+ annotations: RO,
986
+ }, async ({ project, limit, task_id }) => {
987
+ const t0 = Date.now();
988
+ try {
989
+ const pf = await db.projectFilterFor(project);
990
+ const projectId = projectIdFromFilter(pf);
991
+ if (!projectId) {
992
+ return toolResult(`No pending tasks for "${project}".`, { tasks: [] });
993
+ }
994
+ const todas = await db.callRpc("list_agent_tasks", {
995
+ p_project_id: projectId,
996
+ });
997
+ const rows = (task_id
998
+ ? todas.filter((t) => t.id === task_id)
999
+ : todas.filter((t) => t.status === "pending"))
1000
+ .sort((a, b) => a.created_at.localeCompare(b.created_at))
1001
+ .slice(0, task_id ? 1 : limit);
1002
+ if (rows.length === 0) {
1003
+ const salida = toolResult(task_id
1004
+ ? `No task ${task_id} in ${project}.`
1005
+ : `No pending tasks for ${project}.`, { tasks: [], project });
1006
+ recordRead(db, "atlas_pending_tasks", pf, servedCharsOf(salida), Date.now() - t0);
1007
+ return salida;
1008
+ }
1009
+ // LA LISTA DEVUELVE UN EXTRACTO, no el cuerpo entero. Medido el 19/07 en
1010
+ // el hospedado: 10 encargos completos son ~26.000 chars, y un resultado
1011
+ // se reenvía al modelo en cada petición posterior de la sesión — hasta
1012
+ // 20 relecturas, ~104.000 tokens amortizados por UNA llamada. El agente
1013
+ // ataca uno y arrastra los otros nueve sin usarlos.
1014
+ //
1015
+ // 150 chars y no un título pelado: los títulos automáticos son casi
1016
+ // todos «[Auto] Riesgo detectado en <módulo>» y se repiten, así que sin
1017
+ // una línea de contexto el agente abre dos o tres cuerpos y sale más
1018
+ // caro que no haber recortado.
1019
+ const EXTRACTO_CHARS = 150;
1020
+ const resumen = (body) => {
1021
+ const limpio = body
1022
+ .split("\n")
1023
+ .filter((l) => l.trim() && !l.startsWith("#"))
1024
+ .join(" ")
1025
+ .trim();
1026
+ return limpio.length > EXTRACTO_CHARS
1027
+ ? `${limpio.slice(0, EXTRACTO_CHARS)}…`
1028
+ : limpio;
1029
+ };
1030
+ const text = rows
1031
+ .map((t, i) => task_id
1032
+ ? `[${t.id}] ${t.title} — queued ${t.created_at.slice(0, 10)}\n${t.body}`
1033
+ : `${i + 1}. [${t.id}] ${t.title}` +
1034
+ ` — queued ${t.created_at.slice(0, 10)}\n ${resumen(t.body)}`)
1035
+ .join("\n\n");
1036
+ const salida = toolResult(`Pending tasks for ${project} (oldest first):\n\n${text}`, {
1037
+ project,
1038
+ tasks: rows.map((t) => ({
1039
+ id: t.id,
1040
+ title: t.title,
1041
+ ...(task_id ? { body: t.body } : { excerpt: resumen(t.body) }),
1042
+ created_at: t.created_at,
1043
+ })),
1044
+ });
1045
+ recordRead(db, "atlas_pending_tasks", pf, servedCharsOf(salida), Date.now() - t0);
1046
+ return salida;
1047
+ }
1048
+ catch (error) {
1049
+ return errorResult(error);
1050
+ }
1051
+ });
1052
+ // ── atlas_complete_task ─────────────────────────────────────────────────────
1053
+ //
1054
+ // Se cierra por la RPC `resolve_agent_task` y NO con un PATCH a mano, y esa es
1055
+ // la diferencia que importa: su UPDATE dispara el trigger
1056
+ // `agent_tasks_close_alerts`, que cierra además la alerta asociada. Un PATCH
1057
+ // directo cerraría el encargo y dejaría el aviso abierto para siempre — el
1058
+ // encargo desaparecería de la cola y el aviso seguiría re-encolándolo.
1059
+ server.registerTool("atlas_complete_task", {
1060
+ title: "Close a queued task",
1061
+ description: "Mark a task from atlas_pending_tasks as done (or dismissed if it no longer applies). Pass the task id. Closing an already-closed task is a no-op.",
1062
+ inputSchema: {
1063
+ id: z.string().uuid(),
1064
+ status: z.enum(["done", "dismissed"]).default("done"),
1065
+ },
1066
+ annotations: {
1067
+ readOnlyHint: false,
1068
+ destructiveHint: false,
1069
+ idempotentHint: true,
1070
+ openWorldHint: true,
1071
+ },
1072
+ }, async ({ id, status }) => {
1073
+ try {
1074
+ // La RPC devuelve si tocó alguna fila. `false` NO es un error: es un
1075
+ // encargo ya cerrado o ajeno, y se dice tal cual en vez de fingir éxito.
1076
+ const closed = await db.callRpc("resolve_agent_task", {
1077
+ p_id: id,
1078
+ p_status: status,
1079
+ });
1080
+ return toolResult(closed
1081
+ ? `Task ${id} marked as ${status}.`
1082
+ : `Task ${id} was not pending (already closed, or not yours) — nothing changed.`, { closed, status });
1083
+ }
1084
+ catch (error) {
1085
+ return errorResult(error);
1086
+ }
1087
+ });
1088
1088
  }
1089
1089
  //# sourceMappingURL=tools.js.map