changebook 0.7.1 → 0.9.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
@@ -13,6 +13,7 @@
13
13
  import { readFile, writeFile } from 'node:fs/promises';
14
14
  import path from 'node:path';
15
15
  import { aliasesFor, canonicalizeModuleRows, etiquetaPorSlug, projectIdOf, slugModule, } from './aliasDeModulo.js';
16
+ import { consultaDeSucesos } from './friccionDelBrief.js';
16
17
  /**
17
18
  * Cuántas citas de historial sirve el bloque, según el plan (B5, opción A).
18
19
  *
@@ -103,7 +104,7 @@ const MAX_COUPLINGS = 5;
103
104
  //
104
105
  // Subir el techo alimenta al que se lo come. Adelgazada la cabecera a 271
105
106
  // chars, con 2.000 cabe mas contenido REAL que antes con 3.000.
106
- const SYNC_BUDGET_CHARS = 2_000;
107
+ export const SYNC_BUDGET_CHARS = 2_000;
107
108
  // Co-change pair thresholds — same spirit as the web's signals: at least 3
108
109
  // shared analyses and a ≥60% rate before we call it a dependency.
109
110
  const MIN_PAIR_COUNT = 3;
@@ -155,7 +156,7 @@ export async function fetchBriefSection(db, targetDir) {
155
156
  }
156
157
  const projectId = projectIdOf(projectFilter);
157
158
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
158
- const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases },] = await Promise.all([
159
+ const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases }, friccionCruda,] = await Promise.all([
159
160
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
160
161
  projectFilter),
161
162
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -185,6 +186,12 @@ export async function fetchBriefSection(db, targetDir) {
185
186
  .catch(() => []),
186
187
  // Los alias entran en el MISMO Promise.all que ya se hacía: no cuesta ronda.
187
188
  aliasesFor(db, projectId),
189
+ // FRICCIÓN: dónde hubo que rehacer el trabajo. Es la misma consulta que
190
+ // sirve el brief —filtrada por veredicto en el servidor—, así que son 8
191
+ // filas en 30 días y no cuesta ronda tampoco.
192
+ db
193
+ .rest(consultaDeSucesos(projectFilter, Date.now()))
194
+ .catch(() => []),
188
195
  ]);
189
196
  // El punto de estrangulamiento del bloque: se canonicaliza AQUÍ, nada más
190
197
  // traer las filas, y todo lo que hay debajo (el mapa, `hechosCaros`,
@@ -196,6 +203,10 @@ export async function fetchBriefSection(db, targetDir) {
196
203
  const moduleRows = canonicalizeModuleRows(moduleRowsCrudas, aliases);
197
204
  const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
198
205
  const historial = canonicalizeModuleRows(historialCrudo, aliases);
206
+ // LA CUARTA TABLA. La fricción también trae nombre de módulo, así que sin
207
+ // esto una fusión resolvería tres secciones y dejaría la fricción citando el
208
+ // nombre viejo — en la misma pantalla y sin que nada fallara.
209
+ const friccion = canonicalizeModuleRows(friccionCruda, aliases);
199
210
  const health = summarizeHealth(healthRows);
200
211
  // El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
201
212
  // por los analisis que de verdad rompieron algo: es la diferencia entre «este
@@ -217,8 +228,8 @@ export async function fetchBriefSection(db, targetDir) {
217
228
  commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
218
229
  }
219
230
  }
220
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db));
221
- return { section, projectId, projectResolved };
231
+ const { section, omitidas } = buildSectionConInforme(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db), friccion);
232
+ return { section, projectId, projectResolved, omitidas };
222
233
  }
223
234
  /**
224
235
  * Lo que va a cambiar, en corto y para una persona.
@@ -259,12 +270,23 @@ export function resumenDelCambio(previo, siguiente, fichero) {
259
270
  return l.join('\n');
260
271
  }
261
272
  export async function syncContextFiles(db, targetDir, opts = {}) {
262
- const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
273
+ const { section, projectId, projectResolved, omitidas } = await fetchBriefSection(db, targetDir);
263
274
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
264
275
  const file = path.join(targetDir, name);
265
276
  const updated = await upsertSection(file, section, opts);
266
277
  console.error(`${updated} ${name}`);
267
278
  }
279
+ // LO QUE NO CUPO, dicho a la persona. El bloque tiene presupuesto fijo —es
280
+ // coste que se paga en CADA sesión de CADA agente— y hasta hoy una sección
281
+ // que no cabía desaparecía sin dejar rastro en ningún sitio. Se dice aquí y
282
+ // no dentro del bloque: al agente no le falta (la cabecera ya le manda a
283
+ // `atlas_project_brief`, que no tiene tope), y meterlo dentro gastaría
284
+ // presupuesto de todas las sesiones para avisar de que falta presupuesto.
285
+ if (omitidas.length > 0) {
286
+ console.error(`⚠ No cupo todo en ${SYNC_BUDGET_CHARS} chars — pídelo con \`atlas_project_brief\`:`);
287
+ for (const o of omitidas)
288
+ console.error(` · ${o}`);
289
+ }
268
290
  // El sync ES una consulta del atlas — la más apalancada: el mapa que
269
291
  // destila entra en CADA sesión de agente vía CLAUDE.md/AGENTS.md sin
270
292
  // pagar tool calls. Cuenta como lectura (best-effort).
@@ -361,11 +383,31 @@ export function hechosCaros(historial, commitPorAnalisis, tope = 6, maxCitas = C
361
383
  return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
362
384
  });
363
385
  }
364
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
386
+ /**
387
+ * El bloque, y **qué se quedó fuera por tamaño**.
388
+ *
389
+ * POR QUÉ EXISTE ESTA SEGUNDA SALIDA, 24/08: el presupuesto es de suma cero y
390
+ * una sección entera que no cabe desaparecía **en silencio**. `recorta` avisa
391
+ * cuando corta una línea —lleva su elipsis desde el 04/08— pero perder la
392
+ * sección completa no dejaba rastro en ningún sitio.
393
+ *
394
+ * El informe NO va dentro del bloque. Al agente no le falta: la cabecera ya le
395
+ * dice que pida `atlas_project_brief`, que no tiene este tope. A quien le falta
396
+ * es a la PERSONA que corre `changebook sync`, y a ésa se le dice por stderr
397
+ * —una vez, cuando puede hacer algo— en vez de gastar presupuesto de cada
398
+ * sesión para siempre.
399
+ */
400
+ export function buildSectionConInforme(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
365
401
  // B5 · el que paga ve más historial de regresiones. Por defecto, el gratuito:
366
402
  // un fallo al leer el plan tiene que degradar HACIA el plan libre, nunca
367
403
  // regalar el de pago por un error de red.
368
- maxCitas = CITAS_GRATIS) {
404
+ maxCitas = CITAS_GRATIS,
405
+ // Décimo posicional, y no me gusta: esta firma pide ya un objeto de opciones.
406
+ // Se deja posicional a propósito porque cambiarla toca todos los sitios de
407
+ // llamada y todos los contratos que la prueban, y eso no es parte de esta
408
+ // pieza. Por defecto `[]`: un proyecto sin fricción —o una consulta que
409
+ // falló— produce la sección vacía, que desaparece sola.
410
+ friccion = []) {
369
411
  // Newest-first rows: the first occurrence of a module is its latest state.
370
412
  //
371
413
  // POR SLUG (03/08). Agrupaba por `row.module` crudo, asi que un modulo
@@ -462,11 +504,17 @@ maxCitas = CITAS_GRATIS) {
462
504
  '',
463
505
  ];
