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
package/dist/audit.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook audit` — la pieza `[5]`: auditoría ESTÁTICA del setup.
|
|
3
|
+
*
|
|
4
|
+
* QUÉ LA DISTINGUE DE UNA LISTA DE BUENAS PRÁCTICAS. Cada comprobación de aquí
|
|
5
|
+
* se escribió DESPUÉS de encontrar algo real en un setup real (este repo, el
|
|
6
|
+
* 24/08), no antes. Una lista de comprobaciones inventadas produce avisos que
|
|
7
|
+
* nadie ha visto fallar nunca, y eso entrena a ignorar la herramienta.
|
|
8
|
+
*
|
|
9
|
+
* SIN RED Y SIN CREDENCIALES, como `silence` y `scan`: todo lo que dice sale de
|
|
10
|
+
* ficheros del repo. Así contesta el mismo día en que alguien lo instala, antes
|
|
11
|
+
* de tener cuenta, y no puede quedarse callada por un fallo de red — que es la
|
|
12
|
+
* forma en que una auditoría miente.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
/** Los ficheros de contexto que un agente lee al abrir, por convención. */
|
|
17
|
+
export const FICHEROS_DE_CONTEXTO = ['CLAUDE.md', 'AGENTS.md'];
|
|
18
|
+
const INICIO = '<!-- changebook:start -->';
|
|
19
|
+
const FIN = '<!-- changebook:end -->';
|
|
20
|
+
/**
|
|
21
|
+
* Las rutas citadas entre backticks que parecen ficheros.
|
|
22
|
+
*
|
|
23
|
+
* Se exige extensión conocida a propósito: sin ella, cualquier `foo.bar` de la
|
|
24
|
+
* prosa entraría como ruta y la auditoría publicaría falsos positivos — que en
|
|
25
|
+
* una herramienta que se corre a mano es peor que no publicar nada.
|
|
26
|
+
*/
|
|
27
|
+
export function rutasCitadas(texto) {
|
|
28
|
+
const re = /`([A-Za-z0-9_./-]+\.(?:ts|tsx|js|mjs|cjs|sql|yml|yaml|json|sh))`/g;
|
|
29
|
+
return [...new Set([...texto.matchAll(re)].map((m) => m[1]))];
|
|
30
|
+
}
|
|
31
|
+
/** El bloque auto-generado, si lo hay. */
|
|
32
|
+
export function partirContexto(texto) {
|
|
33
|
+
const i = texto.indexOf(INICIO);
|
|
34
|
+
const j = texto.indexOf(FIN);
|
|
35
|
+
if (i === -1 || j === -1 || j < i)
|
|
36
|
+
return { auto: '', aMano: texto };
|
|
37
|
+
const auto = texto.slice(i, j + FIN.length);
|
|
38
|
+
return { auto, aMano: texto.replace(auto, '') };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* ¿Existe? Y si no, ¿hay algo que se le parezca?
|
|
42
|
+
*
|
|
43
|
+
* El «quizá quisiste decir» no es adorno: los dos casos reales que encontró
|
|
44
|
+
* esto el 24/08 —`test/aliasEnLasToolsDelCli.ts` y `test/sinRayasEnLaWeb.ts`—
|
|
45
|
+
* eran el mismo fichero con `.test.ts`. Un aviso que sólo dice «no existe»
|
|
46
|
+
* manda a buscar; uno que dice el nombre bueno se arregla en diez segundos.
|
|
47
|
+
*/
|
|
48
|
+
export function resolverCita(raiz, cita) {
|
|
49
|
+
if (fs.existsSync(path.join(raiz, cita)))
|
|
50
|
+
return { existe: true };
|
|
51
|
+
// UN NOMBRE A SECAS NO ES UNA RUTA ROTA. La primera versión avisaba de
|
|
52
|
+
// `publish-extension.yml` —que existe, en `.github/workflows/`— porque se
|
|
53
|
+
// cita por su nombre. Ese falso positivo enseña a ignorar los avisos buenos,
|
|
54
|
+
// que era justo el riesgo escrito en la cabecera de este fichero. Cazado el
|
|
55
|
+
// 24/08 corriendo la auditoría contra su propio repo.
|
|
56
|
+
if (!cita.includes('/')) {
|
|
57
|
+
return { existe: buscarPorNombre(raiz, cita) };
|
|
58
|
+
}
|
|
59
|
+
const dir = path.join(raiz, path.dirname(cita));
|
|
60
|
+
const base = path.basename(cita, path.extname(cita));
|
|
61
|
+
let vecinos;
|
|
62
|
+
try {
|
|
63
|
+
vecinos = fs.readdirSync(dir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { existe: false };
|
|
67
|
+
}
|
|
68
|
+
const parecido = vecinos.find((v) => v.startsWith(`${base}.`) && v !== path.basename(cita));
|
|
69
|
+
return parecido
|
|
70
|
+
? { existe: false, parecido: path.join(path.dirname(cita), parecido) }
|
|
71
|
+
: { existe: false };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* ¿Existe algún fichero con ese nombre, en cualquier parte del repo?
|
|
75
|
+
*
|
|
76
|
+
* Con tope de profundidad y saltándose lo que no se versiona: sin eso, un
|
|
77
|
+
* `node_modules` convierte una orden instantánea en una que se piensa.
|
|
78
|
+
*/
|
|
79
|
+
function buscarPorNombre(raiz, nombre, profundidad = 6) {
|
|
80
|
+
const saltar = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
81
|
+
const pila = [[raiz, 0]];
|
|
82
|
+
while (pila.length > 0) {
|
|
83
|
+
const [dir, nivel] = pila.pop();
|
|
84
|
+
let entradas;
|
|
85
|
+
try {
|
|
86
|
+
entradas = fs.readdirSync(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entradas) {
|
|
92
|
+
if (e.isFile() && e.name === nombre)
|
|
93
|
+
return true;
|
|
94
|
+
if (e.isDirectory() && !saltar.has(e.name) && nivel < profundidad) {
|
|
95
|
+
pila.push([path.join(dir, e.name), nivel + 1]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Citas rotas en los ficheros de contexto.
|
|
103
|
+
*
|
|
104
|
+
* DE DÓNDE SALE: 2 de 9 rutas citadas en el `CLAUDE.md` de este repo no
|
|
105
|
+
* existían. Una regla que apunta a código que no está es peor que no tener la
|
|
106
|
+
* regla — le enseña al agente a desconfiar del documento entero, incluidas las
|
|
107
|
+
* partes que sí valen.
|
|
108
|
+
*/
|
|
109
|
+
export function citasRotas(raiz) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const nombre of FICHEROS_DE_CONTEXTO) {
|
|
112
|
+
let texto;
|
|
113
|
+
try {
|
|
114
|
+
texto = fs.readFileSync(path.join(raiz, nombre), 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue; // No tenerlo no es un fallo: no todos los repos los tienen.
|
|
118
|
+
}
|
|
119
|
+
// Sólo lo escrito A MANO: el bloque auto-generado se regenera solo y sus
|
|
120
|
+
// rutas salen de la base de datos, no de la prosa de nadie.
|
|
121
|
+
const { aMano } = partirContexto(texto);
|
|
122
|
+
const rotas = rutasCitadas(aMano)
|
|
123
|
+
.map((c) => ({ cita: c, ...resolverCita(raiz, c) }))
|
|
124
|
+
.filter((c) => !c.existe);
|
|
125
|
+
if (rotas.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
out.push({
|
|
128
|
+
nivel: 'aviso',
|
|
129
|
+
titulo: `${nombre}: ${rotas.length} cita(s) apuntan a ficheros que no existen`,
|
|
130
|
+
detalle: rotas.map((r) => r.parecido ? `${r.cita} → ¿quisiste decir ${r.parecido}?` : r.cita),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* El peso de contexto que se paga en CADA sesión.
|
|
137
|
+
*
|
|
138
|
+
* DE DÓNDE SALE, medido el 24/08 en este repo: el bloque auto-generado se
|
|
139
|
+
* raciona a 2.000 chars —hay una PR entera dedicada a que sea honesto cuando
|
|
140
|
+
* recorta— y al lado había **12.407 chars escritos a mano, seis veces más**,
|
|
141
|
+
* sin tope y sin que nadie los midiera. No se juzga si sobran: se ponen delante.
|
|
142
|
+
*/
|
|
143
|
+
export function pesoDeContexto(raiz, topeAuto = 2000) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const nombre of FICHEROS_DE_CONTEXTO) {
|
|
146
|
+
let texto;
|
|
147
|
+
try {
|
|
148
|
+
texto = fs.readFileSync(path.join(raiz, nombre), 'utf8');
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const { auto, aMano } = partirContexto(texto);
|
|
154
|
+
const detalle = [
|
|
155
|
+
`total ${texto.length} chars`,
|
|
156
|
+
`auto-generado ${auto.length} (tope ${topeAuto})`,
|
|
157
|
+
`escrito a mano ${aMano.length}, sin tope`,
|
|
158
|
+
];
|
|
159
|
+
// El aviso salta cuando lo de a mano PASA del bloque racionado: hasta ahí,
|
|
160
|
+
// la comparación no dice nada que preocupe.
|
|
161
|
+
out.push({
|
|
162
|
+
nivel: aMano.length > topeAuto ? 'aviso' : 'nota',
|
|
163
|
+
titulo: aMano.length > topeAuto
|
|
164
|
+
? `${nombre}: lo escrito a mano pesa ${(aMano.length / topeAuto).toFixed(1)}× el bloque racionado`
|
|
165
|
+
: `${nombre}: ${texto.length} chars en cada sesión`,
|
|
166
|
+
detalle,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* El hook de contexto: ¿está puesto, y su orden existe?
|
|
173
|
+
*
|
|
174
|
+
* Un hook cuyo comando no resuelve falla en silencio para siempre — la línea
|
|
175
|
+
* que instala el propio producto acaba en `|| true`, así que ni siquiera deja
|
|
176
|
+
* un exit distinto de cero. Es el Invariante 17 en la instalación.
|
|
177
|
+
*/
|
|
178
|
+
export function hookDeImpacto(raiz) {
|
|
179
|
+
const p = path.join(raiz, '.claude', 'settings.json');
|
|
180
|
+
let crudo;
|
|
181
|
+
try {
|
|
182
|
+
crudo = fs.readFileSync(p, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
nivel: 'aviso',
|
|
188
|
+
titulo: 'No hay hook de impacto instalado',
|
|
189
|
+
detalle: [
|
|
190
|
+
'Sin él, nadie te avisa de qué depende del fichero que vas a tocar.',
|
|
191
|
+
'Se instala con `changebook init`.',
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
const puesto = /changebook\s+impact/.test(crudo);
|
|
197
|
+
return puesto
|
|
198
|
+
? []
|
|
199
|
+
: [
|
|
200
|
+
{
|
|
201
|
+
nivel: 'aviso',
|
|
202
|
+
titulo: '.claude/settings.json existe pero no lanza `changebook impact`',
|
|
203
|
+
detalle: ['Se reinstala con `changebook init`.'],
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
/** Todas las comprobaciones, en el orden en que conviene leerlas. */
|
|
208
|
+
export function auditarSetup(raiz) {
|
|
209
|
+
return [...citasRotas(raiz), ...hookDeImpacto(raiz), ...pesoDeContexto(raiz)];
|
|
210
|
+
}
|
|
211
|
+
export function informeDeAuditoria(hallazgos) {
|
|
212
|
+
const avisos = hallazgos.filter((h) => h.nivel === 'aviso');
|
|
213
|
+
if (hallazgos.length === 0)
|
|
214
|
+
return 'Setup sin nada que señalar.';
|
|
215
|
+
const cabecera = avisos.length === 0
|
|
216
|
+
? 'Setup sin avisos.'
|
|
217
|
+
: `${avisos.length} aviso(s) sobre tu setup:`;
|
|
218
|
+
return [
|
|
219
|
+
cabecera,
|
|
220
|
+
...hallazgos.map((h) => [
|
|
221
|
+
`${h.nivel === 'aviso' ? '⚠' : '·'} ${h.titulo}`,
|
|
222
|
+
...h.detalle.map((d) => ` ${d}`),
|
|
223
|
+
].join('\n')),
|
|
224
|
+
].join('\n');
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La entrega de la fricción por el MCP **local** (stdio), gemela de
|
|
3
|
+
* `supabase/functions/_shared/friccionConsultas.ts`.
|
|
4
|
+
*
|
|
5
|
+
* POR QUÉ EXISTE, medido el 24/08: el bloque de fricción se implementó sólo en
|
|
6
|
+
* la edge function, así que un agente conectado por el **CLI del paquete npm**
|
|
7
|
+
* —que es como se conecta la mayoría, y como estaba conectada la sesión que
|
|
8
|
+
* escribió esto— no veía la fricción JAMÁS. El lector y el comando
|
|
9
|
+
* `changebook friction` sí estaban en el CLI; lo que faltaba era la entrega.
|
|
10
|
+
*
|
|
11
|
+
* Es la misma deriva entre las dos implementaciones que ya costó las once
|
|
12
|
+
* portadas del paquete: el MCP stdio registraba 4 tools y su propio `sync.ts`
|
|
13
|
+
* pedía 7. Por eso esto vive en un fichero gemelo del del servidor y no
|
|
14
|
+
* mezclado con el lector: `test/laFriccionEsLaMismaEnLosDosLados.test.ts` los
|
|
15
|
+
* compara función a función, y un gemelo que se quede corto pone rojo.
|
|
16
|
+
*
|
|
17
|
+
* El corte es el MISMO que en el servidor: `friction.ts` rinde la línea de un
|
|
18
|
+
* suceso, esto arma la consulta y el bloque.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ LO QUE SIGUE SIN CUBRIRSE, Y ES DELIBERADO: el hospedado sirve además la
|
|
21
|
+
* TASA por fichero en `atlas_file_context`, y el CLI no. No es un olvido: esa
|
|
22
|
+
* línea exige ≥2 correcciones sobre el mismo fichero y, medido el 24/08 contra
|
|
23
|
+
* las 1.509 filas de producción, la cumple **1 fichero de 341**. Pagar una
|
|
24
|
+
* consulta de red en el camino que corre ANTES DE CADA EDICIÓN para eso es un
|
|
25
|
+
* mal cambio. Si algún día la densidad sube, el gemelo que falta es
|
|
26
|
+
* `consultaPorFicheros` + `friccionPorRuta`, y el contrato de paridad de
|
|
27
|
+
* `test/laFriccionEsLaMismaEnLosDosLados.test.ts` es donde se engancha.
|
|
28
|
+
*/
|
|
29
|
+
import { lineaDeSuceso, VENTANA_DIAS } from "./friction.js";
|
|
30
|
+
/**
|
|
31
|
+
* El techo de PostgREST. Pedir más no trae más: trae 1.000 y se calla.
|
|
32
|
+
*
|
|
33
|
+
* Aquí casi no muerde, y a propósito: la consulta filtra por veredicto en el
|
|
34
|
+
* servidor, así que lo que cuenta contra el techo son las correcciones (8 en 30
|
|
35
|
+
* días, medido) y no las ediciones (1.509).
|
|
36
|
+
*/
|
|
37
|
+
export const TOPE_DE_FILAS = 1000;
|
|
38
|
+
/**
|
|
39
|
+
* Cuántos sucesos como mucho en el brief. Es lo PRIMERO que lee el agente al
|
|
40
|
+
* abrir y ya es largo: cinco caben sin desplazar a los avisos abiertos.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_SUCESOS = 5;
|
|
43
|
+
export function desdeLaVentana(ahoraMs) {
|
|
44
|
+
return new Date(ahoraMs - VENTANA_DIAS * 24 * 60 * 60 * 1000).toISOString();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* La consulta de sucesos. Idéntica a la del servidor, y el contrato de paridad
|
|
48
|
+
* lo exige carácter a carácter: dos URLs que difieran en el `order` o en el
|
|
49
|
+
* filtro darían briefs distintos según por dónde te conectes.
|
|
50
|
+
*/
|
|
51
|
+
export function consultaDeSucesos(filtroProyecto, ahoraMs) {
|
|
52
|
+
return (`friction_event?select=occurred_at,path,module` +
|
|
53
|
+
`&veredicto=eq.correccion` +
|
|
54
|
+
`&occurred_at=gte.${desdeLaVentana(ahoraMs)}` +
|
|
55
|
+
`&order=occurred_at.desc&limit=${TOPE_DE_FILAS}${filtroProyecto}`);
|
|
56
|
+
}
|
|
57
|
+
export function sucesosDeFriccion(filas) {
|
|
58
|
+
// Se reordena aquí aunque la consulta ya pida `order=occurred_at.desc`: esta
|
|
59
|
+
// función es pura y tiene contrato propio, y un contrato que depende del
|
|
60
|
+
// `order` de OTRO módulo se rompe el día que alguien reutilice ésta con filas
|
|
61
|
+
// de otra procedencia.
|
|
62
|
+
return filas
|
|
63
|
+
.map((f) => ({ fecha: f.occurred_at, ruta: f.path, modulo: f.module }))
|
|
64
|
+
.sort((a, b) => (b.fecha ?? "").localeCompare(a.fecha ?? ""));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Las líneas del bloque del brief, o `[]` si no hay nada que decir.
|
|
68
|
+
*
|
|
69
|
+
* Publica SUCESOS, no una tasa: con 8 correcciones en 30 días un porcentaje no
|
|
70
|
+
* informa de nada, y ocho hechos con su fecha sí. El razonamiento entero está
|
|
71
|
+
* en `docs/superpowers/specs/2026-08-23-mapa-de-friccion-design.md`.
|
|
72
|
+
*/
|
|
73
|
+
export function bloqueDeFriccion(filas) {
|
|
74
|
+
if (filas.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const truncado = filas.length >= TOPE_DE_FILAS;
|
|
77
|
+
const sucesos = sucesosDeFriccion(filas);
|
|
78
|
+
const mostrados = sucesos.slice(0, MAX_SUCESOS);
|
|
79
|
+
// «al menos» cuando el servidor cortó: el conteo sigue siendo cierto como
|
|
80
|
+
// cota inferior, y decir el número pelado sería afirmar de más.
|
|
81
|
+
const cuantas = truncado ? `at least ${sucesos.length}` : `${sucesos.length}`;
|
|
82
|
+
const lineas = [
|
|
83
|
+
"",
|
|
84
|
+
`## Where work got redone (${cuantas} in the last ${VENTANA_DIAS} days)`,
|
|
85
|
+
...mostrados.map((s) => `- ${lineaDeSuceso(s)}`),
|
|
86
|
+
];
|
|
87
|
+
// No recortar en silencio: un tope alcanzado sin decirlo se lee como «esto es
|
|
88
|
+
// todo».
|
|
89
|
+
if (sucesos.length > mostrados.length) {
|
|
90
|
+
lineas.push(`- …and ${sucesos.length - mostrados.length} more.`);
|
|
91
|
+
}
|
|
92
|
+
return lineas;
|
|
93
|
+
}
|
|
94
|
+
/** La forma del structured, armada aquí para que el brief sólo la esparza. */
|
|
95
|
+
export function friccionParaStructured(filas) {
|
|
96
|
+
return sucesosDeFriccion(filas).map((x) => ({
|
|
97
|
+
date: (x.fecha ?? "").slice(0, 10),
|
|
98
|
+
path: x.ruta,
|
|
99
|
+
module: x.modulo,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=friccionDelBrief.js.map
|