changebook 0.9.1 → 0.10.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 CHANGED
@@ -57,6 +57,7 @@ your other servers.
57
57
  | `changebook scan [dir] [--json\|--card\|--badge]` | Coupling report for **any** repo from its git history alone: no account, no network, writes nothing — run it on something you just cloned. `--card` renders a shareable SVG, `--badge` publishes four numbers and prints the README snippet (needs an account; `--badge --off` turns it off). The badge exposes those four numbers and nothing else — not your code, modules or change summaries. |
58
58
  | `changebook silence [dir]` | How often the `PreToolUse` hook stays quiet, with both raw numbers. Local by design: it answers the day you install it, not two days later. |
59
59
  | `changebook friction [dir]` | Where the agent's work gets redone in this repo, read from the **local** Claude Code transcripts — no prose leaves the machine, only paths, modules, dates and a session hash. Says **MUERTO** if the repo has edits and it read nothing, and reports its own blind spot: edits made through the shell (`sed -i`, heredocs) leave no before/after, so ~25% of writes are invisible to it and it says so. |
60
+ | `changebook spend [dir]` | Where the model money goes and why: cost by month and model, whether the prompt cache is actually paying for itself (it reports the median gap between calls against the cache window — that gap is what decides it), how often responses hit the output cap, and whether every analysis left a row in the billing ledger. Account-wide: `usage_ledger` has no project column. |
60
61
  | `changebook audit [dir]` | Static check of your agent setup — no network, no credentials, nothing written. Flags rules in `CLAUDE.md`/`AGENTS.md` that cite files which no longer exist (with a «did you mean…»), how much context you pay every session, and whether the impact hook is actually installed. Every check here was written after it found something real, not from a best-practices list. |
61
62
  | `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
62
63
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
package/dist/hook.js CHANGED
@@ -22,6 +22,7 @@ function cliEntry() {
22
22
  }
23
23
  const POST_COMMIT_MARKER = "# changebook post-commit hook";
24
24
  const PRE_COMMIT_MARKER = "# changebook pre-commit guard";
25
+ const POST_REWRITE_MARKER = "# changebook post-rewrite hook";
25
26
  // The hook log grows by one run per commit, so it needs a ceiling — but one
26
27
  // that keeps enough history to diagnose a streak of failures, which is the
27
28
  // whole reason it is worth keeping at all.
@@ -52,6 +53,40 @@ fi
52
53
  ( { date -u +'=== %Y-%m-%dT%H:%M:%SZ'; ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD; } >> "$LOG" 2>&1 & ) || true
53
54
  `;
54
55
  }
56
+ /**
57
+ * Exported for tests. `post-rewrite` is the hook git runs after `git rebase`
58
+ * and `git commit --amend` — the two ways commits are born WITHOUT firing
59
+ * `post-commit`, and therefore the source of the ~3.5% of commits the atlas
60
+ * never saw (the `unanalyzed` rows).
61
+ *
62
+ * git feeds one `<old-sha> <new-sha>` line per rewritten commit on stdin. We
63
+ * count them and hand that number to ONE background `import --commits N`:
64
+ * - one process, not N — a 50-commit rebase must not spawn 50 node processes;
65
+ * - `import` dedups server-side, so the already-analyzed commits in that
66
+ * window are free and only the genuinely-new (rewritten) ones are billed,
67
+ * through the cheaper Batch API;
68
+ * - background + `|| true` + fail-open, exactly like post-commit: a rebase is
69
+ * never blocked or slowed, and a logged-out/offline CLI can't break it.
70
+ */
71
+ export function postRewriteScript() {
72
+ const trimmed = `${LOG_TRIM_KEEP_BYTES}`;
73
+ return `#!/bin/sh
74
+ ${POST_REWRITE_MARKER} — reconciles commits rewritten by rebase/amend, which
75
+ # post-commit never sees. Runs in the background and never blocks. Remove with:
76
+ # changebook hook uninstall
77
+ LOG="$(git rev-parse --git-path changebook-hook.log)"
78
+ if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt ${LOG_MAX_BYTES} ]; then
79
+ tail -c ${trimmed} "$LOG" > "$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG"
80
+ fi
81
+ # One "<old> <new>" line per rewritten commit; count them so import reconciles
82
+ # exactly that many. Already-analyzed commits are deduped server-side (free).
83
+ n=0
84
+ while read old new rest; do
85
+ [ -n "$new" ] && n=$((n + 1))
86
+ done
87
+ [ "$n" -gt 0 ] && ( { date -u +'=== %Y-%m-%dT%H:%M:%SZ (post-rewrite)'; ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} import --commits "$n"; } >> "$LOG" 2>&1 & ) || true
88
+ `;
89
+ }
55
90
  /** Exported for tests: the contract between this script and guard.ts. */