464
506
  if (modules.length === 0) {
465
- return [
466
- ...head,
467
- '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
468
- END,
469
- ].join('\n');
507
+ // Sin módulos no hay reparto, así que no hay nada omitido — y decir `[]` no
508
+ // es lo mismo que no decir nada: quien lea el informe sabe que aquí no se
509
+ // perdió nada por tamaño, sino que no había datos.
510
+ return {
511
+ section: [
512
+ ...head,
513
+ '_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._',
514
+ END,
515
+ ].join('\n'),
516
+ omitidas: [],
517
+ };
470
518
  }
471
519
  // Una linea por modulo, corta a proposito. Antes llevaba 3 ficheros y una
472
520
  // nota de 110 chars: ~200 chars por modulo, asi que en el presupuesto cabian
@@ -523,6 +571,41 @@ maxCitas = CITAS_GRATIS) {
523
571
  ...taskTitles.map((t) => `- ${sanitizeCell(recorta(t, 120))}`),
524
572
  ]
525
573
  : [];
574
+ // ── Fricción: dónde hubo que rehacer el trabajo ────────────────────────────
575
+ //
576
+ // POR QUÉ ENTRA, medido el 24/08 contra producción antes de escribirlo: de los
577
+ // 4 módulos con fricción en 30 días, **3 no aparecen en ninguna otra sección**
578
+ // (`Badge del README`, `Analítica de producto`, `Consolidación de módulos`).
579
+ // El único que se solapa es `Reportes de regresiones`, que ya sale en «costó
580
+ // caro» con 8. Si el solape hubiera sido total, esta sección sería decoración
581
+ // compitiendo por un presupuesto de suma cero contra señales validadas.
582
+ //
583
+ // TRES Y NO CINCO como el brief: esto entra en el contexto de CADA sesión, no
584
+ // se pide. El listado entero se sirve con `changebook friction` y por
585
+ // `atlas_project_brief`.
586
+ //
587
+ // ⚠ LO QUE ESTA SEÑAL NO ES: una regresión confirmada. Dice «aquí hubo que
588
+ // rehacer trabajo», con una precisión que NO está validada (n=15, un repo, y
589
+ // etiquetado por quien escribió el clasificador). Por eso su prioridad de
590
+ // presupuesto es 2 y no 1: cae antes que los avisos abiertos, la salud, los
591
+ // módulos críticos y el historial de regresiones, que sí están medidos.
592
+ //
593
+ // Y ESO TIENE UNA CONSECUENCIA MEDIDA, dicha aquí para que nadie la
594
+ // redescubra: en ESTE repo la sección NO SE PINTA. El bloque sale a 1.998 de
595
+ // 2.000 chars —saturado por tres módulos críticos y cinco entradas de
596
+ // historial— y la sección cuesta 271. Que pierda esa puja es lo correcto:
597
+ // enfrente hay regresiones confirmadas. En un repo con menos historial sí
598
+ // entra (comprobado con `buildSection` sobre un repo nuevo y sobre uno con
599
+ // una regresión: sale en los dos).
600
+ //
601
+ // ⚠ Y el hueco que esto destapa, PREVIO a esta sección y común a todas: una
602
+ // sección descartada por presupuesto es MUDA. `recorta` avisa cuando corta
603
+ // una línea, pero nada avisa cuando cae una sección entera. Mientras siga
604
+ // así, el listado completo se pide con `changebook friction` o por
605
+ // `atlas_project_brief`, que no tienen este tope.
606
+ const friccionLines = friccion
607
+ .slice(0, 3)
608
+ .map((f) => `- ${(f.occurred_at ?? '').slice(8, 10)}/${(f.occurred_at ?? '').slice(5, 7)} · \`${sanitizeCell(recorta(f.path, 80))}\`${f.module ? ` — **${sanitizeCell(f.module)}**` : ''}`);
526
609
  // El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
