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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Formas de fila y de respuesta del servidor MCP stdio.
3
+ *
4
+ * POR QUÉ EXISTE. Espejo de `supabase/functions/mcp/filasYRespuestas.ts`, que el
5
+ * hospedado sacó de su `index.ts` el 03/08 (AUD-C11) por la misma razón que
6
+ * aquí: `tools.ts` pasó de 1.400 a 1.900 líneas en un día y las herramientas que
7
+ * quedaban por portar no caben sin convertirlo en el módulo-dios siguiente.
8
+ *
9
+ * Y NO ES SOLO TAMAÑO: sin este corte, una herramienta en su propio fichero
10
+ * tendría que importar `toolResult` de `tools.ts` mientras `tools.ts` la importa
11
+ * a ella para registrarla — un ciclo. Estas funciones son puras y no dependen de
12
+ * ninguna herramienta, así que salir es lo natural.
13
+ *
14
+ * REGLA: aquí solo entra lo que no sabe NADA del atlas. Si algo necesita el
15
+ * cliente de Supabase, el repo o una consulta, no es de este fichero.
16
+ */
17
+ import { SupabaseError } from './supabase.js';
18
+ /**
19
+ * Tope de lo que se sirve en una respuesta. Por encima se recorta el texto Y se
20
+ * deja de mandar el `structuredContent`: mandarlo entero derrotaría el propio
21
+ * tope que acaba de recortar el texto.
22
+ */
23
+ export const CHARACTER_LIMIT = 25_000;
24
+ /**
25
+ * Las anotaciones de una tool de SOLO LECTURA. Espejo del `ro` del hospedado
26
+ * (`buildServer` en supabase/functions/mcp/index.ts). Vive aquí porque iba
27
+ * camino de su cuarta copia literal, y cuatro copias de un objeto de
28
+ * anotaciones es como se acaba teniendo una que miente.
29
+ */
30
+ export const RO = {
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ idempotentHint: true,
34
+ openWorldHint: true,
35
+ };
36
+ /**
37
+ * El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
38
+ * agente afirmó un valor revertido porque la nota hablaba en presente). El ancla
39
+ * de deriva de `derivaContraHead` lo dice con precisión cuando hay árbol y hash;
40
+ * esta línea fija cubre el resto de los casos — nunca las dos a la vez.
41
+ */
42
+ export 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.';
43
+ // ── Respuestas ───────────────────────────────────────────────────────────────
44
+ function truncate(text) {
45
+ if (text.length <= CHARACTER_LIMIT)
46
+ return text;
47
+ return (text.slice(0, CHARACTER_LIMIT) +
48
+ '\n\n[Truncated. Use a smaller `limit`, an `offset`, or filters to narrow the result.]');
49
+ }
50
+ /**
51
+ * Construye el resultado de una tool. `structuredContent` duplica el texto
52
+ * renderizado, así que cuando hay que recortar el texto se deja fuera: mandarlo
53
+ * entero enviaría igualmente el objeto sin tope y derrotaría al recorte.
54
+ */
55
+ export function toolResult(text, structured) {
56
+ if (text.length <= CHARACTER_LIMIT) {
57
+ return {
58
+ content: [{ type: 'text', text }],
59
+ structuredContent: structured,
60
+ };
61
+ }
62
+ return { content: [{ type: 'text', text: truncate(text) }] };
63
+ }
64
+ export function errorResult(error) {
65
+ const message = error instanceof SupabaseError || error instanceof Error
66
+ ? error.message
67
+ : String(error);
68
+ // isError deja al cliente MCP (y al agente) distinguir una llamada fallida de
69
+ // un resultado válido cuyo texto empieza por "Error:".
70
+ return {
71
+ isError: true,
72
+ content: [{ type: 'text', text: `Error: ${message}` }],
73
+ };
74
+ }
75
+ /**
76
+ * Lo que de VERDAD llega al modelo. Espejo de `servedCharsOf` del hospedado.
77
+ *
78
+ * `chars_served` contaba el markdown, y el markdown casi nunca se usa: los
79
+ * tool_result llegan al modelo como el JSON del `structuredContent`. Solo cuando
80
+ * la respuesta revienta `CHARACTER_LIMIT` deja de haber JSON y queda el texto.
81
+ * Contar el markdown infravaloraba el gasto un 35%.
82
+ */
83
+ export function servedCharsOf(result) {
84
+ return 'structuredContent' in result && result.structuredContent
85
+ ? JSON.stringify(result.structuredContent).length
86
+ : (result.content?.[0]?.text?.length ?? 0);
87
+ }
88
+ export function fileList(files) {
89
+ return Array.isArray(files) ? files.map(String) : [];
90
+ }
91
+ export function day(iso) {
92
+ return iso.slice(0, 10);
93
+ }
94
+ /** El patrón de un `or=(...ilike...)` de PostgREST, codificado una sola vez. */
95
+ export function ilikePattern(search) {
96
+ return encodeURIComponent(`*${search.replace(/[%*,()]/g, ' ').trim()}*`);
97
+ }
98
+ // Extractos resumidos: los excerpts de diff guardados son lo más caro de
99
+ // atlas_module_detail (~1.5k chars cada uno) y la mayoría de las llamadas solo
100
+ // necesita saber QUÉ cambió. Por defecto va una vista previa corta; `full: true`
101
+ // los devuelve verbatim.
102
+ const EXCERPT_PREVIEW_LINES = 4;
103
+ const EXCERPT_PREVIEW_CHARS = 320;
104
+ export function previewExcerpt(excerpt) {
105
+ let text = excerpt.split('\n').slice(0, EXCERPT_PREVIEW_LINES).join('\n');
106
+ if (text.length > EXCERPT_PREVIEW_CHARS) {
107
+ text = text.slice(0, EXCERPT_PREVIEW_CHARS);
108
+ }
109
+ return { text: text.trimEnd(), truncated: text.length < excerpt.length };
110
+ }
111
+ //# sourceMappingURL=respuestas.js.map
package/dist/supabase.js CHANGED
@@ -261,14 +261,50 @@ export class Supabase {
261
261
  throw new SupabaseError(`Patch on ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
262
262
  }
263
263
  }
264
- patchOnce(table, filter, patch) {
264
+ /**
265
+ * Como `patchRows`, pero DEVUELVE las filas escritas (`return=representation`).
266
+ *
267
+ * POR QUÉ EXISTE, y no es cosmético (invariante 17). Con `return=minimal` un
268
+ * PATCH que no tocó nada y un PATCH que tocó cinco filas se ven exactamente
269
+ * igual: 204 los dos. Quien llama no puede distinguir «no había nada que
270
+ * cerrar» de «no me dejaron escribir», y esa confusión ya costó cara aquí: el
271
+ * 25/07 se añadió la columna `resolution` sin meterla en el grant POR COLUMNA
272
+ * de `regression_alerts` y los tres caminos que la escribían empezaron a
273
+ * fallar EN SILENCIO —`atlas_resolve_alert` informaba de que había cerrado
274
+ * cero, como si el aviso ya no estuviera abierto—. Volvió a asomar el 02/08
275
+ * con `resolution_by`. Ver 20260802120000_quien_juzgo_el_aviso.sql, que lo
276
+ * cuenta en su propio comentario.
277
+ *
278
+ * Devolver la representación no impide que vuelva a pasar; lo que hace es que
279
+ * cuando pase se VEA: quien llama compara lo que pidió cerrar con lo que
280
+ * volvió y puede gritar en vez de informar de un cero tranquilizador.
281
+ */
282
+ async patchRowsReturning(table, filter, patch) {
283
+ if (!this.hasCredentials())
284
+ throw new SupabaseError(AUTH_HELP, 401);
285
+ if (!this.accessToken)
286
+ await this.refresh();
287
+ let res = await this.patchOnce(table, filter, patch, 'return=representation');
288
+ if (res.status === 401 && this.refreshToken) {
289
+ await this.refresh();
290
+ res = await this.patchOnce(table, filter, patch, 'return=representation');
291
+ }
292
+ if (!res.ok) {
293
+ throw new SupabaseError(`Patch on ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
294
+ }
295
+ return (await res.json());
296
+ }
297
+ patchOnce(table, filter, patch,
298
+ // `return=minimal` sigue siendo el defecto: cambiarlo para todos haría que
299
+ // cada PATCH del CLI se trajera filas que nadie lee.
300
+ prefer = 'return=minimal') {
265
301
  return fetch(`${this.url}/rest/v1/${table}?${filter}`, {
266
302
  method: 'PATCH',
267
303
  headers: {
268
304
  apikey: this.anonKey,
269
305
  Authorization: `Bearer ${this.accessToken}`,
270
306
  'Content-Type': 'application/json',
271
- Prefer: 'return=minimal',
307
+ Prefer: prefer,
272
308
  },
273
309
  body: JSON.stringify(patch),
274
310
  signal: AbortSignal.timeout(10_000),
package/dist/sync.js CHANGED
@@ -54,6 +54,37 @@ const FIRMA = '_Generado por ChangeBook · changebook.app_';
54
54
  function sanitizeCell(text) {
55
55
  return text.replace(/<!--/g, '<!- -');
56
56
  }
57
+ /**
58
+ * Recorta a `tope` caracteres sin partir palabras y DICIENDO que ha recortado.
59
+ *
60
+ * POR QUÉ EXISTE. Los topes de este bloque son deliberados —entra en cada
61
+ * sesión de agente y se reenvía en cada petición, así que es coste FIJO— pero
62
+ * se aplicaban con un `.slice(0, n)` pelado. El 04/08 el CLAUDE.md de este
63
+ * mismo repo decía «permitiendo validar q»: cortado a media palabra y sin
64
+ * ninguna marca. Eso no se lee como un extracto, se lee como salida rota, y
65
+ * este bloque es el artefacto más leído del producto.
66
+ *
67
+ * DOS COSAS, Y LAS DOS IMPORTAN:
68
+ *
69
+ * · La elipsis SOLO aparece si de verdad se recortó. Ponerla siempre haría
70
+ * que toda nota pareciera truncada, que es el error simétrico y igual de
71
+ * malo: dejaría de distinguir lo completo de lo cortado.
72
+ * · El hueco de la elipsis sale DE DENTRO del tope. El presupuesto está en
73
+ * caracteres; un tope que se desborda por la marca que anuncia el tope es
74
+ * un tope de mentira.
75
+ *
76
+ * Una palabra sola más larga que el tope se corta a lo bruto: retroceder al
77
+ * espacio anterior devolvería la cadena vacía, y perder la nota entera es peor
78
+ * que un corte feo. Ese caso está en el corpus del test.
79
+ */
80
+ export function recorta(texto, tope) {
81
+ if (texto.length <= tope)
82
+ return texto;
83
+ const cortado = texto.slice(0, tope - 1);
84
+ const ultimoEspacio = cortado.lastIndexOf(' ');
85
+ const base = ultimoEspacio > 0 ? cortado.slice(0, ultimoEspacio) : cortado;
86
+ return `${base.replace(/[\s.,;:]+$/, '')}…`;
87
+ }
57
88
  const MAX_MODULES = 15;
58
89
  const MAX_CHANGES = 5;
59
90
  const MAX_COUPLINGS = 5;
@@ -464,21 +495,21 @@ maxCitas = CITAS_GRATIS) {
464
495
  // un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
465
496
  // y expulsaban el mapa. El texto entero sigue a una llamada de
466
497
  // `atlas_project_brief`, donde se paga solo si alguien pregunta.
467
- return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? '').slice(0, 130))}`;
498
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell(recorta(a.plain ?? '', 130))}`;
468
499
  });
