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.
- package/README.md +5 -0
- package/dist/agregados.js +496 -0
- package/dist/audit.js +226 -0
- package/dist/friccionDelBrief.js +102 -0
- package/dist/friction.js +1130 -0
- package/dist/guard.js +184 -2
- package/dist/impact.js +172 -17
- package/dist/index.js +40 -1
- package/dist/respuestas.js +111 -0
- package/dist/supabase.js +38 -2
- package/dist/sync.js +127 -13
- 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
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|