changebook 0.6.0 → 0.7.1

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,100 @@
1
+ import * as fs from 'node:fs';
2
+ import { gitPath } from './git.js';
3
+ /** Cuántos días atrás se mira. Más allá, la noticia ya no es noticia. */
4
+ export const VENTANA_DIAS = 14;
5
+ /**
6
+ * La línea, o cadena vacía.
7
+ *
8
+ * UNA SOLA LÍNEA Y UNA SOLA ALERTA. Con dos aciertos a la vez la tentación es
9
+ * listarlos, y una lista de autoelogio en cada commit es ruido con otro
10
+ * nombre. Se dice el más reciente y se callan los demás; ya se dirán cuando
11
+ * toque, o no se dirán, que tampoco pasa nada.
12
+ */
13
+ export function lineaDeAcierto(candidatos, yaDichas, ahora = Date.now()) {
14
+ const frescos = candidatos
15
+ .filter((a) => a.id && !yaDichas.has(a.id))
16
+ .filter((a) => (a.resolution ?? '') === 'fixed')
17
+ .filter((a) => (a.plain ?? '').trim().length > 0)
18
+ .filter((a) => {
19
+ const t = Date.parse(a.resolved_at ?? '');
20
+ return Number.isFinite(t) && ahora - t <= VENTANA_DIAS * 86_400_000;
21
+ })
22
+ .sort((a, b) => Date.parse(b.resolved_at ?? '') - Date.parse(a.resolved_at ?? ''));
23
+ const a = frescos[0];
24
+ if (!a)
25
+ return null;
26
+ const modulo = (a.module ?? '').trim();
27
+ const donde = modulo ? ` sobre ${modulo}` : '';
28
+ // QUIÉN lo juzgó va en la frase, no en una nota al pie. «Lo confirmaste tú» y
29
+ // «lo dio por bueno el agente que lo arregló» no valen lo mismo, y el producto
30
+ // no puede cobrarse el segundo como si fuera el primero.
31
+ const quien = a.resolution_by === 'human'
32
+ ? 'y lo confirmaste tú'
33
+ : a.resolution_by === 'grep'
34
+ ? 'y lo confirmó una comprobación en tu código'
35
+ : 'y lo dio por bueno el agente que lo arregló';
36
+ return {
37
+ id: a.id,
38
+ texto: `\n✓ ChangeBook acertó: el aviso${donde} era real, ${quien}.\n «${recorta(a.plain ?? '')}»\n`,
39
+ };
40
+ }
41
+ /** El texto del aviso, acotado: una línea de commit no es un informe. */
42
+ function recorta(s, max = 110) {
43
+ const limpio = s.trim().replace(/\s+/g, ' ');
44
+ return limpio.length <= max ? limpio : `${limpio.slice(0, max - 1)}…`;
45
+ }
46
+ /** Las que ya se dijeron. Fichero propio, mismo sitio que el resto del estado. */
47
+ async function memoriaPath(dir) {
48
+ return gitPath(dir, 'changebook-aciertos.json').catch(() => null);
49
+ }
50
+ function leerDichas(file) {
51
+ if (!file)
52
+ return new Set();
53
+ try {
54
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
56
+ }
57
+ catch {
58
+ return new Set();
59
+ }
60
+ }
61
+ function guardarDichas(file, ids) {
62
+ if (!file)
63
+ return;
64
+ try {
65
+ // Se recorta a las últimas 200: es una memoria para no repetirse, no un
66
+ // registro. Un fichero que solo crece acaba siendo un fichero que nadie
67
+ // borra y que se lee entero en cada commit.
68
+ fs.writeFileSync(file, JSON.stringify([...ids].slice(-200)));
69
+ }
70
+ catch {
71
+ // Sin sitio donde anotar se repetirá una vez. Es el lado seguro.
72
+ }
73
+ }
74
+ /** `CHANGEBOOK_ACIERTOS=off` lo apaga del todo. */
75
+ export function estaSilenciado(env = process.env) {
76
+ return (env.CHANGEBOOK_ACIERTOS ?? '').trim().toLowerCase() === 'off';
77
+ }
78
+ /**
79
+ * La línea para este repo, y la marca para no repetirla.
80
+ *
81
+ * Best-effort de principio a fin: corre dentro del pre-commit y no puede
82
+ * romperlo. Y si no hay nada que celebrar, silencio — que es el estado normal.
83
+ */
84
+ export function acierto(candidatos, dir, env) {
85
+ if (estaSilenciado(env))
86
+ return Promise.resolve('');
87
+ return memoriaPath(dir)
88
+ .catch(() => null)
89
+ .then((file) => {
90
+ const dichas = leerDichas(file);
91
+ const linea = lineaDeAcierto(candidatos, dichas);
92
+ if (!linea)
93
+ return '';
94
+ dichas.add(linea.id);
95
+ guardarDichas(file, dichas);
96
+ return linea.texto;
97
+ })
98
+ .catch(() => '');
99
+ }
100
+ //# sourceMappingURL=aciertos.js.map
@@ -0,0 +1,203 @@
1
+ // ── Alias de módulo, lado CLI ────────────────────────────────────────────────
2
+ //
3
+ // ESPEJO de `fetchModuleAliases` / `canonicalizeModuleRows` en
4
+ // supabase/functions/_shared/analysis.ts. La paridad está testada en
5
+ // test/aliasCliParidad.test.ts, y hace falta: el atlas se alimenta y se lee por
6
+ // TRES superficies —las functions del servidor, el MCP hospedado y este CLI— y
7
+ // una fusión que solo resolviera en dos de ellas haría que el guardián avisara
8
+ // sobre un módulo que el mapa ya no muestra con ese nombre.
9
+ //
10
+ // No se puede importar el original: aquel es código Deno y esto es Node.
11
+ export const SIN_ALIAS = {
12
+ aliases: new Map(),
13
+ hermanas: new Map(),
14
+ };
15
+ /** El MISMO slug que usa el servidor (slugModule) y el mapa de la web. */
16
+ export function slugModule(s) {
17
+ return (s
18
+ .toLowerCase()
19
+ .normalize('NFD')
20
+ .replace(/[\u0300-\u036f]/g, '')
21
+ .replace(/[^a-z0-9]+/g, '-')
22
+ .replace(/^-|-$/g, '') || 'modulo');
23
+ }
24
+ /** Aplana las cadenas (A→B, B→C deja A en C) con tope y detección de ciclo. */
25
+ export function resolveAliasChains(directo) {
26
+ const out = new Map();
27
+ for (const [from, to] of directo) {
28
+ let actual = to;
29
+ const visto = new Set([from]);
30
+ for (let salto = 0; salto < 10; salto++) {
31
+ const siguiente = directo.get(slugModule(actual));
32
+ if (!siguiente || visto.has(slugModule(actual)))
33
+ break;
34
+ visto.add(slugModule(actual));
35
+ actual = siguiente;
36
+ }
37
+ if (slugModule(actual) !== from)
38
+ out.set(from, actual);
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * slug → etiqueta que lo representa. ESPEJO de `etiquetaPorSlug` en
44
+ * supabase/functions/mcp/scope.ts; la paridad la vigila
45
+ * test/paridadCoCambios.test.ts, porque el CLI y el hospedado calculan los
46
+ * mismos co-cambios y una discrepancia aquí los haría diferir en silencio.
47
+ *
48
+ * Existe porque `canonicalModule` NO cubre esta clase: hace
49
+ * `if (aliases.size === 0) return limpia`, así que con la tabla de alias vacía
50
+ * —0 filas en los tres proyectos— «Brief de orientación» y «Brief de
51
+ * orientacion» siguen siendo dos módulos distintos al agrupar.
52
+ *
53
+ * Desempate: la más frecuente; a igualdad, la más reciente. `labels` viene
54
+ * newest-first, así que la primera vista es la más reciente.
55
+ */
56
+ export function etiquetaPorSlug(labels) {
57
+ const cuentas = new Map();
58
+ for (const raw of labels) {
59
+ const label = (raw ?? '').trim();
60
+ if (!label)
61
+ continue;
62
+ const slug = slugModule(label);
63
+ let porEtiqueta = cuentas.get(slug);
64
+ if (!porEtiqueta) {
65
+ porEtiqueta = new Map();
66
+ cuentas.set(slug, porEtiqueta);
67
+ }
68
+ porEtiqueta.set(label, (porEtiqueta.get(label) ?? 0) + 1);
69
+ }
70
+ const out = new Map();
71
+ for (const [slug, porEtiqueta] of cuentas) {
72
+ let mejor = '';
73
+ let mejorN = -1;
74
+ // `>` estricto: al recorrer en orden de aparición, la primera etiqueta (la
75
+ // más reciente) se queda cuando hay empate de frecuencia.
76
+ for (const [label, n] of porEtiqueta) {
77
+ if (n > mejorN) {
78
+ mejor = label;
79
+ mejorN = n;
80
+ }
81
+ }
82
+ out.set(slug, mejor);
83
+ }
84
+ return out;
85
+ }
86
+ export function canonicalModule(label, aliases) {
87
+ const limpia = (label ?? '').trim();
88
+ if (!limpia || aliases.size === 0)
89
+ return limpia;
90
+ return aliases.get(slugModule(limpia)) ?? limpia;
91
+ }
92
+ /**
93
+ * El punto de estrangulamiento: se llama al traer las filas, antes de agregar.
94
+ *
95
+ * ★ `deps` TAMBIÉN (2026-07-31): `change_module.deps` es un array DE NOMBRES DE
96
+ * MÓDULO y `dependentsOf` los casa por texto. Resolver solo la columna dejaba
97
+ * una arista declarada bajo la etiqueta vieja apuntando a un nodo que ya no se
98
+ * llama así, y el dependiente desaparecía del radio de impacto en silencio.
99
+ */
100
+ export function canonicalizeModuleRows(rows, aliases) {
101
+ if (aliases.size === 0)
102
+ return rows;
103
+ return rows.map((r) => {
104
+ const crudo = r;
105
+ const module = crudo.module
106
+ ? canonicalModule(crudo.module, aliases)
107
+ : crudo.module;
108
+ if (!Array.isArray(crudo.deps)) {
109
+ return crudo.module ? { ...r, module } : r;
110
+ }
111
+ // Se deduplica: dos aristas hacia las dos etiquetas de un módulo fusionado
112
+ // son UNA arista, y dejarlas contaría dos veces al mismo dependiente.
113
+ const deps = [
114
+ ...new Set(crudo.deps.map((d) => typeof d === 'string' ? canonicalModule(d, aliases) : d)),
115
+ ];
116
+ return { ...r, module, deps };
117
+ });
118
+ }
119
+ /**
120
+ * Todas las etiquetas CRUDAS que acaban en el mismo canónico, incluida la propia.
121
+ *
122
+ * ESPEJO de `expandModuleNames` en analysis.ts, y existe por lo mismo: el CLI
123
+ * también filtra EN EL SERVIDOR por nombre de módulo (`module=in.(…)` sobre
124
+ * `regression_alerts` y `change_module`). En la base siguen las etiquetas viejas
125
+ * —el alias se resuelve al leer, no se reescribe nada—, así que preguntar por el
126
+ * canónico a secas PIERDE justo las filas que la fusión pretendía recuperar.
127
+ * Se expande ANTES de consultar y se canonicaliza DESPUÉS.
128
+ */
129
+ export function expandModuleNames(names, hermanas) {
130
+ if (hermanas.size === 0)
131
+ return names;
132
+ const out = new Set();
133
+ for (const n of names) {
134
+ out.add(n);
135
+ for (const h of hermanas.get(slugModule(n)) ?? [])
136
+ out.add(h);
137
+ }
138
+ return [...out];
139
+ }
140
+ /** canónico (slug) → etiquetas crudas que resuelven a él. ESPEJO de analysis.ts. */
141
+ export function siblingLabels(filas, aliases) {
142
+ // Se agrupa por el canónico YA RESUELTO, no por el de cada fila: una cadena
143
+ // A→B→C tiene que dejar A, B y C en el mismo grupo, no en dos.
144
+ const grupos = new Map();
145
+ for (const f of filas) {
146
+ const label = (f.alias_label ?? '').trim();
147
+ if (!label)
148
+ continue;
149
+ const canonico = canonicalModule(label, aliases);
150
+ const clave = slugModule(canonico);
151
+ const g = grupos.get(clave) ?? new Set([canonico]);
152
+ g.add(label);
153
+ grupos.set(clave, g);
154
+ }
155
+ // Se indexa por CADA miembro: preguntar por la etiqueta vieja o por la buena
156
+ // tiene que devolver el grupo entero, porque quien consulta no sabe cuál tiene.
157
+ const out = new Map();
158
+ for (const g of grupos.values()) {
159
+ const miembros = [...g];
160
+ for (const m of miembros)
161
+ out.set(slugModule(m), miembros);
162
+ }
163
+ return out;
164
+ }
165
+ /**
166
+ * El id del proyecto que ya lleva dentro el filtro `project_id=eq.…`.
167
+ *
168
+ * Los tres lectores del CLI resuelven el proyecto para poder filtrar y ninguno
169
+ * se queda con el id. Sacarlo de aquí evita una segunda resolución (que en
170
+ * producción tarda ~590 ms, medido) por el gusto de tener la misma cadena.
171
+ */
172
+ export function projectIdOf(projectFilter) {
173
+ return /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
174
+ }
175
+ /**
176
+ * Los alias del proyecto. Best-effort como el resto del contexto del CLI: sin
177
+ * tabla (versión vieja del servidor) o con error, contexto vacío y todo se
178
+ * comporta como antes de que existiera. Un guardián caído por leer alias sería
179
+ * mucho peor que un guardián que no los resuelve.
180
+ *
181
+ * ES LA ÚNICA LECTURA DE `module_alias` EN EL CLI, a propósito: la identidad del
182
+ * módulo ya vivió en tres sitios una vez y divergió sin ruido.
183
+ */
184
+ export async function aliasesFor(db, projectId) {
185
+ if (!projectId)
186
+ return SIN_ALIAS;
187
+ try {
188
+ const rows = await db.rest(`module_alias?select=alias_slug,alias_label,canonical&project_id=eq.${projectId}&limit=500`);
189
+ const directo = new Map();
190
+ for (const r of rows) {
191
+ const from = (r.alias_slug ?? '').trim();
192
+ const to = (r.canonical ?? '').trim();
193
+ if (from && to)
194
+ directo.set(from, to);
195
+ }
196
+ const aliases = resolveAliasChains(directo);
197
+ return { aliases, hermanas: siblingLabels(rows, aliases) };
198
+ }
199
+ catch {
200
+ return SIN_ALIAS;
201
+ }
202
+ }
203
+ //# sourceMappingURL=aliasDeModulo.js.map
package/dist/analyze.js CHANGED
@@ -6,9 +6,37 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import { atlasWebUrl } from "./browser.js";
9
- import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
9
+ import { commitDiff, esFicheroGenerado, execFileAsync, FICHEROS_GENERADOS, ficherosDelCommit, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
10
10
  import { canonicalDiffHash } from "./canonical.js";
11
11
  import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
12
+ /**
13
+ * Por qué se salta un commit sin diff analizable. Tres causas, tres frases.
14
+ *
15
+ * HASTA EL 04/08 ERA UNA SOLA, Y ADIVINABA: «has no analyzable diff (merge?)».
16
+ * `commitDiff` excluye los ficheros que ChangeBook genera —`CLAUDE.md`,
17
+ * `AGENTS.md`— para no registrar sus propias regeneraciones en bucle, así que un
18
+ * commit normal que solo toque esos devuelve diff vacío igual que un merge. El
19
+ * `0bac769` de este repo era exactamente eso y el mensaje mandaba a buscar un
20
+ * merge que no existía.
21
+ *
22
+ * Saltar sigue siendo lo correcto en los tres casos: lo que cambia es que el
23
+ * instrumento sepa distinguirlos en vez de elegir uno (invariante 17 del repo).
24
+ *
25
+ * Es una función aparte y pura para que se pueda medir la ELECCIÓN sin montar
26
+ * un repo ni capturar `console.error`.
27
+ */
28
+ export function motivoDelSalto(hash, ficheros) {
29
+ const corto = hash.slice(0, 8);
30
+ if (!ficheros.length) {
31
+ return `Commit ${corto} no toca ningun fichero (merge o commit vacio). Skipped.`;
32
+ }
33
+ if (ficheros.every(esFicheroGenerado)) {
34
+ return `Commit ${corto} solo toca ficheros que genera ChangeBook (${ficheros.join(", ")}). Saltado a proposito: regenerar el mapa no es un cambio de producto.`;
35
+ }
36
+ // Ni vacío ni todo generado: aquí no hay explicación conocida, y decirlo así
37
+ // es el punto. Inventar una tercera causa plausible sería repetir el bug.
38
+ return `Commit ${corto} toca ${ficheros.length} fichero(s) y aun asi no deja diff que analizar. Skipped. Ficheros: ${ficheros.join(", ")}`;
39
+ }
12
40
  export async function analyze(db, options = {}) {
13
41
  const cwd = path.resolve(options.dir ?? process.cwd());
14
42
  const projectName = projectNameFor(cwd);
@@ -27,7 +55,17 @@ export async function analyze(db, options = {}) {
27
55
  agentSummary = usableSummary(meta.message) ?? undefined;
28
56
  rawDiff = await commitDiff(cwd, meta.hash);
29
57
  if (!rawDiff.trim()) {
30
- console.error(`Commit ${meta.hash.slice(0, 8)} has no analyzable diff (merge?). Skipped.`);
58
+ // Un diff vacío tiene DOS causas y hasta el 04/08 se contaban como una:
59
+ // el commit no toca nada (un merge), o toca solo ficheros que ChangeBook
60
+ // genera y que `commitDiff` excluye a propósito. Decir «(merge?)» de lo
61
+ // segundo es adivinar, y adivinar mal: `0bac769` era un commit normal de
62
+ // CLAUDE.md y AGENTS.md, y el mensaje mandaba a buscar un merge que no
63
+ // existía.
64
+ //
65
+ // Saltarlo sigue siendo lo correcto en los dos casos. Lo que cambia es
66
+ // que ahora el instrumento sabe por qué está saltando (invariante 17).
67
+ const ficheros = await ficherosDelCommit(cwd, meta.hash);
68
+ console.error(motivoDelSalto(meta.hash, ficheros));
31
69
  return;
32
70
  }
33
71
  }
package/dist/badge.js ADDED
@@ -0,0 +1,159 @@
1
+ import { projectNameFor } from './git.js';
2
+ import { slugifyProject } from './guard.js';
3
+ import { REPO_JOVEN } from './scan.js';
4
+ /**
5
+ * Del informe a la fila. Pura, para poder probar la regla del repo joven sin
6
+ * red: es la que decide si el badge enseña un porcentaje o se calla.
7
+ */
8
+ export function filaDeBadge(informe, ids, ahora = new Date().toISOString()) {
9
+ return {
10
+ ...ids,
11
+ commits: informe.commits,
12
+ ficheros_vivos: informe.ficherosVivos,
13
+ concentracion_ficheros: informe.concentracion.ficheros,
14
+ concentracion_pct: informe.concentracion.porcentaje,
15
+ // MISMO umbral que usa `scan` para callar el porcentaje en pantalla. Si los
16
+ // dos no coinciden, el badge afirma lo que el informe se negó a afirmar.
17
+ joven: informe.commits < REPO_JOVEN,
18
+ updated_at: ahora,
19
+ };
20
+ }
21
+ /** El markdown que el dueño pega en su README. */
22
+ export function snippetDeBadge(base, token) {
23
+ const img = `${base}/functions/v1/badge?token=${token}`;
24
+ return `[![ChangeBook](${img})](https://changebook.app)`;
25
+ }
26
+ /**
27
+ * Lo que se hace público al encender el badge, dicho ANTES de encenderlo.
28
+ *
29
+ * No es cortesía: la primera versión ataba el badge a `share_token`, o sea que
30
+ * pedir una imagen publicaba el atlas entero —módulos, riesgos y el resumen en
31
+ * llano de cada cambio—. Son dos permisos de tamaños muy distintos y juntarlos
32
+ * hacía que el pequeño costara el precio del grande.
33
+ */
34
+ export const LO_QUE_SE_PUBLICA = [
35
+ 'El badge hace públicos CUATRO NÚMEROS, y nada más:',
36
+ ' · cuántos commits tiene el repo',
37
+ ' · cuántos ficheros vivos',
38
+ ' · cuántos de ellos concentran la mitad de los cambios, y su porcentaje',
39
+ '',
40
+ 'NO se publica: ni código, ni diffs, ni nombres de fichero, ni nombres de',
41
+ 'módulo, ni resúmenes de tus cambios, ni fechas. Es una llave aparte de la de',
42
+ 'compartir el atlas: encender el badge NO publica tu historial.',
43
+ '',
44
+ 'Se apaga cuando quieras con: changebook scan --badge --off',
45
+ ].join('\n');
46
+ /**
47
+ * El proyecto de este repo, con la MISMA cascada que usa `projectFilterFor`:
48
+ * slug primero, luego nombre, y si la cuenta solo tiene uno, ése.
49
+ *
50
+ * Buscar por `name=eq.` a secas —que es lo que hacía la primera versión— falla
51
+ * en cuanto la carpeta no se llama exactamente igual que el proyecto: aquí el
52
+ * directorio es `AppAtlas` y el proyecto `appatlas`, y el comando contestaba
53
+ * «no hay ningún proyecto llamado AppAtlas» teniéndolo delante. Se vio
54
+ * ejecutándolo, no leyéndolo.
55
+ */
56
+ export async function proyectoDe(db, nombre) {
57
+ const campos = 'select=id,user_id,badge_token';
58
+ const slug = slugifyProject(nombre);
59
+ const buscar = (q) => db.rest(`projects?${campos}&${q}&limit=1`);
60
+ if (slug) {
61
+ const porSlug = await buscar(`slug=eq.${encodeURIComponent(slug)}`);
62
+ if (porSlug[0])
63
+ return porSlug[0];
64
+ }
65
+ const porNombre = await buscar(`name=eq.${encodeURIComponent(nombre)}`);
66
+ if (porNombre[0])
67
+ return porNombre[0];
68
+ // Con UN solo proyecto no hay frontera que proteger: no hay entre qué elegir.
69
+ const todos = await db.rest(`projects?${campos}&order=created_at.asc&limit=2`);
70
+ return todos.length === 1 ? todos[0] : null;
71
+ }
72
+ const SIN_PROYECTO = (nombre) => `No hay ningún proyecto llamado "${nombre}" en tu cuenta de ChangeBook.\n` +
73
+ `Analiza al menos un commit primero (changebook analyze) y vuelve a intentarlo.`;
74
+ /**
75
+ * Publica los números y devuelve el snippet, encendiendo la llave si hace falta.
76
+ *
77
+ * LA LLAVE SE ENCIENDE AQUÍ, y es una decisión que cambió. La primera versión
78
+ * se apoyaba en `share_token` y se negaba a crear nada: encender ESE token
79
+ * habría convertido «quiero una imagen» en «publico mi atlas entero», que no es
80
+ * lo mismo ni de lejos. Con llave propia el cálculo es otro: lo que se expone
81
+ * son exactamente los cuatro números que el usuario va a ver dibujados, y ha
82
+ * escrito el comando que los pide. Encenderla en silencio seguiría estando mal,
83
+ * así que el comando imprime `LO_QUE_SE_PUBLICA` antes.
84
+ */
85
+ export async function publicarBadge(db, dir, informe) {
86
+ const nombre = projectNameFor(dir);
87
+ const proyecto = await proyectoDe(db, nombre);
88
+ if (!proyecto)
89
+ return { ok: false, motivo: SIN_PROYECTO(nombre) };
90
+ // LOS NÚMEROS PRIMERO, LA LLAVE DESPUÉS. El orden no es indiferente: encender
91
+ // la llave abre una URL pública, y hacerlo antes de saber si hay algo que
92
+ // servir deja el badge encendido aunque la publicación falle. Pasó de verdad
93
+ // —el primer intento reventó con un 42501 DESPUÉS de haber encendido la
94
+ // llave—, y el usuario vio un error creyendo que no había pasado nada
95
+ // mientras la URL ya existía. Un permiso se abre cuando ya hay algo detrás.
96
+ //
97
+ // Por RPC y no por un upsert a la tabla. Un upsert escribe TODAS las columnas
98
+ // enviadas, incluidas `project_id` y `user_id`, que están fuera del grant por
99
+ // columna a propósito: son la frontera entre cuentas. Medido ejecutándolo —
100
+ // 42501— y la salida fácil (añadirlas al grant) es el agujero que
101
+ // 20260710120000 cerró en `regression_alerts`. La función valida la propiedad
102
+ // y el cliente se queda sin escritura directa.
103
+ const fila = filaDeBadge(informe, {
104
+ project_id: proyecto.id,
105
+ user_id: proyecto.user_id,
106
+ });
107
+ await db.callRpc('badge_publicar', {
108
+ p_project_id: fila.project_id,
109
+ p_commits: fila.commits,
110
+ p_ficheros_vivos: fila.ficheros_vivos,
111
+ p_concentracion_ficheros: fila.concentracion_ficheros,
112
+ p_concentracion_pct: fila.concentracion_pct,
113
+ p_joven: fila.joven,
114
+ });
115
+ // El token lo genera la BASE, no el cliente: uno elegido por quien lo usa se
116
+ // puede adivinar o reutilizar entre proyectos. `coalesce` dentro de la función
117
+ // hace que reencenderlo devuelva el mismo, así que republicar no invalida el
118
+ // badge que ya está pegado en un README.
119
+ const token = proyecto.badge_token ??
120
+ (await db.callRpc('badge_encender', {
121
+ p_project_id: proyecto.id,
122
+ }));
123
+ // `callRpc` devuelve null ante un cuerpo vacío (204) en vez de lanzar, que es
124
+ // lo correcto para las RPC `void` — pero aquí un null significaría imprimir
125
+ // una URL con la palabra «null» dentro y llamarlo éxito. Se para en seco: un
126
+ // snippet roto pegado en un README es peor que un error en la terminal.
127
+ if (!token) {
128
+ return {
129
+ ok: false,
130
+ motivo: 'La base no devolvió la llave del badge. No se ha impreso ningún\n' +
131
+ 'snippet: pegar una URL incompleta en tu README sería peor. Vuelve a\n' +
132
+ 'intentarlo, y si sigue, es un fallo del servidor y no tuyo.',
133
+ };
134
+ }
135
+ return {
136
+ ok: true,
137
+ snippet: snippetDeBadge(db.baseUrl, token),
138
+ nuevo: !proyecto.badge_token,
139
+ };
140
+ }
141
+ /**
142
+ * Apaga el badge: borra la llave y los números.
143
+ *
144
+ * Los números se van con la llave a propósito. Si el dueño retira el badge, los
145
+ * datos que solo existían para dibujarlo no se quedan ahí esperando a que
146
+ * alguien los vuelva a exponer.
147
+ */
148
+ export async function apagarBadge(db, dir) {
149
+ const nombre = projectNameFor(dir);
150
+ const proyecto = await proyectoDe(db, nombre);
151
+ if (!proyecto)
152
+ return { ok: false, motivo: SIN_PROYECTO(nombre) };
153
+ if (!proyecto.badge_token) {
154
+ return { ok: false, motivo: `"${nombre}" no tiene ningún badge encendido.` };
155
+ }
156
+ await db.callRpc('badge_apagar', { p_project_id: proyecto.id });
157
+ return { ok: true, snippet: '', apagado: true };
158
+ }
159
+ //# sourceMappingURL=badge.js.map
package/dist/context.js CHANGED
@@ -24,6 +24,7 @@
24
24
  import * as fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { feedWarningFor } from "./feed.js";
27
+ import { avisoDeHookMudoPara } from "./hookMudo.js";
27
28
  import { fetchBriefSection } from "./sync.js";
28
29
  import { commitAliasesShort, derivaContraHead } from "./tools.js";
29
30
  /** Hard ceiling on the critical path: past this, emit nothing and move on. */
@@ -204,6 +205,11 @@ async function buildSessionContext(db, dir) {
204
205
  // Local and offline: one small file plus a git rev-parse. It cannot fail
205
206
  // because of the network or the session, which is the point.
206
207
  const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
208
+ // El segundo silencio (C6): el aviso previo a editar puede llevar semanas sin
209
+ // darse y el agente no tiene forma de notarlo — para él, «no me dijeron nada»
210
+ // y «no había nada que decir» son la misma frase. Por eso se le dice a él
211
+ // también y no solo al humano en el pre-commit.
212
+ const mudo = await avisoDeHookMudoPara(dir, { audience: "agent" }).catch(() => "");
207
213
  let brief = null;
208
214
  try {
209
215
  brief = await buildPayload(db, dir);
@@ -211,7 +217,7 @@ async function buildSessionContext(db, dir) {
211
217
  catch {
212
218
  brief = null;
213
219
  }
214
- const parts = [pulse, brief].filter(Boolean);
220
+ const parts = [pulse, mudo, brief].filter(Boolean);
215
221
  return parts.length > 0 ? parts.join("\n\n") : null;
216
222
  }
217
223
  export async function printContext(db, dir) {
package/dist/git.js CHANGED
@@ -75,8 +75,9 @@ export function usableSummary(message) {
75
75
  * exactamente eso.
76
76
  *
77
77
  * Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
78
- * salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
79
- * mapa no es un cambio de producto.
78
+ * salta, que es la respuesta correcta: regenerar el mapa no es un cambio de
79
+ * producto. Lo que NO era correcto era cómo lo contaba — ver
80
+ * `ficherosDelCommit` aquí abajo y el mensaje en `analyze.ts`.
80
81
  */
81
82
  export const FICHEROS_GENERADOS = [
82
83
  ":(exclude)CLAUDE.md",
@@ -104,6 +105,45 @@ export async function commitDiff(cwd, hash) {
104
105
  throw new Error(`Could not read the diff of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
105
106
  }
106
107
  }
108
+ /**
109
+ * Los ficheros que toca un commit, SIN excluir los generados.
110
+ *
111
+ * POR QUÉ EXISTE. `commitDiff` excluye `CLAUDE.md`/`AGENTS.md`, así que un
112
+ * commit que solo toca esos devuelve diff vacío — igual que un merge, que no
113
+ * tiene diff propio. Dos situaciones distintas, una sola señal, y `analyze`
114
+ * elegía la equivocada: decía «has no analyzable diff (merge?)» de un commit
115
+ * normal que ni siquiera era un merge (medido el 04/08 con `0bac769`).
116
+ *
117
+ * Es la invariante 17 del repo dentro del producto: la ausencia de diff no
118
+ * prueba la ausencia de cambio mientras el instrumento no sepa distinguir por
119
+ * qué está ausente. Esta función es lo que le da esa segunda pregunta.
120
+ *
121
+ * Devuelve `[]` si el commit no toca ningún fichero — el caso del merge de
122
+ * verdad, y también el de un commit vacío.
123
+ */
124
+ export async function ficherosDelCommit(cwd, hash) {
125
+ try {
126
+ const { stdout } = await execFileAsync("git",
127
+ // Mismo `--end-of-options` que commitDiff, por el mismo motivo.
128
+ ["show", "--pretty=format:", "--name-only", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
129
+ return stdout
130
+ .split("\n")
131
+ .map((l) => l.trim())
132
+ .filter(Boolean);
133
+ }
134
+ catch (error) {
135
+ throw new Error(`Could not read the files of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
136
+ }
137
+ }
138
+ /**
139
+ * ¿Es un fichero de los que ChangeBook genera? Se decide con las MISMAS rutas
140
+ * que excluye `commitDiff`, derivadas de ella y no reescritas: dos listas que
141
+ * pudieran separarse harían que el mensaje explicara un salto que no ocurrió.
142
+ */
143
+ export function esFicheroGenerado(ruta) {
144
+ const nombre = ruta.split("/").pop() ?? "";
145
+ return FICHEROS_GENERADOS.some((patron) => patron.replace(/^:\(exclude\)(\*\*\/)?/, "") === nombre);
146
+ }
107
147
  /** Pulls git's stderr out of an execFile rejection for a readable message. */
108
148
  export function gitErrorMessage(error) {
109
149
  if (typeof error === "object" &&