56
91
  export function preCommitScript() {
57
92
  // Foreground (a warning printed after the commit would be pointless) but
@@ -83,6 +118,12 @@ const HOOKS = [
83
118
  script: preCommitScript,
84
119
  installedNote: "Before each commit, you'll be warned if a staged file belongs to a module with an open alert (set CHANGEBOOK_GUARD=block to make it blocking, =off to silence it).",
85
120
  },
121
+ {
122
+ name: "post-rewrite",
123
+ marker: POST_REWRITE_MARKER,
124
+ script: postRewriteScript,
125
+ installedNote: "After a rebase or --amend, the rewritten commits are reconciled into the atlas in the background (deduped server-side, so only genuinely new commits are billed).",
126
+ },
86
127
  ];
87
128
  async function hookPath(dir, name) {
88
129
  // --git-path (not --git-dir + "/hooks") resolves core.hooksPath — set by
package/dist/impact.js CHANGED
@@ -1415,7 +1415,7 @@ marca = { contado: false }) {
1415
1415
  // Servir la nota no vale nada si la nota que importa no entra.
1416
1416
  //
1417
1417
  // Total y determinista: el desempate por nombre mantiene el string estable
1418
- // entre sesiones, que es lo que evita pagar una escritura de caché a 1,25×
1418
+ // entre sesiones, que es lo que evita pagar una escritura de caché (×2 desde el 24/08)
1419
1419
  // en vez de una lectura a 0,1×.
1420
1420
  .sort((a, b) => b.alerts.length - a.alerts.length ||
1421
1421
  b.priorRegressions - a.priorRegressions ||
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * extension: login, analyze, sync, init, open.
10
10
  */
11
11
  import { auditarSetup, informeDeAuditoria } from "./audit.js";
12
+ import { fugasDelLedger, informeDeGasto, textoDeGasto, } from "./spend.js";
12
13
  import { readFileSync } from "node:fs";
13
14
  import * as path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
@@ -71,6 +72,10 @@ Usage:
71
72
  changebook friction [dir] Where the agent's work gets redone in this repo,
72
73
  read from the local Claude Code transcripts. Says
73
74
  MUERTO if the repo has edits and it read nothing
75
+ changebook spend [dir] Where the model money goes and why: cost by month and
76
+ model, whether the prompt cache is actually paying for
77
+ itself, how often responses hit the output cap, and
78
+ whether every analysis left a row in the billing ledger
74
79
  changebook audit [dir] Static check of your agent setup: rules in CLAUDE.md
75
80
  that cite files which no longer exist, how much
76
81
  context you pay per session, whether the hook is on.
@@ -368,6 +373,49 @@ async function main() {
368
373
  console.log(rankingDeFriccion(dir));
369
374
  return;
370
375
  }
376
+ case "spend": {
377
+ // Lee `usage_ledger` y `changelog` y no escribe nada. El calculo entero
378
+ // es puro y vive en `spend.ts`; aqui solo esta la red.
379
+ const db = new Supabase();
380
+ requireCredentials(db);
381
+ const filas = [];
382
+ for (let o = 0;; o += 1000) {
383
+ const p = await db.rest("usage_ledger?select=model,action,input_tokens,output_tokens," +
384
+ "cache_creation_input_tokens,cache_read_input_tokens,diff_chars," +
385
+ `raw_diff_chars,created_at,changelog_id&order=created_at.asc&limit=1000&offset=${o}`);
386
+ filas.push(...p);
387
+ if (p.length < 1000 || o > 20000)
388
+ break;
389
+ }
390
+ console.log(textoDeGasto(informeDeGasto(filas)));
391
+ // La integridad del cobro: un analisis sin fila en el ledger es servicio
392
+ // regalado, y no se ve en ningun panel. Best-effort — si falla la
393
+ // consulta, el informe de arriba sigue siendo cierto.
394
+ // SIN filtro de proyecto: `usage_ledger` no tiene `project_id`, así que
395
+ // acotar un lado y el otro no compara nada. El informe es de CUENTA.
396
+ //
397
+ // Y PAGINADO. `limit=10000` NO sobrepasa el techo de PostgREST: devuelve
398
+ // 1.000 y se calla, y el informe decía «1000 de 1000 análisis, sin fugas»
399
+ // — un truncamiento leído como dato completo, que es la Invariante 17 de
400
+ // este repo mordiendo a la herramienta que la publica.
401
+ const analisis = [];
402
+ for (let o = 0;; o += 1000) {
403
+ const p = await db
404
+ .rest(`changelog?select=id&order=created_at.asc&limit=1000&offset=${o}`)
405
+ .catch(() => []);
406
+ analisis.push(...p);
407
+ if (p.length < 1000 || o > 50000)
408
+ break;
409
+ }
410
+ if (analisis.length > 0) {
411
+ const fugas = fugasDelLedger(analisis, filas);
412
+ console.log("");
413
+ console.log(fugas === 0
414
+ ? `COBRO: ${analisis.length} de ${analisis.length} análisis con fila en el ledger. Sin fugas.`
415
+ : `⚠ COBRO: ${fugas} de ${analisis.length} análisis SIN fila en el ledger — servidos sin cobrar.`);
416
+ }
417
+ return;
418
+ }
371
419
  case "audit": {
372
420
  // Como `silence` y `scan`: sin credenciales, sin red, sin escribir. Todo
373
421
  // lo que dice sale de ficheros del repo, así que contesta el mismo día en
package/dist/spend.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `changebook spend` — a dónde va el dinero del modelo, y por qué.
3
+ *
4
+ * POR QUÉ EXISTE. El 24/08 todo esto se midió con scripts de usar y tirar, y de
5
+ * ahí salieron dos arreglos que valían la pena: la caché caducaba justo en el
6
+ * filo de su ventana (mediana entre llamadas 5,1 min contra 5 de ventana) y el
7
+ * tope de salida cortaba el 7,2% de los análisis, multiplicando por 17 la
8
+ * probabilidad de perder el mapa del commit. Ninguna de las dos se veía en
9
+ * ningún panel: había que ir a contar filas.
10
+ *
11
+ * La tabla de precios NO se reimplementa aquí — se importa de `usage.ts`, que
12
+ * es una de las tres copias que `usageParity.test.ts` mantiene atadas. Una
13
+ * cuarta sería la que se queda atrás.
14
+ */
15
+ import { CACHE_1H_DESDE, costOfRow, fmtUsd, percentile, } from "./usage.js";
16
+ /** La ventana de caché que usa el servidor hoy. Sólo para el diagnóstico. */
17
+ export const VENTANA_DE_CACHE_MIN = 60;
18
+ function entrada(f) {
19
+ return (f.cache_creation_input_tokens ?? 0) + (f.cache_read_input_tokens ?? 0);
20
+ }
21
+ /**
22
+ * El tope de salida, DEDUCIDO DE LOS DATOS y no escrito a mano.
23
+ *
24
+ * Un tope se delata como un pico: decenas de llamadas con exactamente el mismo
25
+ * número de tokens de salida. Deducirlo importa porque el histórico tiene
26
+ * VARIOS —1.800 hasta julio, 3.500 después, 7.000 desde el 24/08— y un número
27
+ * fijo etiquetaría mal las filas viejas, que es peor que no etiquetarlas.
28
+ */
29
+ export function topeDeducido(filas) {
30
+ const cuenta = new Map();
31
+ for (const f of filas) {
32
+ const o = f.output_tokens ?? 0;
33
+ if (o > 0)
34
+ cuenta.set(o, (cuenta.get(o) ?? 0) + 1);
35
+ }
36
+ // Un pico de verdad: al menos 10 llamadas clavadas en el mismo valor, y ese
37
+ // valor por encima de la mediana (un tope corta por arriba, no por abajo).
38
+ const med = percentile(filas.map((f) => f.output_tokens ?? 0), 50) ?? 0;
39
+ const picos = [...cuenta].filter(([v, n]) => n >= 10 && v > med);
40
+ if (picos.length === 0)
41
+ return null;
42
+ return Math.max(...picos.map(([v]) => v));
43
+ }
44
+ export function informeDeGasto(filas) {
45
+ if (filas.length === 0)
46
+ return null;
47
+ const orden = [...filas].sort((a, b) => a.created_at.localeCompare(b.created_at));
48
+ const porMes = new Map();
49
+ const porModelo = new Map();
50
+ let total = 0;
51
+ let leen = 0;
52
+ let tocan = 0;
53
+ let R = 0;
54
+ let W = 0;
55
+ for (const f of orden) {
56
+ const c = costOfRow(f);
57
+ total += c;
58
+ const mes = f.created_at.slice(0, 7);
59
+ porMes.set(mes, (porMes.get(mes) ?? 0) + c);
60
+ const m = f.model ?? "(sin modelo)";
61
+ porModelo.set(m, (porModelo.get(m) ?? 0) + c);
62
+ if (entrada(f) > 0)
63
+ tocan += 1;
64
+ if ((f.cache_read_input_tokens ?? 0) > 0)
65
+ leen += 1;
66
+ R += f.cache_read_input_tokens ?? 0;
67
+ W += f.cache_creation_input_tokens ?? 0;
68
+ }
69
+ const huecos = [];
70
+ for (let i = 1; i < orden.length; i++) {
71
+ huecos.push((Date.parse(orden[i].created_at) - Date.parse(orden[i - 1].created_at)) / 60000);
72
+ }
73
+ const salidas = orden.map((f) => f.output_tokens ?? 0);
74
+ const tope = topeDeducido(orden);
75
+ return {
76
+ filas: orden.length,
77
+ desde: orden[0].created_at.slice(0, 10),
78
+ hasta: orden[orden.length - 1].created_at.slice(0, 10),
79
+ total,
80
+ porMes: [...porMes].sort(),
81
+ porModelo: [...porModelo].sort((a, b) => b[1] - a[1]),
82
+ cache: {
83
+ leen,
84
+ tocan,
85
+ ratio: W > 0 ? R / W : 0,
86
+ conVentanaActual: orden.filter((f) => f.created_at >= CACHE_1H_DESDE).length,
87
+ medianaMin: percentile(huecos, 50),
88
+ dentroDeVentana: huecos.filter((g) => g <= VENTANA_DE_CACHE_MIN).length,
89
+ huecos: huecos.length,
90
+ },
91
+ salida: {
92
+ mediana: percentile(salidas, 50),
93
+ p90: percentile(salidas, 90),
94
+ tope,
95
+ // «Cerca» y no «igual»: la respuesta puede parar una pizca antes.
96
+ enTope: tope === null ? 0 : salidas.filter((o) => o >= tope - 5).length,
97
+ },
98
+ };
99
+ }
100
+ const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : 0);
101
+ export function textoDeGasto(i) {
102
+ if (!i)
103
+ return "Sin llamadas al modelo registradas todavía.";
104
+ const l = [];
105
+ l.push(`${i.filas} llamadas al modelo, del ${i.desde} al ${i.hasta}.`, "");
106
+ l.push("GASTO POR MES");
107
+ for (const [m, v] of i.porMes)
108
+ l.push(` ${m} ${fmtUsd(v)}`);
109
+ l.push(` ${"-".repeat(16)}`, ` TOTAL ${fmtUsd(i.total)}`, "");
110
+ l.push("POR MODELO");
111
+ for (const [m, v] of i.porModelo)
112
+ l.push(` ${fmtUsd(v).padStart(9)} ${m}`);
113
+ l.push("", "CACHÉ");
114
+ l.push(` leen de caché ${i.cache.leen} de ${i.cache.tocan} llamadas (${pct(i.cache.leen, i.cache.tocan)}%)`);
115
+ l.push(` lecturas por escritura: ${i.cache.ratio.toFixed(2)}`);
116
+ if (i.cache.medianaMin !== null) {
117
+ l.push(` mediana entre llamadas: ${i.cache.medianaMin.toFixed(1)} min · ventana ${VENTANA_DE_CACHE_MIN} min`);
118
+ l.push(` huecos que caben en la ventana: ${i.cache.dentroDeVentana} de ${i.cache.huecos} (${pct(i.cache.dentroDeVentana, i.cache.huecos)}%)`);
119
+ // EL RATIO DE ARRIBA ES HISTÓRICO, y el histórico se escribió con otra
120
+ // ventana. Juzgar el ajuste de hoy con él es exactamente el error de
121
+ // comparar antes y después mezclándolos: por eso el aviso espera a tener
122
+ // datos suficientes escritos YA con la ventana actual.
123
+ const MINIMO = 50;
124
+ if (i.cache.conVentanaActual < MINIMO) {
125
+ l.push(` · Sólo ${i.cache.conVentanaActual} llamadas con la ventana actual (hacen falta ${MINIMO}`, " para juzgarla). El ratio de arriba describe el pasado, no el ajuste de hoy.");
126
+ }
127
+ else if (i.cache.ratio < 1.11) {
128
+ l.push(" ⚠ Por debajo de 1,11 lecturas por escritura la caché NO compensa:", " una escritura de ventana larga cuesta el doble de la entrada.");
129
+ }
130
+ }
131
+ l.push("", "SALIDA (lo que la caché NO puede abaratar)");
132
+ l.push(` mediana ${i.salida.mediana ?? "?"} tokens · p90 ${i.salida.p90 ?? "?"}`);
133
+ if (i.salida.tope !== null) {
134
+ l.push(` tope deducido de los datos: ${i.salida.tope} · llamadas que lo tocan: ${i.salida.enTope} (${pct(i.salida.enTope, i.filas)}%)`);
135
+ if (i.salida.enTope > 0) {
136
+ l.push(" ⚠ Una respuesta cortada llega sin el mapa del commit mucho más a menudo.", " Medido el 24/08: 17 veces más. Subir el tope no cuesta nada en las", " llamadas que no lo alcanzan — se factura por token producido.");
137
+ }
138
+ }
139
+ return l.join("\n");
140
+ }
141
+ /**
142
+ * Análisis que no dejaron fila en el ledger: eso es servicio sin cobrar.
143
+ *
144
+ * ⚠ ES DE CUENTA, NO DE PROYECTO, y no por comodidad: `usage_ledger` no tiene
145
+ * `project_id` — sólo `user_id` y `changelog_id`. Filtrar los análisis por
146
+ * proyecto y el ledger no compararía 741 contra 1.782 y diría «sin fugas» por
147
+ * accidente. Quien llama tiene que pasarle TODOS los análisis de la cuenta.
148
+ */
149
+ export function fugasDelLedger(analisis, filas) {
150
+ const conFila = new Set(filas.map((f) => f.changelog_id).filter(Boolean));
151
+ return analisis.filter((a) => !conFila.has(a.id)).length;
152
+ }
153
+ //# sourceMappingURL=spend.js.map
package/dist/toolUsage.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import { z } from "zod";
15
15
  import { recordRead } from "./agregados.js";
16
16
  import { RO, errorResult, servedCharsOf, toolResult, } from "./respuestas.js";
17
- import { fmtUsd, latencyByTool, summarizeUsage, } from "./usage.js";
17
+ import { fmtUsd, latencyByTool, readsByKind, readsBreakdownLines, summarizeUsage, } from "./usage.js";
18
18
  /**
19
19
  * Tope de filas por consulta de PostgREST. Se pagina hasta agotarlo y se AVISA
20
20
  * si se alcanzó: un truncamiento leído como total convierte «has ahorrado $X»
@@ -75,7 +75,13 @@ export function registrarUsage(server, db) {
75
75
  .filter((r) => r.source !== "guard")
76
76
  .reduce((a, r) => a + (r.chars_served ?? 0), 0) +
77
77
  rolledUp.reduce((a, r) => a + (r.chars_served ?? 0), 0);
78
- const readsCount = reads.length + rolledUp.reduce((a, r) => a + (r.reads ?? 0), 0);
78
+ const rolledUpReads = rolledUp.reduce((a, r) => a + (r.reads ?? 0), 0);
79
+ const readsCount = reads.length + rolledUpReads;
80
+ // El desglose por tipo: qué parte de las consultas fue TIRÓN deliberado
81
+ // (adopción real) y qué parte fue EMPUJE. Lo purgado, que llega sin
82
+ // source/tool, va a `historical` para que el desglose siga sumando a
83
+ // readsCount en vez de adivinarle un tipo.
84
+ const byKind = readsByKind(reads, rolledUpReads);
79
85
  const u = summarizeUsage(ledger, skipsP.filas.length, readsCount, readsChars);
80
86
  const naive = u.realCost + u.savedTotal;
81
87
  const pct = naive > 0 ? Math.round((u.savedTotal / naive) * 100) : 0;
@@ -95,7 +101,10 @@ export function registrarUsage(server, db) {
95
101
  ` - Model routing: ${fmtUsd(u.saved.routing)}`,
96
102
  ` - Prompt cache: ${fmtUsd(u.saved.cache)}`,
97
103
  ` - Diff compression: ${fmtUsd(u.saved.compression)}`,
98
- `- Agent consultations: ${u.reads.count} (≈${Math.round(u.reads.chars / 4)} tokens served; estimated exploration avoided ${fmtUsd(u.reads.explorationEstimate)} — conservative estimate, kept OUT of the hard savings above)`,
104
+ `- Agent consultations: ${u.reads.count} total (≈${Math.round(u.reads.chars / 4)} tokens served; estimated exploration avoided ${fmtUsd(u.reads.explorationEstimate)} — conservative estimate, kept OUT of the hard savings above)`,
105
+ // Solo el TIRÓN mide adopción: un total alto hecho de empuje dice que
106
+ // el hook está puesto, no que el agente consulte.
107
+ ...readsBreakdownLines(byKind),
99
108
  ];
100
109
  const latency = latencyByTool(reads);
101
110
  if (latency.length > 0) {
@@ -114,7 +123,7 @@ export function registrarUsage(server, db) {
114
123
  real_cost_usd: u.realCost,
115
124
  saved_usd: u.saved,
116
125
  saved_total_usd: u.savedTotal,
117
- reads: u.reads,
126
+ reads: { ...u.reads, by_kind: byKind },
118
127
  latency_by_tool: latency,
119
128
  });
120
129
  recordRead(db, "atlas_usage", "", servedCharsOf(salida), Date.now() - t0);
package/dist/usage.js CHANGED
@@ -21,7 +21,6 @@ const MODEL_PRICES = [
21
21
  ];
22
22
  const FALLBACK_PRICE = { input: 3, output: 15 };
23
23
  const CACHE_READ_FACTOR = 0.1;
24
- const CACHE_WRITE_FACTOR = 1.25;
25
24
  const BATCH_FACTOR = 0.5;
26
25
  const CHARS_PER_TOKEN = 4;
27
26
  const READ_EXPLORATION_FACTOR = 3;
@@ -30,13 +29,41 @@ function modelPrice(model) {
30
29
  const hit = MODEL_PRICES.find((p) => p.match.test(id));
31
30
  return hit ? { input: hit.input, output: hit.output } : FALLBACK_PRICE;
32
31
  }
32
+ /**
33
+ * Cuándo pasó la caché de 5 minutos (escritura ×1,25) a una hora (×2).
34
+ *
35
+ * SIN ESTO EL HISTÓRICO SE SOBREVALORA. Las filas anteriores a #438 se
36
+ * escribieron con la ventana corta, y aplicarles ×2 infla el pasado — medido:
37
+ * $38,14 se convertían en $43,64, y con eso el «antes y después» del cambio
38
+ * dejaba de poder compararse.
39
+ *
40
+ * El ledger guarda la SUMA de tokens de caché, no el desglose 5m/1h, así que la
41
+ * ventana de una fila vieja no se puede deducir del dato: sólo de la fecha.
42
+ *
43
+ * Sin `created_at` se asume la ventana ACTUAL, que es lo correcto para una fila
44
+ * recién escrita — el caso de quien no pasa la fecha.
45
+ */
46
+ export const CACHE_1H_DESDE = "2026-08-24T16:20:00Z";
47
+ function factorDeEscritura(createdAt) {
48
+ return createdAt && createdAt < CACHE_1H_DESDE ? 1.25 : 2;
49
+ }
33
50
  function costAtPrice(row, price) {
34
51
  return (((row.input_tokens ?? 0) * price.input +
35
52
  (row.cache_read_input_tokens ?? 0) * price.input * CACHE_READ_FACTOR +
36
- (row.cache_creation_input_tokens ?? 0) * price.input * CACHE_WRITE_FACTOR +
53
+ (row.cache_creation_input_tokens ?? 0) * price.input * factorDeEscritura(row.created_at) +
37
54
  (row.output_tokens ?? 0) * price.output) /
38
55
  1_000_000);
39
56
  }
57
+ /**
58
+ * El coste de UNA fila, con la tabla de precios de este mismo fichero.
59
+ *
60
+ * Se exporta para que `spend.ts` no tenga que reimplementarla: el factor de
61
+ * escritura de caché ya vive en TRES sitios atados por `usageParity.test.ts`, y
62
+ * una cuarta copia es la que se quedaría atrás el día que cambie la ventana.
63
+ */
64
+ export function costOfRow(row) {
65
+ return costAtPrice(row, modelPrice(row.model));
66
+ }
40
67
  export function summarizeUsage(rows, skipsCount, readsCount, readsChars) {
41
68
  let realCost = 0;
42
69
  let compression = 0;
@@ -117,4 +144,48 @@ export function latencyByTool(rows) {
117
144
  }))
118
145
  .sort((a, b) => b.n - a.n);
119
146
  }
