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/README.md +4 -0
- package/dist/agregados.js +496 -0
- package/dist/analyze.js +40 -2
- package/dist/friccionDelBrief.js +102 -0
- package/dist/friction.js +1016 -0
- package/dist/git.js +42 -2
- package/dist/guard.js +184 -2
- package/dist/impact.js +172 -17
- package/dist/index.js +31 -1
- package/dist/respuestas.js +111 -0
- package/dist/supabase.js +38 -2
- package/dist/sync.js +36 -5
- package/dist/toolActionPlan.js +98 -0
- package/dist/toolProjectBrief.js +310 -0
- package/dist/toolUsage.js +128 -0
- package/dist/tools.js +332 -332
- package/dist/usage.js +120 -0
- package/package.json +1 -1
- package/server.json +2 -2
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*
|
|
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
|
-
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
|
|
775
|
-
|
|
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
|