469
500
  const hotspotLines = modules
470
501
  .filter((m) => m.risk === 'hotspot')
471
502
  .slice(0, 5)
472
503
  .map((m) => {
473
- const note = sanitizeCell((m.note ?? '').slice(0, 110));
504
+ const note = sanitizeCell(recorta(m.note ?? '', 110));
474
505
  return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
475
506
  });
476
- const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
507
+ const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell(recorta(c.business_impact ?? '', 140))}`);
477
508
  // Salud en riesgo: los controles (auth, secretos, validación, límites…) que
478
509
  // el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
479
510
  // es seguridad. Solo los at_risk (lo accionable); el texto entero va a
480
511
  // `atlas_project_brief`. Cap a 130 chars como las alertas.
481
- const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
512
+ const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(recorta(h.evidence, 130))}`);
482
513
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
483
514
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
484
515
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
@@ -489,7 +520,7 @@ maxCitas = CITAS_GRATIS) {
489
520
  const taskLines = taskTitles.length > 0
490
521
  ? [
491
522
  `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
492
- ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
523
+ ...taskTitles.map((t) => `- ${sanitizeCell(recorta(t, 120))}`),
493
524
  ]
494
525
  : [];
495
526
  // El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `atlas_action_plan`: qué arreglar primero, en una sola lista priorizada.
3
+ *
4
+ * PORTADA DEL HOSPEDADO EL 09/08. Existía allí desde el 24/07 (`333c49a`).
5
+ *
6
+ * POR TIERS EXPLÍCITOS Y NO POR UNA PUNTUACIÓN: un número oculto obliga a
7
+ * confiar; cuatro tiers con nombre —regresión abierta, control de salud roto,
8
+ * hotspot, reincidente— dejan discutir el orden. Es agregación pura de señales
9
+ * que el atlas ya tiene: no dispara ningún análisis nuevo.
10
+ */
11
+ import { z } from "zod";
12
+ import { alertasDesde, briefModules, buildActionPlan, computeRecidivism, lineaDePrecision, projectIdFromFilter, recordRead, } from "./agregados.js";
13
+ import { aliasesFor, canonicalizeModuleRows } from "./aliasDeModulo.js";
14
+ import { RO, errorResult, servedCharsOf, toolResult, } from "./respuestas.js";
15
+ import { summarizeHealth } from "./sync.js";
16
+ export function registrarActionPlan(server, db) {
17
+ server.registerTool("atlas_action_plan", {
18
+ title: "Action plan (what to fix first)",
19
+ description: "A single prioritized list of what to address in this project, most urgent first: open regressions (a change already broke something), at-risk health controls (a safety posture the analysis flagged), hotspot modules (fragile by design) and repeat-offender modules. Pure aggregation of signals the atlas already holds — no new analysis, ordered by transparent severity tiers rather than a hidden score. Use it to decide where to start, then ANNOUNCE your pick and wait for the user's OK before acting. " +
20
+ "project: the repo you are working in (folder name or slug).",
21
+ inputSchema: { project: z.string().min(1).max(120) },
22
+ annotations: RO,
23
+ }, async ({ project }) => {
24
+ const t0 = Date.now();
25
+ try {
26
+ const pf = await db.projectFilterFor(project);
27
+ const projectId = projectIdFromFilter(pf);
28
+ const [modRowsCrudas, alertRowsCrudas, healthRows, { aliases }] = await Promise.all([
29
+ db.rest(`change_module?select=changelog_id,module,domain,risk,created_at&order=created_at.desc&limit=1000${pf}`),
30
+ db
31
+ .rest(
32
+ // SIN `created_at=gte` A PROPÓSITO. Esta lectura sirve a DOS
33
+ // cosas con criterios de edad OPUESTOS: las regresiones abiertas
34
+ // (que sí quieren la ventana de 14 días) y la reincidencia (que
35
+ // la quiere all-time — recortarla borraría el historial que
36
+ // justifica llamar reincidente a un módulo). La ventana se
37
+ // aplica abajo, sobre estas mismas filas.
38
+ `regression_alerts?select=module,plain,resolution,resolved_at,created_at&order=created_at.desc&limit=500${pf}`)
39
+ .catch(() => []),
40
+ db
41
+ .rest(`project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8${pf}`)
42
+ .catch(() => []),
43
+ aliasesFor(db, projectId),
44
+ ]);
45
+ // LAS DOS, y antes de nada: `modules` alimenta los hotspots del plan y
46
+ // `alertRows` la reincidencia. Canonicalizar una sola haría que el plan
47
+ // hablara de un módulo con la reincidencia de otro.
48
+ const modRows = canonicalizeModuleRows(modRowsCrudas, aliases);
49
+ const alertRows = canonicalizeModuleRows(alertRowsCrudas, aliases);
50
+ const { modules } = briefModules(modRows);
51
+ const desde = alertasDesde();
52
+ const openAlerts = alertRows.filter((a) => !a.resolved_at && a.created_at >= desde);
53
+ const recidivism = computeRecidivism(alertRows);
54
+ const health = summarizeHealth(healthRows);
55
+ const plan = buildActionPlan({
56
+ alerts: openAlerts,
57
+ atRiskHealth: health.at_risk,
58
+ modules,
59
+ recidivism,
60
+ });
61
+ const lines = [`# ${project} — action plan`, ""];
62
+ if (plan.length === 0) {
63
+ lines.push("Nothing flagged: no open regressions, no at-risk health controls, no hotspot or repeat-offender modules. Nothing the atlas can point at right now.");
64
+ }
65
+ else {
66
+ lines.push(`${plan.length} item(s), most urgent first:`, "");
67
+ for (const it of plan) {
68
+ lines.push(`${it.rank}. **${it.what}** — ${it.why}`);
69
+ }
70
+ lines.push("", "ANNOUNCE to the user which item you'd start with and why, and wait " +
71
+ "for their OK before touching code. They cannot see this plan.");
72
+ }
73
+ // ¿ACERTAMOS? Al final, no al principio: el plan es lo accionable y el
74
+ // número es el respaldo. Del MISMO RPC que sirve el panel del dueño: si
75
+ // el agente y la persona vieran cifras distintas, el número dejaría de
76
+ // valer para los dos. Best-effort — un plan útil no puede caerse por su
77
+ // respaldo.
78
+ if (projectId) {
79
+ const precision = await db
80
+ .callRpc("atlas_precision", {
81
+ p_user_id: null,
82
+ p_project_id: projectId,
83
+ })
84
+ .catch(() => null);
85
+ const linea = lineaDePrecision(precision);
86
+ if (linea)
87
+ lines.push("", linea);
88
+ }
89
+ const salida = toolResult(lines.join("\n"), { project, plan });
90
+ recordRead(db, "atlas_action_plan", pf, servedCharsOf(salida), Date.now() - t0);
91
+ return salida;
92
+ }
93
+ catch (error) {
94
+ return errorResult(error);
95
+ }
96
+ });
97
+ }
98
+ //# sourceMappingURL=toolActionPlan.js.map
@@ -0,0 +1,310 @@
1
+ /**
2
+ * `atlas_project_brief`: el brief de orientación, la herramienta que abre cada
3
+ * sesión.
4
+ *
5
+ * PORTADA DEL HOSPEDADO EL 09/08. Existía allí desde el 18/07 (`9a8387a`) y el
6
+ * bloque de CLAUDE.md que escribe este mismo paquete decía «orientarte →
7
+ * `atlas_project_brief`» desde entonces. Quien instalaba por npm leía la orden
8
+ * en cada sesión y no tenía con qué cumplirla.
9
+ *
10
+ * VIVE EN SU PROPIO FICHERO, y no por estética: `tools.ts` iba por 1.900 líneas
11
+ * y esto son otras 300. Es el mismo corte que el hospedado hizo el 03/08
12
+ * (AUD-C11). Los agregados puros que necesita salieron antes a `agregados.ts`,
13
+ * porque pedírselos a `tools.ts` —que importa este módulo para registrarlo—
14
+ * cerraría un ciclo.
15
+ *
16
+ * UNA LLAMADA, NO CUATRO. Cada llamada extra del agente relee ~45k tokens de
17
+ * contexto fijo (anatomía medida en el benchmark del 2026-07-20), mientras que
18
+ * los joins de aquí cuestan milisegundos. Por eso `intent` ENSANCHA esta misma
19
+ * llamada en vez de sugerir otra.
20
+ */
21
+ import { z } from "zod";
22
+ import { MODULE_GRAPH_WINDOW_ROWS, aggregateFileContext, briefModules, commitLabel, computeRecidivism, fileContainsFilter, filesUnionByChange, normalizeRepoPath, projectIdFromFilter, quotedInList, recordRead, } from "./agregados.js";
23
+ import { aliasesFor, canonicalizeModuleRows } from "./aliasDeModulo.js";
24
+ import { bloqueDeFriccion, consultaDeSucesos, friccionParaStructured, } from "./friccionDelBrief.js";
25
+ import { RO, day, errorResult, ilikePattern, servedCharsOf, toolResult, } from "./respuestas.js";
26
+ import { coChangePairs, summarizeHealth } from "./sync.js";
27
+ /**
28
+ * Ventana de filas de `change_module` sobre la que se calcula el mapa.
29
+ *
30
+ * ES UNA VENTANA, NO UN CENSO, y el brief lo dice con esas palabras. Medido el
31
+ * 2026-08-03: la ventana estaba llena al ras, así que el número de módulos BAJA
32
+ * según se trabaja. Llamarlo «total» hacía leer eso como módulos que
33
+ * desaparecen.
34
+ */
35
+ const MODULE_WINDOW = MODULE_GRAPH_WINDOW_ROWS;
36
+ const MAX_ALERTS = 10;
37
+ const MAX_TASKS = 10;
38
+ export function registrarProjectBrief(server, db) {
39
+ server.registerTool("atlas_project_brief", {
40
+ title: "Project brief (start here)",
41
+ description: "Call this FIRST, the moment a session opens, to get your bearings: it hands you the module map with risks, the open regression alerts, the project health (which safety controls the analysis has evidenced as passing or at-risk), the tasks the owner queued for you, and the latest analyzed changes — ONE call that replaces atlas_modules + atlas_pending_tasks + atlas_recent_changes. Optional intent widens the SAME call (never a second trip): intent=orient serves 10 recent changes with commit+files; intent=pre_edit with files=[paths] appends each file's modules, notes and current watched values; intent=feature with feature=\"text\" appends the changes matching that text. After reading it, tell the user in 1-3 lines what you found and what you plan to do, and WAIT for their OK before editing any code — announcing and then editing in the same turn leaves the owner nothing to decide. " +
42
+ "project: the repo you are working in (folder name or slug).",
43
+ inputSchema: {
44
+ project: z.string().min(1).max(120),
45
+ intent: z.enum(["orient", "pre_edit", "feature"]).optional(),
46
+ files: z.array(z.string().min(1).max(300)).min(1).max(8).optional(),
47
+ feature: z.string().min(2).max(120).optional(),
48
+ },
49
+ annotations: RO,
50
+ }, async ({ project, intent, files, feature }) => {
51
+ const t0 = Date.now();
52
+ try {
53
+ const pf = await db.projectFilterFor(project);
54
+ const projectId = projectIdFromFilter(pf);
55
+ // intent=orient sirve 10 cambios en vez de 5: la tarea de orientación
56
+ // del benchmark pide «los 10 commits más recientes», y con 5 el agente
57
+ // gastaba una segunda llamada para el resto.
58
+ const changesLimit = intent === "orient" ? 10 : 5;
59
+ // TODO EN PARALELO. Son consultas independientes y el arranque del
60
+ // agente es tiempo que el usuario ve. Las que pueden faltar por tabla
61
+ // ausente caen a vacío en vez de tumbar el brief entero.
62
+ const [modRowsCrudas, alertsCrudas, changes, healthRows, tasks, unanalyzed, { aliases }, filasDeFriccion,] = await Promise.all([
63
+ db.rest(`change_module?select=changelog_id,module,domain,risk,files,note,created_at` +
64
+ `&order=created_at.desc&limit=${MODULE_WINDOW}${pf}`),
65
+ db
66
+ .rest(`regression_alerts?select=id,module,plain,created_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,evidence_in_diff` +
67
+ `&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}${pf}`)
68
+ .catch(() => []),
69
+ db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
70
+ `&order=created_at.desc&limit=${changesLimit}${pf}`),
71
+ db
72
+ .rest(`project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8${pf}`)
73
+ .catch(() => []),
74
+ projectId
75
+ ? db
76
+ .callRpc("list_agent_tasks", {
77
+ p_project_id: projectId,
78
+ })
79
+ .then((rows) => rows.filter((r) => r.status === "pending").slice(0, MAX_TASKS))
80
+ .catch(() => [])
81
+ : Promise.resolve([]),
82
+ db
83
+ .rest(`unanalyzed_commits?select=commit_hash,committed_at,reason&order=committed_at.asc&limit=200${pf}`)
84
+ .catch(() => []),
85
+ aliasesFor(db, projectId),
86
+ // Dónde hay que rehacer el trabajo. Va en el MISMO viaje: son 8 filas
87
+ // en 30 días (medido), así que no añade latencia al arranque. Cae a
88
+ // vacío si la tabla no está, en vez de tumbar el brief.
89
+ db
90
+ .rest(consultaDeSucesos(pf, Date.now()))
91
+ .catch(() => []),
92
+ ]);
93
+ // SE CANONICALIZA AQUÍ Y UNA VEZ. `briefModules`, `coChangePairs` y
94
+ // `computeRecidivism` son puras sobre estas filas: resolviendo alias
95
+ // antes, las tres ven lo mismo. Resolver dentro de cada una es cómo la
96
+ // identidad del módulo acabó viviendo en tres sitios y divergiendo.
97
+ const modRows = canonicalizeModuleRows(modRowsCrudas, aliases);
98
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
99
+ const { modules, total: modulesTotal } = briefModules(modRows);
100
+ const parejas = coChangePairs(modRows);
101
+ const recidivism = computeRecidivism(alertsCrudas.map((a) => ({
102
+ module: a.module ?? null,
103
+ plain: a.plain ?? null,
104
+ resolution: a.resolution,
105
+ })));
106
+ const health = summarizeHealth(healthRows);
107
+ const filesByChange = filesUnionByChange(modRowsCrudas);
108
+ const lines = [`# ${project} — project brief`, ""];
109
+ // ARRIBA DEL TODO. Si el mapa está incompleto, eso condiciona todo lo
110
+ // que viene detrás y hay que saberlo ANTES de leerlo, no en una nota al
111
+ // pie.
112
+ if (unanalyzed.length > 0) {
113
+ const oldest = unanalyzed[0]?.committed_at;
114
+ // LA CAUSA CAMBIA LO QUE EL USUARIO TIENE QUE HACER, así que cambia
115
+ // lo que se le dice. Culparle de su cuota cuando el que falló fue
116
+ // ChangeBook sería mentirle, y encima le empujaría a pagar por algo
117
+ // que no arregla nada (incidente del 2026-07-19).
118
+ const provider = unanalyzed.filter((u) => u.reason === "provider_error").length;
119
+ const quota = unanalyzed.length - provider;
120
+ const causa = provider > 0 && quota > 0
121
+ ? `${quota} because the monthly analysis quota ran out, and ${provider} because ChangeBook could not reach the AI (a ChangeBook-side failure, not the user's fault)`
122
+ : provider > 0
123
+ ? `because ChangeBook could not reach the AI. This is a ChangeBook-side failure, NOT the user's quota and NOT their fault — they do not need to upgrade or pay anything`
124
+ : `because the monthly analysis quota ran out`;
125
+ const salida = provider > 0 && quota === 0
126
+ ? `They will be analyzable once the service recovers.`
127
+ : `Their atlas will keep drifting until the quota renews on the 1st or they upgrade.`;
128
+ lines.push(`> **This atlas is out of date.** ${unanalyzed.length} commit(s) ` +
129
+ `could not be analyzed${oldest ? ` (oldest: ${day(oldest)})` : ""} ` +
130
+ `${causa}. Their files and commit messages were recorded, but they ` +
131
+ `have no narrated summary and no regression check. Treat the module ` +
132
+ `map below as missing those changes, and TELL THE USER. ${salida}`, "");
133
+ }
134
+ lines.push(modulesTotal > modules.length
135
+ ? `## Modules (${modules.length} most recent of ${modulesTotal} seen in the last ${MODULE_WINDOW} changes — all of them in atlas_modules, same window)`
136
+ : `## Modules (${modulesTotal} in the last ${MODULE_WINDOW} changes)`);
137
+ for (const m of modules) {
138
+ // Reincidente: N problemas de regresión DISTINTOS a lo largo del
139
+ // tiempo. Es un hecho de los datos, no una corazonada.
140
+ const previas = recidivism.get(m.module);
141
+ lines.push(`- **${m.module}**${m.domain ? ` · ${m.domain}` : ""} — ${m.changes} change(s), last ${m.last_changed}` +
142
+ (m.risk ? `, risk: ${m.risk}` : "") +
143
+ (previas ? ` · ⚠ ${previas} prior regressions` : ""));
144
+ }
145
+ if (parejas.length > 0) {
146
+ lines.push("", "## Change together (touch one, check the other)");
147
+ for (const p of parejas) {
148
+ lines.push(`- **${p.a}** ↔ **${p.b}** — together in ${Math.round(p.rate * 100)}% of their changes`);
149
+ }
150
+ }
151
+ // Devuelve [] cuando no hay nada que decir, así que no deja cabecera
152
+ // huérfana. Va ANTES de los avisos abiertos, igual que en el hospedado.
153
+ lines.push(...bloqueDeFriccion(filasDeFriccion));
154
+ lines.push("", `## Open regression alerts (${alerts.length})`);
155
+ if (alerts.length === 0)
156
+ lines.push("None — no open alerts.");
157
+ for (const a of alerts) {
158
+ lines.push(`- ${(a.created_at ?? "").slice(0, 10)}${a.module ? ` · **${a.module}**` : ""} — ${a.plain ?? ""}`);
159
+ }
160
+ if (alerts.length > 0) {
161
+ lines.push("", "SAY THIS OUT LOUD to the user, in one or two lines, before you start " +
162
+ "working. They cannot see this brief. A warning you absorb silently and " +
163
+ "route around is indistinguishable, from where they sit, from a product " +
164
+ "that does nothing: they paid for this and never learn it fired. Name the " +
165
+ "module and what it puts at risk, in plain language, and only then get on " +
166
+ "with the task. Once you FIX or verify one, call atlas_resolve_alert({ " +
167
+ "project, module, verdict }) so it stops being re-raised and stops re-queuing " +
168
+ "the owner's task — an alert nobody closes is billed forever.");
169
+ }
170
+ lines.push("", `## Project health (${health.passed} passed, ${health.at_risk.length} at risk)`);
171
+ if (healthRows.length === 0) {
172
+ lines.push("Not yet evidenced — no analyzed change has touched a health control yet.");
173
+ }
174
+ else if (health.at_risk.length === 0) {
175
+ lines.push("All evidenced controls passing.");
176
+ }
177
+ else {
178
+ for (const h of health.at_risk) {
179
+ lines.push(`- ⚠ **${h.check}** at risk${h.since ? ` (since ${h.since})` : ""} — ${h.evidence}`);
180
+ }
181
+ lines.push("", "SAY THIS OUT LOUD to the user before you edit: a health control the " +
182
+ "analysis already caught as at-risk (an unauthorized endpoint, a " +
183
+ "hardcoded secret, a table-wide grant) is exactly what they cannot " +
184
+ "see. Name it in plain language, then get on with the task.");
185
+ }
186
+ lines.push("", `## Tasks the owner queued for you (${tasks.length})`);
187
+ if (tasks.length === 0)
188
+ lines.push("None pending.");
189
+ for (const t of tasks)
190
+ lines.push(`- [${t.id}] ${t.title}`);
191
+ if (tasks.length > 0) {
192
+ lines.push("", "Fetch a task's full body with atlas_pending_tasks({ task_id }). Tell the user which task you picked and your plan, and WAIT for their OK before touching code.");
193
+ }
194
+ lines.push("", `## Latest changes`);
195
+ for (const c of changes) {
196
+ // Con aliases post-squash: `abc1234 (=def5678)` — si el primero no
197
+ // existe en el main del lector, el segundo sí.
198
+ const commit = commitLabel(c.commit_hash, c.hash_aliases);
199
+ lines.push(`- ${day(c.created_at)}${commit ? ` · ${commit}` : ""} — ${c.business_impact ?? ""}`);
200
+ const served = filesByChange.get(c.id);
201
+ if (served && served.files.length > 0) {
202
+ lines.push(` files: ${served.files.join(", ")}${served.more > 0 ? ` (+${served.more} more)` : ""}`);
203
+ }
204
+ }
205
+ // ── intent=pre_edit: el contexto por fichero, en ESTA misma llamada ──
206
+ let preEdit = [];
207
+ if (intent === "pre_edit" && files && files.length > 0) {
208
+ const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
209
+ const [perFileCrudo, watchedRows] = await Promise.all([
210
+ Promise.all(paths.map((p) => db
211
+ .rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(p)}&order=created_at.desc&limit=20${pf}`)
212
+ .then((rows) => ({ p, rows })))),
213
+ db
214
+ .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40${pf}`)
215
+ .catch(() => []),
216
+ ]);
217
+ // La agregación va DESPUÉS de canonicalizar: `aggregateFileContext`
218
+ // agrupa por slug, y canonicalizar después dejaría dos grupos para el
219
+ // mismo módulo.
220
+ preEdit = perFileCrudo.map(({ p, rows }) => {
221
+ const { file, modules: mods } = aggregateFileContext(p, canonicalizeModuleRows(rows, aliases));
222
+ return {
223
+ file,
224
+ modules: mods,
225
+ watched_values: watchedRows
226
+ .filter((w) => w.file === file)
227
+ .map((w) => ({
228
+ name: w.name,
229
+ value: w.value,
230
+ commit: w.commit_hash?.slice(0, 7) ?? null,
231
+ })),
232
+ };
233
+ });
234
+ lines.push("", `## Pre-edit context (${preEdit.length} file(s))`);
235
+ for (const f of preEdit) {
236
+ lines.push(`### ${f.file}`);
237
+ if (f.modules.length === 0) {
238
+ lines.push("No atlas history for this file yet (new or never analyzed).");
239
+ }
240
+ for (const m of f.modules) {
241
+ lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
242
+ (m.risk ? `, risk: ${m.risk}` : ""));
243
+ if (m.last_note) {
244
+ lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
245
+ }
246
+ }
247
+ for (const w of f.watched_values) {
248
+ lines.push(`- Current value: ${w.name} = ${w.value}` +
249
+ (w.commit ? ` (as of commit ${w.commit})` : ""));
250
+ }
251
+ }
252
+ }
253
+ else if (intent === "pre_edit") {
254
+ lines.push("", "intent=pre_edit needs files=[paths]; nothing appended.");
255
+ }
256
+ // ── intent=feature: los cambios que casan con un texto ───────────────
257
+ let featureMatches = [];
258
+ if (intent === "feature" && feature) {
259
+ const patron = ilikePattern(feature);
260
+ const rows = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
261
+ `&or=(business_impact.ilike.${patron},summary_tech.ilike.${patron})&order=created_at.desc&limit=5${pf}`);
262
+ featureMatches = rows.map((r) => ({
263
+ date: day(r.created_at),
264
+ commit: commitLabel(r.commit_hash, r.hash_aliases),
265
+ business_impact: r.business_impact ?? "",
266
+ }));
267
+ lines.push("", `## Changes matching "${feature}" (${rows.length})`);
268
+ if (rows.length === 0) {
269
+ lines.push("No analyzed change mentions it.");
270
+ }
271
+ for (const m of featureMatches) {
272
+ lines.push(`- ${m.date}${m.commit ? ` · ${m.commit}` : ""} — ${m.business_impact}`);
273
+ }
274
+ }
275
+ else if (intent === "feature") {
276
+ lines.push("", 'intent=feature needs feature="text"; nothing appended.');
277
+ }
278
+ const salida = toolResult(lines.join("\n"), {
279
+ project,
280
+ modules,
281
+ modules_total: modulesTotal,
282
+ couplings: parejas,
283
+ // Paridad con el texto, igual que en el hospedado: sin esto, lo único
284
+ // que dice dónde se rehace trabajo vive SÓLO dentro del markdown.
285
+ friction: friccionParaStructured(filasDeFriccion),
286
+ alerts: alerts.map((a) => ({
287
+ module: a.module,
288
+ plain: a.plain,
289
+ created_at: a.created_at ?? null,
290
+ })),
291
+ health,
292
+ tasks: tasks.map((t) => ({ id: t.id, title: t.title })),
293
+ changes: changes.map((c) => ({
294
+ date: day(c.created_at),
295
+ commit: commitLabel(c.commit_hash, c.hash_aliases),
296
+ business_impact: c.business_impact ?? "",
297
+ })),
298
+ unanalyzed: unanalyzed.length,
299
+ ...(preEdit.length > 0 ? { pre_edit: preEdit } : {}),
300
+ ...(featureMatches.length > 0 ? { feature_matches: featureMatches } : {}),
301
+ });
302
+ recordRead(db, "atlas_project_brief", pf, servedCharsOf(salida), Date.now() - t0);
303
+ return salida;
304
+ }
305
+ catch (error) {
306
+ return errorResult(error);
307
+ }
308
+ });
309
+ }
310
+ //# sourceMappingURL=toolProjectBrief.js.map