147
+ export function readKind(row) {
148
+ // El guardián pre-commit primero: su `source` es inequívoco y su contrafáctico
149
+ // es otro (evita un error, no una lectura), como ya lo trata `explorationEstimate`.
150
+ if (row.source === "guard")
151
+ return "guard";
152
+ // El mapa al abrir sesión (`changebook context`) se escribe con `source: "sync"`.
153
+ if (row.source === "sync")
154
+ return "push_session";
155
+ // El radio de impacto antes de editar. Mismo `source` que el tirón: SOLO el
156
+ // tool lo delata.
157
+ if (row.tool === "impact_pretooluse")
158
+ return "push_edit";
159
+ // Lo demás es una tool con nombre del atlas que el agente llamó a propósito.
160
+ return "pull";
161
+ }
162
+ export function readsByKind(rows, historical) {
163
+ const b = {
164
+ pull: 0,
165
+ push_edit: 0,
166
+ push_session: 0,
167
+ guard: 0,
168
+ historical,
169
+ };
170
+ for (const r of rows)
171
+ b[readKind(r)] += 1;
172
+ return b;
173
+ }
174
+ /**
175
+ * Las líneas del informe que separan tirón de empuje. Vive aquí, compartida por
176
+ * los dos runtimes, por dos motivos: la paridad (un solo texto, no dos que
177
+ * deriven) y el techo del módulo-dios `toolUsage.ts`, que no puede engordar con
178
+ * la presentación. La línea de `historical` solo sale si hay algo purgado, para
179
+ * no plantar un "0" que se lee como dato donde no hay ninguno.
180
+ */
181
+ export function readsBreakdownLines(b) {
182
+ const lines = [
183
+ ` - deliberate tool calls (adoption): ${b.pull}`,
184
+ ` - pushed before edits: ${b.push_edit} · pushed at session start: ${b.push_session} · pre-commit guardian: ${b.guard}`,
185
+ ];
186
+ if (b.historical > 0) {
187
+ lines.push(` - historical (rolled up, type unknown): ${b.historical}`);
188
+ }
189
+ return lines;
190
+ }
120
191
  //# sourceMappingURL=usage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "mcpName": "io.github.raulbr90/changebook",
5
5
  "description": "Your agent already broke this three times. ChangeBook tells it before the fourth. MCP server + CLI: the history of what broke in your repo, served to Claude Code, Cursor or Codex before they edit.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
4
  "description": "Your agent already broke this three times. ChangeBook tells it before the fourth.",
5
- "version": "0.9.1",
5
+ "version": "0.10.0",
6
6
  "websiteUrl": "https://changebook.dev",
7
7
  "remotes": [
8
8
  {
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "changebook",
18
- "version": "0.9.1",
18
+ "version": "0.10.0",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }