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
|
@@ -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
|
@@ -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 ?? ''
|
|
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 ?? ''
|
|
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 ?? ''
|
|
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
|
|
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
|
|
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
|