527
610
  // recorta primero) son independientes. Los dos importan, y hasta el 2026-07-26
528
611
  // solo uno estaba bien.
@@ -578,6 +661,16 @@ maxCitas = CITAS_GRATIS) {
578
661
  title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
579
662
  lines: hechosCaros(historial, commitPorAnalisis, 6, maxCitas),
580
663
  },
664
+ {
665
+ key: 'friccion',
666
+ priority: 2,
667
+ // EL TOTAL VA EN EL TÍTULO. Se enseñan 3 de las que haya, y un recorte
668
+ // mudo se lee como «esto es todo». Meterlo en el título cuesta CERO
669
+ // líneas de presupuesto, que es lo que impide que la honestidad compita
670
+ // con el contenido. El listado entero: `changebook friction`.
671
+ title: `### Dónde hubo que rehacer el trabajo (${friccionLines.length} de ${friccion.length}, últimos 30 días)`,
672
+ lines: friccionLines,
673
+ },
581
674
  // El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
582
675
  // Claude Code recorta por su cuenta «architecture overviews» de un
583
676
  // CLAUDE.md porque el agente los deriva del repo. Lo que no puede derivar
@@ -683,7 +776,28 @@ maxCitas = CITAS_GRATIS) {
683
776
  // se apartó del presupuesto arriba, así que entra siempre.
684
777
  lines.push('', FIRMA);
685
778
  lines.push(END);
686
- return lines.join('\n');
779
+ // LO QUE NO CABIÓ. Una sección con líneas que se quedó con `count` 0 —o con
780
+ // menos líneas de las que traía— no entró entera. Se compara contra lo que la
781
+ // sección TENÍA, no contra un tope fijo: así también se ve la que entró a
782
+ // medias, que es igual de muda.
783
+ const omitidas = sections
784
+ .filter((x) => x.lines.length > 0)
785
+ .filter((x) => (includedCount.get(x.key) ?? 0) < x.lines.length)
786
+ .map((x) => {
787
+ const dentro = includedCount.get(x.key) ?? 0;
788
+ const nombre = x.title.replace(/^#+\s*/, '').replace(/\s*\(.*\)\s*$/, '');
789
+ return dentro === 0
790
+ ? `${nombre} (entera, ${x.lines.length} línea(s))`
791
+ : `${nombre} (${x.lines.length - dentro} de ${x.lines.length} línea(s))`;
792
+ });
793
+ return { section: lines.join('\n'), omitidas };
794
+ }
795
+ /**
796
+ * El bloque, a secas. La forma que usan los diez sitios de llamada que no
797
+ * necesitan el informe.
798
+ */
799
+ export function buildSection(...args) {
800
+ return buildSectionConInforme(...args).section;
687
801
  }
688
802
  /**
689
803
  * Symmetric co-change pairs: modules that appear in the same analyses often
@@ -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