estela 0.3.4 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +97 -0
  2. package/dist/billing/assign.d.ts +11 -1
  3. package/dist/billing/assign.js +59 -1
  4. package/dist/billing/sessionize.d.ts +6 -0
  5. package/dist/billing/sessionize.js +24 -5
  6. package/dist/cli.js +1084 -8
  7. package/dist/db/schema.js +91 -0
  8. package/dist/db/store.d.ts +28 -0
  9. package/dist/db/store.js +120 -1
  10. package/dist/delivery/clients.d.ts +52 -0
  11. package/dist/delivery/clients.js +94 -0
  12. package/dist/delivery/github.d.ts +21 -0
  13. package/dist/delivery/github.js +123 -0
  14. package/dist/delivery/report.d.ts +55 -0
  15. package/dist/delivery/report.js +124 -0
  16. package/dist/delivery/source.d.ts +68 -0
  17. package/dist/delivery/source.js +3 -0
  18. package/dist/export/csv.d.ts +14 -0
  19. package/dist/export/csv.js +78 -0
  20. package/dist/export/finance.d.ts +79 -0
  21. package/dist/export/finance.js +208 -0
  22. package/dist/i18n/en.js +166 -0
  23. package/dist/metrics/spend.d.ts +83 -0
  24. package/dist/metrics/spend.js +167 -0
  25. package/dist/metrics/undelivered.d.ts +104 -0
  26. package/dist/metrics/undelivered.js +199 -0
  27. package/dist/server.js +1 -0
  28. package/dist/watchers/git.d.ts +40 -0
  29. package/dist/watchers/git.js +87 -0
  30. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  32. package/node_modules/@estela/shared/dist/money.js +7 -7
  33. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  34. package/node_modules/@estela/shared/dist/types.d.ts +2 -0
  35. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  36. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  37. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  39. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -58,6 +58,7 @@ const demo_js_1 = require("./demo.js");
58
58
  const doctor_js_1 = require("./doctor.js");
59
59
  const setup_js_1 = require("./setup.js");
60
60
  const csv_js_1 = require("./export/csv.js");
61
+ const finance_js_1 = require("./export/finance.js");
61
62
  const paths_js_1 = require("./paths.js");
62
63
  const clockify_js_1 = require("./export/clockify.js");
63
64
  const invoice_pdf_js_1 = require("./export/invoice-pdf.js");
@@ -65,6 +66,8 @@ const share_js_1 = require("./export/share.js");
65
66
  const server_js_1 = require("./server.js");
66
67
  const updateCheck_js_1 = require("./updateCheck.js");
67
68
  const plan_js_1 = require("./metrics/plan.js");
69
+ const undelivered_js_1 = require("./metrics/undelivered.js");
70
+ const spend_js_1 = require("./metrics/spend.js");
68
71
  const plan_blocks_js_1 = require("./plan-blocks.js");
69
72
  const plan_prices_js_1 = require("./plan-prices.js");
70
73
  const plan_report_js_1 = require("./plan-report.js");
@@ -82,6 +85,10 @@ Object.defineProperty(exports, "breakdownOfTurn", { enumerable: true, get: funct
82
85
  const agents_js_1 = require("./watchers/agents.js");
83
86
  const git_js_1 = require("./watchers/git.js");
84
87
  const identity_js_1 = require("./watchers/identity.js");
88
+ const project_of_js_1 = require("./project-of.js");
89
+ const github_js_1 = require("./delivery/github.js");
90
+ const report_js_1 = require("./delivery/report.js");
91
+ const clients_js_1 = require("./delivery/clients.js");
85
92
  const index_js_1 = require("./i18n/index.js");
86
93
  const dates_js_1 = require("./export/dates.js");
87
94
  const quota_js_1 = require("./metrics/quota.js");
@@ -124,10 +131,40 @@ estela — registro de horas para desarrollo asistido por IA
124
131
  vez de perder el bloque o facturarlo por sorpresa.
125
132
  estela project reopen --project <id>
126
133
  estela project tracker --project <id> [--system jira|azure|github|gitlab|none]
127
- [--prefix PROJ,OPS]
134
+ [--prefix PROJ,OPS] [--plan-label <etiqueta>|none]
135
+ [--client-label <prefijo>|none]
128
136
  El gestor de tareas del proyecto, para reconocer sus tickets en la
129
137
  rama y los commits (feature/1234-login, AB#1234, PROJ-12). Luego
130
- "estela import" lo aplica a lo ya capturado.
138
+ "estela import" lo aplica a lo ya capturado. --plan-label es la
139
+ etiqueta que marca lo planificado para "estela delivery", y
140
+ --client-label el prefijo de las peticiones de clientes (cliente:).
141
+ estela delivery [--project <id>] [--month YYYY-MM | --from YYYY-MM-DD [--to YYYY-MM-DD]]
142
+ [--label <etiqueta> | --milestone <nombre>] [--offline] [--json]
143
+ Planificado frente a entregado en un periodo (por defecto, este mes),
144
+ con el coste de IA de cada parte: lo del plan que se entregó, lo que
145
+ no, y lo entregado que no estaba en el plan. Lee las issues y los PR
146
+ de GitHub con "gh", solo en un proyecto con --system github; es la
147
+ única llamada fuera de tu máquina, y --offline la evita. Sin gh usa
148
+ lo leído la última vez o, si nunca se leyó, lo entregado según git.
149
+ estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
150
+ El gasto de IA del periodo en peticiones de cada cliente frente al
151
+ resto: una issue o un PR con la etiqueta "cliente:<nombre>" es una
152
+ petición de ese cliente, y un PR hereda las etiquetas de las issues
153
+ que cierra. Una hora que sirvió a varios va a "Varios", sin repartir.
154
+ No hay ingresos ni contratos: solo une el gasto con quién lo pidió.
155
+ estela summary [--month YYYY-MM | --from YYYY-MM-DD [--to YYYY-MM-DD]]
156
+ [--client <id>] [--project <id>] [--format md|csv|json]
157
+ [--out <fichero>] [--stale-days 14] [--lang es|en]
158
+ Informe para finanzas, en el lenguaje de quien no programa: lo
159
+ pagado en IA, por cliente y proyecto, lo entregado (y el plan, si
160
+ "estela delivery" lo leyó), lo pedido por cada cliente y lo que no
161
+ llegó a entregarse. Los detalles técnicos van al final. Por defecto,
162
+ el último mes cerrado. Con --out, el formato sale de la extensión.
163
+ No llama a nada fuera de tu máquina.
164
+ estela client alias [--client <id> --label <nombre>] [--remove --label <nombre>]
165
+ Qué cliente de Estela es cada nombre de las etiquetas de cliente.
166
+ Sin alias se compara con el id y el nombre del cliente (sin
167
+ mayúsculas, acentos ni guiones). Sin argumentos, lista los alias.
131
168
  estela rate set --project <id> --rate <importe> [--from YYYY-MM-DD]
132
169
  estela budget --project <id> [--amount <dólares>|none]
133
170
  Presupuesto mensual de IA. Sin --amount, consulta el actual. Alimenta
@@ -160,6 +197,24 @@ estela — registro de horas para desarrollo asistido por IA
160
197
  estela entry link --entry <id> --items PROJ-12[,AB#1234] | --clear
161
198
  Pone a mano los tickets de un bloque. El import ya no los cambia.
162
199
  estela ai-cost Reparto de tu cuota entre proyectos.
200
+ estela ai-cost --by branch|item [--project <id>] [--client <id>]
201
+ [--month YYYY-MM | --from YYYY-MM-DD --to YYYY-MM-DD]
202
+ [--json | --csv <fichero.csv>]
203
+ Coste de IA y horas de cada entrega: por rama, o por ticket y PR.
204
+ Dos cifras: a precio de API (mide uso) y tu cuota repartida (dinero
205
+ pagado). Cada fila dice en qué se basa: la rama que anotó el agente,
206
+ el número en la rama, #12 en un commit, lo puesto a mano o el
207
+ commit de merge del PR. Lo que no tiene base va a "Sin atribuir",
208
+ entero. Solo lee: no recalcula días aprobados ni facturados.
209
+ estela ai-cost --undelivered [--stale-days 14] [--project <id>] [--client <id>]
210
+ [--month YYYY-MM | --from YYYY-MM-DD --to YYYY-MM-DD]
211
+ [--json | --csv <fichero.csv>]
212
+ El gasto de IA que no ha llegado a la rama principal, por proyecto, en
213
+ cifra y en % del gasto: sesiones sin ningún commit tuyo (durante ella
214
+ ni en los 30 min siguientes) y ramas que existen, no están fusionadas
215
+ y llevan --stale-days días sin actividad. Es una señal para revisar,
216
+ no un juicio. No cuenta: las ramas borradas sin merge (pudo ser un
217
+ squash) ni las sesiones anteriores a tu primer commit en el repo.
163
218
  estela quota [--json] [--no-refresh] [--weighted]
164
219
  ¿Qué proyecto se come tu cuota de Claude Code o Codex? Tokens por
165
220
  proyecto en la ventana de 5 h actual y en la semana (de lunes a
@@ -335,10 +390,41 @@ estela — time tracking for AI-assisted development
335
390
  silently dropping the block or billing it by surprise.
336
391
  estela project reopen --project <id>
337
392
  estela project tracker --project <id> [--system jira|azure|github|gitlab|none]
338
- [--prefix PROJ,OPS]
393
+ [--prefix PROJ,OPS] [--plan-label <label>|none]
394
+ [--client-label <prefix>|none]
339
395
  The project's issue tracker, to recognise its tickets in the branch
340
396
  and commits (feature/1234-login, AB#1234, PROJ-12). Then
341
- "estela import" applies it to what's already captured.
397
+ "estela import" applies it to what's already captured. --plan-label
398
+ is the label that marks what's planned, for "estela delivery", and
399
+ --client-label the prefix of client requests (cliente:).
400
+ estela delivery [--project <id>] [--month YYYY-MM | --from YYYY-MM-DD [--to YYYY-MM-DD]]
401
+ [--label <label> | --milestone <name>] [--offline] [--json]
402
+ Planned versus delivered in a period (this month by default), with
403
+ the AI cost of each part: what was planned and delivered, what
404
+ wasn't, and what was delivered without being planned. It reads
405
+ GitHub issues and PRs with "gh", only for a project with --system
406
+ github; it's the only call off your machine, and --offline avoids
407
+ it. Without gh it uses what was read last time or, if never read,
408
+ what git says was delivered.
409
+ estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
410
+ The period's AI spend on each client's requests versus the rest: an
411
+ issue or PR labelled "cliente:<name>" is that client's request, and a
412
+ PR inherits the labels of the issues it closes. An hour that served
413
+ several goes to "Several", unsplit. No revenue or contracts: it only
414
+ links spend to who asked for it.
415
+ estela summary [--month YYYY-MM | --from YYYY-MM-DD [--to YYYY-MM-DD]]
416
+ [--client <id>] [--project <id>] [--format md|csv|json]
417
+ [--out <file>] [--stale-days 14] [--lang es|en]
418
+ A report for finance, in the words of someone who doesn't code: what
419
+ was paid for AI, by client and project, what was delivered (and the
420
+ plan, if "estela delivery" read it), what each client asked for, and
421
+ what never got delivered. Technical details go at the end. Defaults
422
+ to the last closed month. With --out, the format comes from the
423
+ extension. It never calls anything off your machine.
424
+ estela client alias [--client <id> --label <name>] [--remove --label <name>]
425
+ Which Estela client each name in the client labels is. Without an
426
+ alias it's matched against the client's id and name (ignoring case,
427
+ accents and hyphens). With no arguments, lists the aliases.
342
428
  estela rate set --project <id> --rate <amount> [--from YYYY-MM-DD]
343
429
  estela budget --project <id> [--amount <dollars>|none]
344
430
  Monthly AI budget. Without --amount, shows the current one. Feeds the
@@ -371,6 +457,25 @@ estela — time tracking for AI-assisted development
371
457
  estela entry link --entry <id> --items PROJ-12[,AB#1234] | --clear
372
458
  Sets a block's tickets by hand. Imports won't change them anymore.
373
459
  estela ai-cost How your subscription splits across projects.
460
+ estela ai-cost --by branch|item [--project <id>] [--client <id>]
461
+ [--month YYYY-MM | --from YYYY-MM-DD --to YYYY-MM-DD]
462
+ [--json | --csv <file.csv>]
463
+ AI cost and hours of each delivery: by branch, or by ticket and PR.
464
+ Two figures: at API prices (measures usage) and your subscription
465
+ split by usage (money paid). Each row says what it's based on: the
466
+ branch the agent recorded, the number in the branch, #12 in a
467
+ commit, set by hand, or the PR's merge commit. Anything without a
468
+ basis goes to "Unattributed", whole. Read-only: it never recalculates
469
+ approved or invoiced days.
470
+ estela ai-cost --undelivered [--stale-days 14] [--project <id>] [--client <id>]
471
+ [--month YYYY-MM | --from YYYY-MM-DD --to YYYY-MM-DD]
472
+ [--json | --csv <file.csv>]
473
+ AI spend that hasn't reached the main branch, per project, as a figure
474
+ and as a % of spend: sessions with no commit of yours (during them or
475
+ in the 30 min after) and branches that exist, aren't merged, and have
476
+ been idle for --stale-days days. It's a signal to review, not a
477
+ verdict. Not counted: branches deleted with no merge (it may have been
478
+ a squash) and sessions before your first commit in the repo.
374
479
  estela quota [--json] [--no-refresh] [--weighted]
375
480
  Which project is eating your Claude Code or Codex quota? Tokens per
376
481
  project in the current 5-hour window and this week (Monday to
@@ -877,6 +982,7 @@ async function cmdImport(args, dbPath) {
877
982
  if (!root)
878
983
  continue;
879
984
  savedCommits += store.saveCommits(db, await (0, git_js_1.readCommits)(root, since ? { since } : {}));
985
+ store.saveMerges(db, await (0, git_js_1.readMerges)(root, since ? { since } : {}));
880
986
  }
881
987
  // Todo lo conocido, no solo lo leído ahora (ver knownTurns). Con --repo, solo
882
988
  // ese repo, que es lo que se ha pedido.
@@ -1089,6 +1195,24 @@ function cmdProjectTracker(args, dbPath) {
1089
1195
  if (!project)
1090
1196
  throw new UserError((0, index_js_1.tr) `No existe el proyecto "${projectId}".`);
1091
1197
  const system = str(args, "system");
1198
+ if (!system && "client-label" in args.flags) {
1199
+ if (!project.tracker)
1200
+ throw new UserError((0, index_js_1.tr) `Primero el gestor: estela project tracker --project ${projectId} --system github`);
1201
+ const prefix = clientLabelArg(args);
1202
+ const { clientLabel: _, ...rest } = project.tracker;
1203
+ store.setProjectTracker(db, projectId, prefix ? { ...rest, clientLabel: prefix } : rest);
1204
+ console.log((0, index_js_1.tr) `Las peticiones de clientes de "${project.name}" llevan la etiqueta "${prefix ?? DEFAULT_CLIENT_LABEL}<nombre>".`);
1205
+ return;
1206
+ }
1207
+ if (!system && "plan-label" in args.flags) {
1208
+ if (!project.tracker)
1209
+ throw new UserError((0, index_js_1.tr) `Primero el gestor: estela project tracker --project ${projectId} --system github`);
1210
+ const label = planLabelArg(args);
1211
+ store.setProjectTracker(db, projectId, withPlanLabel(project.tracker, label));
1212
+ console.log(label ? (0, index_js_1.tr) `Lo planificado de "${project.name}" es lo que lleva la etiqueta "${label}".`
1213
+ : (0, index_js_1.tr) `"${project.name}" ya no tiene etiqueta de plan.`);
1214
+ return;
1215
+ }
1092
1216
  if (!system) {
1093
1217
  console.log(project.tracker
1094
1218
  ? (0, index_js_1.tr) `"${project.name}" usa ${project.tracker.system}${project.tracker.prefixes.length ? " (" + project.tracker.prefixes.join(", ") + ")" : ""}.`
@@ -1107,7 +1231,9 @@ function cmdProjectTracker(args, dbPath) {
1107
1231
  if (prefixes.some((p) => !/^[A-Z][A-Z0-9]*$/.test(p))) {
1108
1232
  throw new UserError((0, index_js_1.tr) `Prefijo inválido: tienen que ser letras y números, como PROJ u OPS.`);
1109
1233
  }
1110
- store.setProjectTracker(db, projectId, { system: system, prefixes });
1234
+ const label = "plan-label" in args.flags ? planLabelArg(args) : project.tracker?.planLabel;
1235
+ const clientLabel = "client-label" in args.flags ? clientLabelArg(args) : project.tracker?.clientLabel;
1236
+ store.setProjectTracker(db, projectId, withPlanLabel({ system: system, prefixes, ...(clientLabel ? { clientLabel } : {}) }, label));
1111
1237
  console.log((0, index_js_1.tr) `"${project.name}" usa ${system}${prefixes.length ? " (" + prefixes.join(", ") + ")" : ""}.`);
1112
1238
  }
1113
1239
  console.log((0, index_js_1.tr) `Para aplicarlo a lo ya capturado: estela import`);
@@ -1116,6 +1242,65 @@ function cmdProjectTracker(args, dbPath) {
1116
1242
  db.close();
1117
1243
  }
1118
1244
  }
1245
+ /** `--plan-label <etiqueta>`, o `none` para quitarla. */
1246
+ function planLabelArg(args) {
1247
+ const label = str(args, "plan-label")?.trim();
1248
+ if (!label)
1249
+ throw new UserError((0, index_js_1.tr) `Falta la etiqueta: --plan-label <etiqueta> (o none para quitarla).`);
1250
+ return label === "none" ? undefined : label;
1251
+ }
1252
+ /** `--client-label <prefijo>`, o `none` para volver al de por defecto. */
1253
+ function clientLabelArg(args) {
1254
+ const prefix = str(args, "client-label")?.trim();
1255
+ if (!prefix)
1256
+ throw new UserError((0, index_js_1.tr) `Falta el prefijo: --client-label <prefijo> (o none para volver a "cliente:").`);
1257
+ return prefix === "none" ? undefined : prefix;
1258
+ }
1259
+ /**
1260
+ * `estela client alias` — qué cliente de Estela es cada nombre de las
1261
+ * etiquetas `cliente:<nombre>`. Sin alias se prueba con el id y el nombre del
1262
+ * cliente; si no coincide ninguno, el informe lo enseña sin mapear.
1263
+ */
1264
+ function cmdClientAlias(args, dbPath) {
1265
+ const db = (0, schema_js_1.openDatabase)(dbPath);
1266
+ try {
1267
+ const label = str(args, "label");
1268
+ if ("remove" in args.flags) {
1269
+ if (!label)
1270
+ throw new UserError((0, index_js_1.tr) `Falta --label <nombre>`);
1271
+ console.log(store.removeClientAlias(db, label)
1272
+ ? (0, index_js_1.tr) `"${label}" ya no apunta a ningún cliente.` : (0, index_js_1.tr) `"${label}" no tenía alias.`);
1273
+ return;
1274
+ }
1275
+ const clientId = str(args, "client");
1276
+ if (!label && !clientId) {
1277
+ const aliases = store.listClientAliases(db);
1278
+ if (!aliases.length) {
1279
+ console.log((0, index_js_1.tr) `Sin alias. Las etiquetas se comparan con el id y el nombre de cada cliente.`);
1280
+ return;
1281
+ }
1282
+ for (const a of aliases)
1283
+ console.log(` ${a.label.padEnd(24)} → ${store.getClient(db, a.clientId)?.name ?? a.clientId}`);
1284
+ return;
1285
+ }
1286
+ if (!label || !clientId)
1287
+ throw new UserError((0, index_js_1.tr) `Hacen falta --client <id> y --label <nombre>.`);
1288
+ const client = store.getClient(db, clientId);
1289
+ if (!client)
1290
+ throw new UserError((0, index_js_1.tr) `No existe el cliente "${clientId}".`);
1291
+ if (!store.aliasKey(label))
1292
+ throw new UserError((0, index_js_1.tr) `La etiqueta necesita al menos una letra o un número.`);
1293
+ store.setClientAlias(db, label, clientId);
1294
+ console.log((0, index_js_1.tr) `La etiqueta "${label}" es ${client.name}.`);
1295
+ }
1296
+ finally {
1297
+ db.close();
1298
+ }
1299
+ }
1300
+ function withPlanLabel(tracker, label) {
1301
+ const { planLabel: _, ...rest } = tracker;
1302
+ return label ? { ...rest, planLabel: label } : rest;
1303
+ }
1119
1304
  /**
1120
1305
  * `estela entry link` — pone a mano los tickets de un bloque, cuando la rama
1121
1306
  * no lo decía o lo decía mal. Desde entonces el import no los toca;
@@ -2046,7 +2231,7 @@ async function cmdExport(args, dbPath) {
2046
2231
  * `--month YYYY-MM`, o `--from`/`--to` en fechas locales. Sin ninguno, todo:
2047
2232
  * es lo que hacía `estela export` antes de tener rango.
2048
2233
  */
2049
- function entriesInRange(entries, args) {
2234
+ function periodOf(args) {
2050
2235
  const month = str(args, "month");
2051
2236
  if (month && !/^\d{4}-\d{2}$/.test(month))
2052
2237
  throw new UserError((0, index_js_1.tr) `--month va como YYYY-MM.`);
@@ -2056,6 +2241,10 @@ function entriesInRange(entries, args) {
2056
2241
  if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d))
2057
2242
  throw new UserError((0, index_js_1.tr) `Fecha inválida: ${d}`);
2058
2243
  }
2244
+ return { from, to };
2245
+ }
2246
+ function entriesInRange(entries, args) {
2247
+ const { from, to } = periodOf(args);
2059
2248
  return entries.filter((e) => {
2060
2249
  const day = (0, shared_1.localDate)(e.startedAt);
2061
2250
  return (!from || day >= from) && (!to || day <= to);
@@ -2111,7 +2300,18 @@ function cmdSubscriptionAdd(args, dbPath) {
2111
2300
  }
2112
2301
  }
2113
2302
  /** Reparto de la cuota entre proyectos, mes a mes. */
2114
- function cmdAiCost(dbPath) {
2303
+ async function cmdAiCost(args, dbPath) {
2304
+ if ("by" in args.flags && "undelivered" in args.flags) {
2305
+ throw new UserError((0, index_js_1.tr) `--by y --undelivered van por separado: son dos informes distintos.`);
2306
+ }
2307
+ if ("by" in args.flags) {
2308
+ cmdAiCostBy(args, dbPath);
2309
+ return;
2310
+ }
2311
+ if ("undelivered" in args.flags) {
2312
+ await cmdAiCostUndelivered(args, dbPath);
2313
+ return;
2314
+ }
2115
2315
  const db = (0, schema_js_1.openDatabase)(dbPath);
2116
2316
  try {
2117
2317
  const subs = store.listSubscriptions(db);
@@ -2147,6 +2347,873 @@ function cmdAiCost(dbPath) {
2147
2347
  db.close();
2148
2348
  }
2149
2349
  }
2350
+ /**
2351
+ * `estela ai-cost --by branch|item` — el coste de IA de cada entrega.
2352
+ *
2353
+ * Lee las imputaciones ya guardadas (ver metrics/spend.ts): no recalcula nada
2354
+ * ni toca días aprobados o facturados.
2355
+ */
2356
+ function cmdAiCostBy(args, dbPath) {
2357
+ const raw = args.flags["by"];
2358
+ if (raw !== "branch" && raw !== "item")
2359
+ throw new UserError((0, index_js_1.tr) `--by va como branch o item.`);
2360
+ const by = raw;
2361
+ const db = (0, schema_js_1.openDatabase)(dbPath);
2362
+ try {
2363
+ const projects = selectedProjects(db, args);
2364
+ const entries = entriesInRange(projects.flatMap((p) => store.getTimeEntries(db, p.id)), args);
2365
+ const subscriptions = store.listSubscriptions(db);
2366
+ const monthTotals = shareDenominators(db);
2367
+ const reports = (0, spend_js_1.spendByUnit)({ entries, projects, merges: store.listMerges(db), subscriptions, monthTotals }, by);
2368
+ const csvOut = str(args, "csv");
2369
+ if ("csv" in args.flags && !csvOut)
2370
+ throw new UserError((0, index_js_1.tr) `Falta el fichero: --csv <fichero.csv>`);
2371
+ if (csvOut) {
2372
+ (0, node_fs_1.writeFileSync)(csvOut, (0, csv_js_1.spendToCsv)(reports, by), "utf8");
2373
+ console.log((0, index_js_1.tr) `CSV guardado en ${csvOut}`);
2374
+ return;
2375
+ }
2376
+ if ("json" in args.flags) {
2377
+ console.log(JSON.stringify(spendJson(reports, by, args), null, 2));
2378
+ return;
2379
+ }
2380
+ printSpend(reports, by, args, subscriptions.length > 0);
2381
+ }
2382
+ finally {
2383
+ db.close();
2384
+ }
2385
+ }
2386
+ /**
2387
+ * El denominador de la cuota por mes, como en ai-cost: todo el consumo de los
2388
+ * turnos guardados, y nunca menos que lo imputado a todos los proyectos (no
2389
+ * solo a los filtrados).
2390
+ */
2391
+ function shareDenominators(db) {
2392
+ const monthTotals = store.totalConsumptionByMonth(db);
2393
+ const assigned = new Map();
2394
+ for (const row of store.consumptionByProjectMonth(db)) {
2395
+ assigned.set(row.month, (assigned.get(row.month) ?? 0) + row.consumption.microUsd);
2396
+ }
2397
+ for (const [month, micro] of assigned) {
2398
+ if (micro > (monthTotals.get(month)?.microUsd ?? 0))
2399
+ monthTotals.set(month, { microUsd: micro });
2400
+ }
2401
+ return monthTotals;
2402
+ }
2403
+ /**
2404
+ * `estela delivery` — planificado frente a entregado en un periodo, con el
2405
+ * coste de IA de cada parte. Ver delivery/report.ts.
2406
+ *
2407
+ * La única llamada fuera de esta máquina de todo Estela sin cuenta: `gh`, y
2408
+ * solo en un proyecto con GitHub configurado como gestor. Sin `gh`, o con
2409
+ * --offline, se usa lo leído la última vez; si nunca se leyó, lo entregado
2410
+ * según git.
2411
+ */
2412
+ async function cmdDelivery(args, dbPath) {
2413
+ const by = str(args, "by");
2414
+ if ("by" in args.flags && by !== "client")
2415
+ throw new UserError((0, index_js_1.tr) `--by en delivery solo admite client.`);
2416
+ const db = (0, schema_js_1.openDatabase)(dbPath);
2417
+ try {
2418
+ const projectId = await projectFromArgsOrCwd(args, db);
2419
+ const project = store.getProject(db, projectId);
2420
+ const client = store.getClient(db, project.clientId);
2421
+ const { from, to } = deliveryPeriod(args);
2422
+ const title = `${project.name}${client ? ` · ${client.name}` : ""}`;
2423
+ if (by === "client") {
2424
+ await deliveryByClient(db, args, project, title, from, to);
2425
+ return;
2426
+ }
2427
+ const label = str(args, "label");
2428
+ const milestone = str(args, "milestone");
2429
+ if (label && milestone)
2430
+ throw new UserError((0, index_js_1.tr) `--label y --milestone van por separado: elige una forma de marcar el plan.`);
2431
+ const configured = project.tracker?.planLabel;
2432
+ const plan = label ? { label } : milestone ? { milestone } : configured ? { label: configured } : null;
2433
+ const { items, origin, notes, fromGit } = await loadTrackerItems(db, project, args, { from, to, plan });
2434
+ const report = (0, report_js_1.deliveryReport)({
2435
+ items, plan: fromGit ? null : plan, from, to,
2436
+ entries: store.getTimeEntries(db, projectId), merges: store.listMerges(db),
2437
+ subscriptions: store.listSubscriptions(db), monthTotals: shareDenominators(db),
2438
+ });
2439
+ if (!fromGit && !plan) {
2440
+ notes.push((0, index_js_1.tr) `Sin etiqueta ni milestone de plan solo se enseña lo entregado. Indícalo con --label o --milestone,`);
2441
+ notes.push((0, index_js_1.tr) `o déjalo fijo: estela project tracker --project ${projectId} --plan-label <etiqueta>`);
2442
+ }
2443
+ if ("json" in args.flags) {
2444
+ console.log(JSON.stringify(deliveryJson(report, { projectId, from, to, plan: fromGit ? null : plan, origin, notes }), null, 2));
2445
+ return;
2446
+ }
2447
+ printDelivery(report, {
2448
+ title, from, to, plan: fromGit ? null : plan, origin, notes, hasSubs: store.listSubscriptions(db).length > 0,
2449
+ });
2450
+ }
2451
+ finally {
2452
+ db.close();
2453
+ }
2454
+ }
2455
+ /**
2456
+ * Las issues y los PRs de un proyecto: de GitHub con gh si se puede; si no, lo
2457
+ * guardado la última vez; y si nunca se leyó, lo entregado según git.
2458
+ */
2459
+ async function loadTrackerItems(db, project, args, query) {
2460
+ const notes = [];
2461
+ let items = null;
2462
+ let origin = "";
2463
+ const repo = project.repoPaths.find((p) => (0, node_fs_1.existsSync)(p));
2464
+ const github = project.tracker?.system === "github";
2465
+ if (!github) {
2466
+ notes.push((0, index_js_1.tr) `Este proyecto no tiene GitHub como gestor, así que no se lee el plan. Para leerlo:`);
2467
+ notes.push((0, index_js_1.tr) ` estela project tracker --project ${project.id} --system github --plan-label <etiqueta>`);
2468
+ }
2469
+ else if (!repo) {
2470
+ notes.push((0, index_js_1.tr) `La carpeta del repo de este proyecto ya no existe: gh no tiene dónde mirar.`);
2471
+ }
2472
+ else if ("offline" in args.flags) {
2473
+ notes.push((0, index_js_1.tr) `--offline: no se consulta GitHub.`);
2474
+ }
2475
+ else {
2476
+ const source = new github_js_1.GitHubPlanSource(repo);
2477
+ const available = await source.availability();
2478
+ if (available.ok) {
2479
+ items = await source.fetch(query);
2480
+ store.saveTrackerItems(db, project.id, items);
2481
+ origin = (0, index_js_1.tr) `GitHub (gh), leído ahora`;
2482
+ }
2483
+ else {
2484
+ notes.push(unavailableText(available));
2485
+ }
2486
+ }
2487
+ if (!items && github) {
2488
+ const cached = store.listTrackerItems(db, project.id);
2489
+ if (cached.items.length && cached.fetchedAt) {
2490
+ items = cached.items;
2491
+ origin = (0, index_js_1.tr) `GitHub, lo guardado el ${(0, shared_1.localDate)(cached.fetchedAt)} (sin conexión)`;
2492
+ }
2493
+ }
2494
+ if (items)
2495
+ return { items, origin, notes, fromGit: false };
2496
+ return { items: deliveredFromGit(db, project.repoPaths), notes, fromGit: true,
2497
+ origin: (0, index_js_1.tr) `git: los PR de los commits de merge y de los squash con (#N)` };
2498
+ }
2499
+ /**
2500
+ * `estela summary` — el informe para finanzas: gasto, clientes, entregas y lo
2501
+ * que no llegó a entregarse, en Markdown, CSV o JSON. Ver export/finance.ts.
2502
+ *
2503
+ * No llama a nada fuera de la máquina: de GitHub usa lo que ya leyó
2504
+ * `estela delivery`, y si no hay nada, lo que dice git.
2505
+ */
2506
+ async function cmdSummary(args, dbPath) {
2507
+ const out = str(args, "out");
2508
+ const format = str(args, "format") ?? (out?.endsWith(".csv") ? "csv" : out?.endsWith(".json") ? "json" : "md");
2509
+ if (!["md", "csv", "json"].includes(format))
2510
+ throw new UserError((0, index_js_1.tr) `--format va como md, csv o json.`);
2511
+ const rawDays = str(args, "stale-days");
2512
+ const staleDays = rawDays === undefined ? 14 : Number(rawDays);
2513
+ if (!Number.isInteger(staleDays) || staleDays < 1)
2514
+ throw new UserError((0, index_js_1.tr) `--stale-days va como un número entero de días, 1 o más.`);
2515
+ const db = (0, schema_js_1.openDatabase)(dbPath);
2516
+ try {
2517
+ const { from, to } = summaryPeriod(args);
2518
+ const projects = selectedProjects(db, args);
2519
+ const filtered = "project" in args.flags || "client" in args.flags;
2520
+ const subs = store.listSubscriptions(db);
2521
+ const inPeriod = (d) => { const day = (0, shared_1.localDate)(d); return day >= from && day <= to; };
2522
+ // Aquí los meses son los de tu reloj, no los de UTC: un mes cerrado tiene que
2523
+ // sumar su cuota exacta, y la tarde del día 31 en América ya es el mes
2524
+ // siguiente en UTC. Los denominadores, igual: todo el consumo de cada mes
2525
+ // local, y nunca menos que lo imputado a todos los proyectos.
2526
+ const month = (d) => (0, shared_1.localDate)(d).slice(0, 7);
2527
+ const denominators = new Map();
2528
+ for (const t of store.turnsBetween(db, new Date(0), new Date("9999-12-31T00:00:00Z"))) {
2529
+ const m = month(t.at);
2530
+ denominators.set(m, { microUsd: (denominators.get(m)?.microUsd ?? 0) + ((0, cost_js_1.costOfTurn)(t.tokens, t.model, t.at)?.microUsd ?? 0) });
2531
+ }
2532
+ const imputed = new Map();
2533
+ for (const p of [...store.listProjects(db), ...store.listArchivedProjects(db)]) {
2534
+ for (const e of store.getTimeEntries(db, p.id))
2535
+ imputed.set(month(e.startedAt), (imputed.get(month(e.startedAt)) ?? 0) + e.aiCost.microUsd);
2536
+ }
2537
+ for (const [m, micro] of imputed)
2538
+ if (micro > (denominators.get(m)?.microUsd ?? 0))
2539
+ denominators.set(m, { microUsd: micro });
2540
+ const costOf = (entries) => {
2541
+ const byMonth = new Map();
2542
+ for (const e of entries)
2543
+ byMonth.set(month(e.startedAt), (byMonth.get(month(e.startedAt)) ?? 0) + e.aiCost.microUsd);
2544
+ const { real, partial } = (0, spend_js_1.realFromMonths)(byMonth, subs, (m) => denominators.get(m)?.microUsd ?? 0);
2545
+ return { seconds: entries.reduce((n, e) => n + e.seconds, 0),
2546
+ consumption: { microUsd: entries.reduce((n, e) => n + e.aiCost.microUsd, 0) }, real, realPartial: partial };
2547
+ };
2548
+ const zero = { seconds: 0, consumption: { microUsd: 0 }, real: null, realPartial: false };
2549
+ // Por cliente y proyecto.
2550
+ const entriesOf = new Map(projects.map((p) => [p.id, store.getTimeEntries(db, p.id).filter((e) => inPeriod(e.startedAt))]));
2551
+ const clients = new Map();
2552
+ for (const p of projects) {
2553
+ const entries = entriesOf.get(p.id);
2554
+ if (!entries.length)
2555
+ continue;
2556
+ const c = clients.get(p.clientId) ?? { name: p.clientName, projects: [] };
2557
+ c.projects.push({ id: p.id, name: p.name, cost: costOf(entries) });
2558
+ clients.set(p.clientId, c);
2559
+ }
2560
+ const financeClients = [...clients].map(([id, c]) => ({
2561
+ id, name: c.name, projects: c.projects, cost: c.projects.reduce((acc, p) => (0, finance_js_1.sum)(acc, p.cost), zero),
2562
+ })).sort((a, b) => b.cost.consumption.microUsd - a.cost.consumption.microUsd);
2563
+ const assigned = financeClients.reduce((acc, c) => (0, finance_js_1.sum)(acc, c.cost), zero);
2564
+ // Lo pagado en todo el periodo, con o sin proyecto: la cuota repartida
2565
+ // sobre todo el consumo de los turnos. Con --project o --client, solo eso.
2566
+ let paid = assigned;
2567
+ let unassigned = zero;
2568
+ let unpriced = false;
2569
+ if (!filtered) {
2570
+ const byMonth = new Map();
2571
+ let micro = 0;
2572
+ // Un día de margen a cada lado: el filtro de verdad es por fecha local.
2573
+ const desde = new Date(Date.parse(`${from}T00:00:00Z`) - DAY_MS);
2574
+ const hasta = new Date(Date.parse(`${to}T00:00:00Z`) + 2 * DAY_MS);
2575
+ for (const t of store.turnsBetween(db, desde, hasta)) {
2576
+ if (!inPeriod(t.at))
2577
+ continue;
2578
+ if (t.agent === "copilot")
2579
+ unpriced = true;
2580
+ const c = (0, cost_js_1.costOfTurn)(t.tokens, t.model, t.at)?.microUsd ?? 0;
2581
+ micro += c;
2582
+ byMonth.set(month(t.at), (byMonth.get(month(t.at)) ?? 0) + c);
2583
+ }
2584
+ const { real, partial } = (0, spend_js_1.realFromMonths)(byMonth, subs, (m) => denominators.get(m)?.microUsd ?? 0);
2585
+ // Nunca por debajo de lo imputado: las imputaciones de días cerrados conservan su cifra.
2586
+ const machine = { seconds: assigned.seconds, consumption: { microUsd: Math.max(micro, assigned.consumption.microUsd) },
2587
+ real: real && assigned.real && real.amount < assigned.real.amount ? assigned.real : real ?? assigned.real, realPartial: partial };
2588
+ paid = machine;
2589
+ unassigned = {
2590
+ seconds: 0, consumption: { microUsd: Math.max(0, machine.consumption.microUsd - assigned.consumption.microUsd) },
2591
+ real: machine.real ? { amount: Math.max(0, machine.real.amount - (assigned.real?.amount ?? 0)), currency: machine.real.currency } : null,
2592
+ realPartial: machine.realPartial,
2593
+ };
2594
+ }
2595
+ for (const p of projects)
2596
+ if ((entriesOf.get(p.id) ?? []).some((e) => e.agents.includes("copilot")))
2597
+ unpriced = true;
2598
+ // Entregas y peticiones de clientes: lo guardado de GitHub, o lo que dice git.
2599
+ const deliveries = [];
2600
+ const merges = store.listMerges(db);
2601
+ for (const p of projects) {
2602
+ const project = store.getProject(db, p.id);
2603
+ const cached = store.listTrackerItems(db, p.id);
2604
+ const fromGitHub = cached.items.length > 0;
2605
+ const items = fromGitHub ? cached.items : deliveredFromGit(db, project.repoPaths);
2606
+ const planLabel = fromGitHub ? project.tracker?.planLabel : undefined;
2607
+ const report = (0, report_js_1.deliveryReport)({ items, plan: planLabel ? { label: planLabel } : null, from, to,
2608
+ entries: store.getTimeEntries(db, p.id), merges, subscriptions: subs, monthTotals: denominators });
2609
+ const lines = [...(report.planned?.delivered.lines ?? []), ...report.unplanned.lines];
2610
+ let requests = null;
2611
+ let requestsRest = null;
2612
+ if (fromGitHub) {
2613
+ const r = (0, clients_js_1.clientRequests)({ items, entries: entriesOf.get(p.id) ?? [], merges,
2614
+ prefix: project.tracker?.clientLabel ?? DEFAULT_CLIENT_LABEL,
2615
+ requesterOf: (name) => {
2616
+ const clientId = store.clientForLabel(db, name);
2617
+ return { id: clientId ?? `?${store.aliasKey(name)}`, clientId, name: (clientId && store.getClient(db, clientId)?.name) || name };
2618
+ },
2619
+ subscriptions: subs, monthTotals: denominators });
2620
+ if (r.groups.length) {
2621
+ requests = r.groups.map((g) => ({ name: g.requesters.map((x) => x.name).join(" + "),
2622
+ mapped: g.requesters.every((x) => x.clientId !== null), cost: g }));
2623
+ requestsRest = r.rest;
2624
+ }
2625
+ }
2626
+ if (!lines.length && !report.planned)
2627
+ continue;
2628
+ deliveries.push({
2629
+ projectName: p.name, clientName: p.clientName, source: fromGitHub ? "github" : "git",
2630
+ fetchedAt: cached.fetchedAt, delivered: lines.map((l) => ({ label: itemLabel(l.item), cost: l })),
2631
+ plan: report.planned && planLabel ? {
2632
+ label: planLabel, delivered: report.planned.delivered.lines.length,
2633
+ total: report.planned.delivered.lines.length + report.planned.pending.lines.length,
2634
+ pending: report.planned.pending, unplanned: report.unplanned,
2635
+ } : null,
2636
+ requests, requestsRest,
2637
+ });
2638
+ }
2639
+ // Lo que no llegó a entregarse.
2640
+ const undelivered = (await collectUndelivered(db, projects, staleDays, from, to))
2641
+ .filter((r) => r.total.consumption.microUsd > 0)
2642
+ .map((r) => {
2643
+ // La proporción sale de las sesiones; el dinero, del gasto del proyecto
2644
+ // en este mismo informe, para que las dos tablas cuadren entre sí.
2645
+ const project = financeClients.flatMap((c) => c.projects).find((p) => p.id === r.projectId)?.cost;
2646
+ const asCost = (x) => {
2647
+ const ratio = r.total.consumption.microUsd ? x.consumption.microUsd / r.total.consumption.microUsd : 0;
2648
+ const real = project?.real ? { amount: Math.round(project.real.amount * ratio), currency: project.real.currency } : x.real;
2649
+ return { seconds: 0, consumption: x.consumption, real, realPartial: project?.realPartial ?? x.realPartial };
2650
+ };
2651
+ return { projectName: projects.find((p) => p.id === r.projectId)?.name ?? r.projectId,
2652
+ total: asCost(r.total), undelivered: asCost(r.undelivered), withoutCommit: asCost(r.sessionsWithoutCommit),
2653
+ unmergedBranches: asCost(r.unmerged), unmergedCount: r.unmerged.branches.length };
2654
+ });
2655
+ const summary = {
2656
+ from, to, currency: subs[0]?.monthlyFee.currency ?? null, paid, unassigned,
2657
+ clients: financeClients, deliveries, undelivered, staleDays, unpricedAgents: unpriced,
2658
+ };
2659
+ const text = format === "csv" ? (0, finance_js_1.financeToCsv)(summary)
2660
+ : format === "json" ? JSON.stringify((0, finance_js_1.financeToJson)(summary), null, 2) + "\n"
2661
+ : (0, finance_js_1.financeToMarkdown)(summary);
2662
+ if (out) {
2663
+ (0, node_fs_1.writeFileSync)(out, text, "utf8");
2664
+ console.log((0, index_js_1.tr) `Informe guardado en ${out}`);
2665
+ }
2666
+ else {
2667
+ process.stdout.write(text);
2668
+ }
2669
+ }
2670
+ finally {
2671
+ db.close();
2672
+ }
2673
+ }
2674
+ const DAY_MS = 86_400_000;
2675
+ /** El periodo del informe: --month, --from/--to, o el último mes cerrado. */
2676
+ function summaryPeriod(args) {
2677
+ const { from, to } = periodOf(args);
2678
+ if (from && to)
2679
+ return { from, to };
2680
+ if (from)
2681
+ return { from, to: (0, shared_1.localDate)(new Date()) };
2682
+ if (to)
2683
+ throw new UserError((0, index_js_1.tr) `Con --to hace falta --from.`);
2684
+ const now = new Date();
2685
+ const prev = new Date(now.getFullYear(), now.getMonth() - 1, 15);
2686
+ const month = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, "0")}`;
2687
+ const last = new Date(prev.getFullYear(), prev.getMonth() + 1, 0).getDate();
2688
+ return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, "0")}` };
2689
+ }
2690
+ /** Sin configurar, las etiquetas de cliente son `cliente:<nombre>`. */
2691
+ const DEFAULT_CLIENT_LABEL = "cliente:";
2692
+ /**
2693
+ * `estela delivery --by client` — el gasto de IA del periodo en peticiones de
2694
+ * cada cliente frente al resto. Ver delivery/clients.ts.
2695
+ */
2696
+ async function deliveryByClient(db, args, project, title, from, to) {
2697
+ const prefix = project.tracker?.clientLabel ?? DEFAULT_CLIENT_LABEL;
2698
+ const { items, origin, notes, fromGit } = await loadTrackerItems(db, project, args, { from, to, plan: null, clientLabelPrefix: prefix });
2699
+ if (fromGit)
2700
+ notes.push((0, index_js_1.tr) `Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.`);
2701
+ const requesterOf = (name) => {
2702
+ const clientId = store.clientForLabel(db, name);
2703
+ const c = clientId ? store.getClient(db, clientId) : null;
2704
+ return { id: clientId ?? `?${store.aliasKey(name)}`, clientId, name: c?.name ?? name };
2705
+ };
2706
+ const report = (0, clients_js_1.clientRequests)({
2707
+ items: fromGit ? [] : items,
2708
+ entries: store.getTimeEntries(db, project.id).filter((e) => {
2709
+ const day = (0, shared_1.localDate)(e.startedAt);
2710
+ return day >= from && day <= to;
2711
+ }),
2712
+ merges: store.listMerges(db), prefix, requesterOf,
2713
+ subscriptions: store.listSubscriptions(db), monthTotals: shareDenominators(db),
2714
+ });
2715
+ if ("json" in args.flags) {
2716
+ const cost = (c) => ({ seconds: c.seconds, apiMicroUsd: c.consumption.microUsd,
2717
+ real: c.real ? { amountMinor: c.real.amount, currency: c.real.currency } : null, realPartial: c.realPartial,
2718
+ percentOfSpend: report.total.consumption.microUsd ? (c.consumption.microUsd / report.total.consumption.microUsd) * 100 : null });
2719
+ console.log(JSON.stringify({
2720
+ project: project.id, period: { from, to }, labelPrefix: prefix, source: origin, notes,
2721
+ requests: report.groups.map((g) => ({
2722
+ requesters: g.requesters.map((r) => ({ clientId: r.clientId, name: r.name, mapped: r.clientId !== null })),
2723
+ items: g.items.map((i) => ({ key: i.key, kind: i.kind, number: i.number, title: i.title, state: i.state })),
2724
+ ...cost(g),
2725
+ })),
2726
+ rest: cost(report.rest), total: cost(report.total),
2727
+ }, null, 2));
2728
+ return;
2729
+ }
2730
+ console.log((0, index_js_1.tr) `Peticiones de clientes · ${title} · ${from} → ${to}`);
2731
+ console.log((0, index_js_1.tr) `Etiqueta: "${prefix}<nombre>" · Fuente: ${origin}`);
2732
+ console.log((0, index_js_1.tr) `Estela no conoce ingresos ni contratos: esto solo une el gasto de IA con quién pidió el trabajo.`);
2733
+ for (const n of notes)
2734
+ console.log(n);
2735
+ const hasSubs = store.listSubscriptions(db).length > 0;
2736
+ const pct = (c) => report.total.consumption.microUsd === 0 ? "—"
2737
+ : `${((c.consumption.microUsd / report.total.consumption.microUsd) * 100).toFixed(1)} %`;
2738
+ const row = (label, c) => ` ${label.slice(0, 44).padEnd(46)}${(c.seconds ? (0, shared_1.formatDuration)(c.seconds) : "—").padStart(9)}` +
2739
+ `${(0, shared_1.formatAiCost)(c.consumption).padStart(11)}` +
2740
+ `${(hasSubs ? (c.real ? (0, shared_1.formatMoney)(c.real) : "—") + (c.realPartial ? "†" : "") : "—").padStart(12)}${pct(c).padStart(10)}`;
2741
+ const who = (r) => (r.clientId ? r.name : (0, index_js_1.tr) `"${r.name}" (sin mapear)`);
2742
+ console.log(`\n ${"".padEnd(46)}${(0, index_js_1.tr) `Horas`.padStart(9)}${(0, index_js_1.tr) `API`.padStart(11)}${(0, index_js_1.tr) `Real`.padStart(12)}${(0, index_js_1.tr) `% gasto`.padStart(10)}`);
2743
+ for (const g of report.groups) {
2744
+ const label = g.requesters.length === 1 ? who(g.requesters[0]) : (0, index_js_1.tr) `Varios: ${g.requesters.map(who).join(" + ")}`;
2745
+ console.log(row(label, g));
2746
+ const shown = g.items.slice(0, 5).map(itemLabel);
2747
+ console.log(` ${shown.join(" · ")}${g.items.length > 5 ? (0, index_js_1.tr) ` · y ${g.items.length - 5} más` : ""}`);
2748
+ }
2749
+ if (report.groups.length === 0)
2750
+ console.log((0, index_js_1.tr) ` (ninguna petición de cliente con horas en el periodo)`);
2751
+ console.log(row((0, index_js_1.tr) `Resto (sin petición de cliente)`, report.rest));
2752
+ console.log(row((0, index_js_1.tr) `Total del periodo`, report.total));
2753
+ const unmapped = [...new Set(report.groups.flatMap((g) => g.requesters).filter((r) => !r.clientId).map((r) => r.name))];
2754
+ console.log((0, index_js_1.tr) `\nUna hora cuenta para quien pidió la issue o el PR al que se une (rama del PR, su número en ramas, commits o a mano).`);
2755
+ console.log((0, index_js_1.tr) `Si sirvió a peticiones de varios clientes va a "Varios", sin repartirla. Un PR hereda las etiquetas de las issues que cierra.`);
2756
+ for (const name of unmapped) {
2757
+ console.log((0, index_js_1.tr) `Para unir "${name}" con un cliente de Estela: estela client alias --client <id> --label "${name}"`);
2758
+ }
2759
+ }
2760
+ /** El periodo de delivery: --month, --from/--to, o el mes en curso. */
2761
+ function deliveryPeriod(args) {
2762
+ const { from, to } = periodOf(args);
2763
+ const today = (0, shared_1.localDate)(new Date());
2764
+ if (from && to)
2765
+ return { from, to };
2766
+ if (from)
2767
+ return { from, to: today };
2768
+ if (to)
2769
+ throw new UserError((0, index_js_1.tr) `Con --to hace falta --from.`);
2770
+ return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-31` };
2771
+ }
2772
+ function unavailableText(a) {
2773
+ switch (a.reason) {
2774
+ case "missing": return (0, index_js_1.tr) `gh no está instalado: sin él no se puede leer el plan de GitHub. Instálalo desde https://cli.github.com y ejecuta: gh auth login`;
2775
+ case "auth": return (0, index_js_1.tr) `gh no tiene sesión iniciada. Ejecuta: gh auth login`;
2776
+ case "repo": return (0, index_js_1.tr) `gh no reconoce un repo de GitHub en la carpeta del proyecto (${a.detail}).`;
2777
+ }
2778
+ }
2779
+ /**
2780
+ * Lo entregado que se sabe sin GitHub: los PR fusionados con merge (su
2781
+ * mensaje dice el número y la rama) y los squash con "(#N)" al final. No hay
2782
+ * plan: eso solo lo sabe el gestor.
2783
+ */
2784
+ function deliveredFromGit(db, repoPaths) {
2785
+ const inProject = (path) => repoPaths.some((r) => (0, paths_js_1.isSameOrInside)(path, r) || (0, paths_js_1.isSameOrInside)(r, path));
2786
+ const items = new Map();
2787
+ const pr = (key, title, at, branch) => ({
2788
+ key, kind: "pr", number: Number(key.split(":")[1]), title, state: "merged", completed: true,
2789
+ closedAt: at, branch, closes: [], labels: [], milestone: null, url: "",
2790
+ });
2791
+ for (const m of store.listMerges(db)) {
2792
+ // El mensaje del merge solo repite el número: la rama dice más.
2793
+ if (m.ref?.startsWith("github:") && inProject(m.repoPath)) {
2794
+ items.set(m.ref, pr(m.ref, m.sourceBranch ?? m.subject, m.at, m.sourceBranch));
2795
+ }
2796
+ }
2797
+ const rows = db.prepare("SELECT repo_path, subject, at FROM commits").all();
2798
+ for (const r of rows) {
2799
+ const squash = /\(#(\d+)\)\s*$/.exec(r.subject);
2800
+ if (!squash || !inProject(r.repo_path))
2801
+ continue;
2802
+ const key = `github:${squash[1]}`;
2803
+ if (!items.has(key))
2804
+ items.set(key, pr(key, r.subject.replace(/\s*\(#\d+\)\s*$/, ""), new Date(r.at), null));
2805
+ }
2806
+ return [...items.values()];
2807
+ }
2808
+ function planText(plan) {
2809
+ if (!plan)
2810
+ return (0, index_js_1.tr) `sin plan`;
2811
+ return plan.label ? (0, index_js_1.tr) `etiqueta "${plan.label}"` : (0, index_js_1.tr) `milestone "${plan.milestone}"`;
2812
+ }
2813
+ function itemLabel(i) {
2814
+ return `${i.kind === "pr" ? (0, index_js_1.tr) `PR #${i.number}` : `#${i.number}`} ${i.title}`;
2815
+ }
2816
+ function printDelivery(r, v) {
2817
+ console.log((0, index_js_1.tr) `Planificado frente a entregado · ${v.title} · ${v.from} → ${v.to}`);
2818
+ console.log((0, index_js_1.tr) `Plan: ${planText(v.plan)} · Fuente: ${v.origin}`);
2819
+ for (const n of v.notes)
2820
+ console.log(n);
2821
+ const money = (c) => v.hasSubs ? (c.real ? (0, shared_1.formatMoney)(c.real) : "—") + (c.realPartial ? "†" : "") : "—";
2822
+ const row = (label, c) => ` ${label.slice(0, 46).padEnd(48)}${(c.seconds ? (0, shared_1.formatDuration)(c.seconds) : "—").padStart(9)}` +
2823
+ `${(0, shared_1.formatAiCost)(c.consumption).padStart(11)}${money(c).padStart(12)}`;
2824
+ const block = (title, b) => {
2825
+ console.log(`\n${title}`);
2826
+ if (b.lines.length === 0) {
2827
+ console.log((0, index_js_1.tr) ` (nada)`);
2828
+ return;
2829
+ }
2830
+ console.log(` ${"".padEnd(48)}${(0, index_js_1.tr) `Horas`.padStart(9)}${(0, index_js_1.tr) `API`.padStart(11)}${(0, index_js_1.tr) `Real`.padStart(12)}`);
2831
+ for (const l of b.lines) {
2832
+ console.log(row(itemLabel(l.item), l));
2833
+ const detail = [];
2834
+ const when = l.item.closedAt ? (0, shared_1.localDate)(l.item.closedAt) : null;
2835
+ if (l.item.kind === "pr") {
2836
+ if (l.item.state === "merged" && when)
2837
+ detail.push((0, index_js_1.tr) `fusionado ${when}`);
2838
+ else
2839
+ detail.push(l.item.state === "open" ? (0, index_js_1.tr) `abierto` : (0, index_js_1.tr) `cerrado sin fusionar`);
2840
+ }
2841
+ else if (l.deliveredBy.length) {
2842
+ detail.push((0, index_js_1.tr) `entregada por ${l.deliveredBy.map((p) => (0, index_js_1.tr) `PR #${p.number}`).join(", ")}`);
2843
+ }
2844
+ else if (l.item.completed && when) {
2845
+ detail.push((0, index_js_1.tr) `cerrada ${when}`);
2846
+ }
2847
+ else {
2848
+ detail.push(l.item.state === "open" ? (0, index_js_1.tr) `abierta` : (0, index_js_1.tr) `cerrada sin hacer`);
2849
+ }
2850
+ detail.push(l.basis.length
2851
+ ? (0, index_js_1.tr) `coste según: ${l.basis.map((x) => (x === "branch" ? (0, index_js_1.tr) `rama del PR` : (0, index_js_1.tr) `número en rama o commit`)).join(", ")}`
2852
+ : (0, index_js_1.tr) `sin horas unidas`);
2853
+ console.log(` ${detail.join(" · ")}`);
2854
+ }
2855
+ if (b.lines.length > 1)
2856
+ console.log(row((0, index_js_1.tr) `Total del bloque`, b));
2857
+ };
2858
+ if (r.planned) {
2859
+ const total = r.planned.delivered.lines.length + r.planned.pending.lines.length;
2860
+ block((0, index_js_1.tr) `Planificado y entregado (${r.planned.delivered.lines.length} de ${total})`, r.planned.delivered);
2861
+ block((0, index_js_1.tr) `Planificado sin entregar (${r.planned.pending.lines.length})`, r.planned.pending);
2862
+ }
2863
+ block(r.planned ? (0, index_js_1.tr) `Entregado sin planificar (${r.unplanned.lines.length})` : (0, index_js_1.tr) `Entregado (${r.unplanned.lines.length})`, r.unplanned);
2864
+ console.log((0, index_js_1.tr) `\nEl coste de cada línea es el de las horas unidas a ella: la rama del PR y su número en ramas, commits o a mano.`);
2865
+ console.log((0, index_js_1.tr) `Incluye lo trabajado antes del periodo para esa entrega. Si una hora se une a dos líneas, el total del bloque la cuenta una vez.`);
2866
+ console.log((0, index_js_1.tr) `API es lo que costaría a precio de API: mide el uso, no es dinero pagado. Real es tu cuota repartida por consumo.`);
2867
+ }
2868
+ function deliveryJson(r, meta) {
2869
+ const cost = (c) => ({
2870
+ seconds: c.seconds, apiMicroUsd: c.consumption.microUsd,
2871
+ real: c.real ? { amountMinor: c.real.amount, currency: c.real.currency } : null, realPartial: c.realPartial,
2872
+ });
2873
+ const block = (b) => ({
2874
+ ...cost(b),
2875
+ items: b.lines.map((l) => ({
2876
+ key: l.item.key, kind: l.item.kind, number: l.item.number, title: l.item.title, state: l.item.state,
2877
+ completed: l.item.completed, closedAt: l.item.closedAt?.toISOString() ?? null, url: l.item.url || null,
2878
+ deliveredBy: l.deliveredBy.map((p) => p.key), basis: l.basis, ...cost(l),
2879
+ })),
2880
+ });
2881
+ return {
2882
+ project: meta.projectId, period: { from: meta.from, to: meta.to }, plan: meta.plan, source: meta.origin, notes: meta.notes,
2883
+ planned: r.planned ? { delivered: block(r.planned.delivered), pending: block(r.planned.pending) } : null,
2884
+ unplanned: block(r.unplanned),
2885
+ };
2886
+ }
2887
+ /** Los proyectos de --project y --client; sin ellos, todos. */
2888
+ function selectedProjects(db, args) {
2889
+ const projectId = str(args, "project");
2890
+ const clientId = str(args, "client");
2891
+ if (projectId && !store.getProject(db, projectId))
2892
+ throw new UserError((0, index_js_1.tr) `No existe el proyecto "${projectId}".`);
2893
+ if (clientId && !store.getClient(db, clientId))
2894
+ throw new UserError((0, index_js_1.tr) `No existe el cliente "${clientId}".`);
2895
+ // También los archivados: su gasto ocurrió, y `ai-cost` tampoco los quita.
2896
+ return [...store.listProjects(db), ...store.listArchivedProjects(db)]
2897
+ .filter((p) => (!projectId || p.id === projectId) && (!clientId || p.clientId === clientId))
2898
+ .map((p) => ({
2899
+ id: p.id, name: p.name, clientId: p.clientId,
2900
+ clientName: store.getClient(db, p.clientId)?.name ?? p.clientId,
2901
+ repoPaths: p.repoPaths, tracker: p.tracker ?? null,
2902
+ }))
2903
+ .sort((a, b) => a.name.localeCompare(b.name));
2904
+ }
2905
+ /**
2906
+ * `estela ai-cost --undelivered` — el gasto que no ha llegado a la rama
2907
+ * principal: sesiones sin commit y ramas sin fusionar. Ver metrics/undelivered.ts.
2908
+ */
2909
+ async function cmdAiCostUndelivered(args, dbPath) {
2910
+ const rawDays = str(args, "stale-days");
2911
+ const staleDays = rawDays === undefined ? 14 : Number(rawDays);
2912
+ if ("stale-days" in args.flags && (!Number.isInteger(staleDays) || staleDays < 1)) {
2913
+ throw new UserError((0, index_js_1.tr) `--stale-days va como un número entero de días, 1 o más.`);
2914
+ }
2915
+ const db = (0, schema_js_1.openDatabase)(dbPath);
2916
+ try {
2917
+ const projects = selectedProjects(db, args);
2918
+ const wanted = new Set(projects.map((p) => p.id));
2919
+ const { from, to } = periodOf(args);
2920
+ const reports = await collectUndelivered(db, projects, staleDays, from, to);
2921
+ const names = new Map(projects.map((p) => [p.id, p]));
2922
+ const csvOut = str(args, "csv");
2923
+ if ("csv" in args.flags && !csvOut)
2924
+ throw new UserError((0, index_js_1.tr) `Falta el fichero: --csv <fichero.csv>`);
2925
+ if (csvOut) {
2926
+ (0, node_fs_1.writeFileSync)(csvOut, (0, csv_js_1.undeliveredToCsv)(reports, names), "utf8");
2927
+ console.log((0, index_js_1.tr) `CSV guardado en ${csvOut}`);
2928
+ return;
2929
+ }
2930
+ if ("json" in args.flags) {
2931
+ console.log(JSON.stringify(undeliveredJson(reports, names, staleDays, args), null, 2));
2932
+ return;
2933
+ }
2934
+ printUndelivered(reports, names, staleDays, args);
2935
+ }
2936
+ finally {
2937
+ db.close();
2938
+ }
2939
+ }
2940
+ /**
2941
+ * Los datos del gasto sin entrega de esos proyectos: sus turnos, tus commits y
2942
+ * el estado de cada rama en git. Lo usan --undelivered y el informe para
2943
+ * finanzas. Solo lee git en local: nada sale de la máquina.
2944
+ */
2945
+ async function collectUndelivered(db, projects, staleDays, from, to) {
2946
+ const wanted = new Set(projects.map((p) => p.id));
2947
+ // El proyecto de cada turno, por el mismo camino que el import: la carpeta
2948
+ // tal cual y, si no, su raíz git (worktrees y subcarpetas). Con los
2949
+ // archivados: su gasto ocurrió aunque ya no capten horas.
2950
+ const roots = new Map();
2951
+ const rootOf = (path) => {
2952
+ let root = roots.get(path);
2953
+ if (root === undefined) {
2954
+ root = (0, project_of_js_1.gitRoot)(path) ?? (0, paths_js_1.normalizePath)(path);
2955
+ // La ruta tal como está en el disco: en macOS y Windows "ApexWall" y
2956
+ // "apexwall" son la misma carpeta, y los turnos y los commits pueden
2957
+ // traer cada uno una.
2958
+ try {
2959
+ root = (0, paths_js_1.normalizePath)(node_fs_1.realpathSync.native(root));
2960
+ }
2961
+ catch { /* ya no existe: tal cual */ }
2962
+ roots.set(path, root);
2963
+ }
2964
+ return root;
2965
+ };
2966
+ const owners = new Map();
2967
+ const projectOf = (path) => {
2968
+ if (!owners.has(path))
2969
+ owners.set(path, store.ownerOfRepo(db, path) ?? store.ownerOfRepo(db, rootOf(path)));
2970
+ return owners.get(path);
2971
+ };
2972
+ const turns = [];
2973
+ for (const t of store.turnsBetween(db, new Date(0), new Date("9999-12-31T00:00:00Z"))) {
2974
+ const projectId = t.repoPath ? projectOf(t.repoPath) : null;
2975
+ if (!projectId || !wanted.has(projectId))
2976
+ continue;
2977
+ turns.push({ projectId, sessionId: t.sessionId, at: t.at, root: rootOf(t.repoPath),
2978
+ branch: t.branch, microUsd: (0, cost_js_1.costOfTurn)(t.tokens, t.model, t.at)?.microUsd ?? 0, agent: t.agent });
2979
+ }
2980
+ const projectRoots = new Set(turns.map((t) => t.root));
2981
+ // Solo tus commits, como en el import: los de un compañero no entregan tu trabajo.
2982
+ const candidates = (await allCommits(db)).filter((c) => projectRoots.has(rootOf(c.repoPath)));
2983
+ const mine = (0, identity_js_1.onlyMine)(candidates, await (0, identity_js_1.myEmailsByRepo)(db, new Set(candidates.map((c) => c.repoPath))));
2984
+ const commits = mine.map((c) => ({ root: rootOf(c.repoPath), at: c.at, branch: c.branch }));
2985
+ // Qué ramas están integradas y cuáles existen hoy, repo a repo.
2986
+ const merges = store.listMerges(db);
2987
+ const facts = new Map();
2988
+ for (const root of projectRoots) {
2989
+ const merged = new Set(await (0, git_js_1.mergedBranches)(root));
2990
+ const base = await (0, git_js_1.integrationBranch)(root);
2991
+ if (base)
2992
+ merged.add(base);
2993
+ for (const m of merges)
2994
+ if (m.sourceBranch && rootOf(m.repoPath) === root)
2995
+ merged.add(m.sourceBranch);
2996
+ facts.set(root, { merged, exists: await (0, git_js_1.branchNames)(root) });
2997
+ }
2998
+ // Lo leído de GitHub con estela delivery: la rama de un PR fusionado está
2999
+ // entregada, aunque fuera con squash y git no lo sepa.
3000
+ for (const p of projects) {
3001
+ const prRoots = new Set(turns.filter((t) => t.projectId === p.id).map((t) => t.root));
3002
+ for (const item of store.listTrackerItems(db, p.id).items) {
3003
+ if (item.kind !== "pr" || !item.completed || !item.branch)
3004
+ continue;
3005
+ for (const root of prRoots)
3006
+ facts.get(root)?.merged.add(item.branch);
3007
+ }
3008
+ }
3009
+ return (0, undelivered_js_1.undeliveredSpend)({
3010
+ turns, commits, staleDays, now: new Date(), ...(from ? { from } : {}), ...(to ? { to } : {}),
3011
+ subscriptions: store.listSubscriptions(db), monthTotals: store.totalConsumptionByMonth(db),
3012
+ branchFacts: (root, branch) => {
3013
+ const f = facts.get(root);
3014
+ return { merged: f?.merged.has(branch) ?? false, exists: f?.exists ? f.exists.has(branch) : null };
3015
+ },
3016
+ });
3017
+ }
3018
+ function moneyCell(s) {
3019
+ return (s.real ? (0, shared_1.formatMoney)(s.real) : "—") + (s.realPartial ? "†" : "");
3020
+ }
3021
+ function pctCell(part, total) {
3022
+ const pct = (0, undelivered_js_1.shareOf)(part, total);
3023
+ return pct === null ? "—" : `${pct.toFixed(1)} %`;
3024
+ }
3025
+ function printUndelivered(reports, projects, staleDays, args) {
3026
+ console.log((0, index_js_1.tr) `Gasto de IA sin entrega · ${periodLabel(args)} · ramas sin fusionar tras ${staleDays} días`);
3027
+ console.log((0, index_js_1.tr) `Es una señal para revisar, no un juicio: explorar, probar y descartar también es trabajo.`);
3028
+ if (reports.length === 0) {
3029
+ console.log((0, index_js_1.tr) `\nNo hay gasto de IA en ese periodo.`);
3030
+ return;
3031
+ }
3032
+ const row = (label, s, pct) => ` ${label.slice(0, 46).padEnd(48)}${(0, shared_1.formatAiCost)(s.consumption).padStart(11)}` +
3033
+ `${moneyCell(s).padStart(13)}${pct.padStart(10)}`;
3034
+ let unpriced = false;
3035
+ let partial = false;
3036
+ let unknown = false;
3037
+ let unverifiable = false;
3038
+ for (const r of reports) {
3039
+ const p = projects.get(r.projectId);
3040
+ console.log(`\n${p?.name ?? r.projectId} · ${p?.clientName ?? ""}`);
3041
+ console.log(` ${"".padEnd(48)}${(0, index_js_1.tr) `API`.padStart(11)}${(0, index_js_1.tr) `Real`.padStart(13)}${(0, index_js_1.tr) `% gasto`.padStart(10)}`);
3042
+ console.log(row((0, index_js_1.tr) `Gasto del periodo`, r.total, "100 %"));
3043
+ const s = r.sessionsWithoutCommit;
3044
+ console.log(row((0, index_js_1.tr) `Sesiones sin commit (${s.count})`, s, pctCell(s, r.total)));
3045
+ if (s.count > 0) {
3046
+ const sinNada = s.count - s.laterCommitted.count;
3047
+ console.log((0, index_js_1.tr) ` ${s.laterCommitted.count} con commit después en su rama (${(0, shared_1.formatAiCost)(s.laterCommitted.consumption)}) · ${sinNada} sin commit después`);
3048
+ }
3049
+ if (r.unverifiable.count > 0) {
3050
+ unverifiable = true;
3051
+ console.log((0, index_js_1.tr) ` No cuentan otras ${r.unverifiable.count} (${(0, shared_1.formatAiCost)(r.unverifiable.consumption)}): son de antes de tu primer commit reconocido en el repo.`);
3052
+ }
3053
+ const u = r.unmerged;
3054
+ console.log(row((0, index_js_1.tr) `Ramas sin fusionar tras ${staleDays} días (${u.branches.length})`, u, pctCell(u, r.total)));
3055
+ for (const b of u.branches.slice(0, 8)) {
3056
+ console.log((0, index_js_1.tr) ` ${b.branch} · sin actividad hace ${b.idleDays} días · ${(0, shared_1.formatAiCost)(b.consumption)}`);
3057
+ }
3058
+ if (u.branches.length > 8)
3059
+ console.log((0, index_js_1.tr) ` … y ${u.branches.length - 8} más (--json para verlas todas)`);
3060
+ console.log(row((0, index_js_1.tr) `Sin entrega en total`, r.undelivered, pctCell(r.undelivered, r.total)));
3061
+ const notCounted = (state) => r.notCounted.filter((b) => b.state === state);
3062
+ const open = notCounted("open");
3063
+ const dudosas = notCounted("unknown");
3064
+ if (open.length) {
3065
+ console.log((0, index_js_1.tr) ` No cuenta: ${open.length} ramas abiertas hace menos de ${staleDays} días (${(0, shared_1.formatAiCost)(sumSpend(open))}).`);
3066
+ }
3067
+ if (dudosas.length) {
3068
+ unknown = true;
3069
+ console.log((0, index_js_1.tr) ` No cuenta: ${dudosas.length} ramas borradas sin merge registrado (${(0, shared_1.formatAiCost)(sumSpend(dudosas))}): ${dudosas.slice(0, 4).map((b) => b.branch).join(", ")}${dudosas.length > 4 ? ", …" : ""}`);
3070
+ }
3071
+ unpriced ||= r.unpricedAgents;
3072
+ partial ||= r.total.realPartial;
3073
+ }
3074
+ console.log((0, index_js_1.tr) `\nSesión sin commit: un rato de un chat (sin pausas de más de 30 min) sin ningún commit tuyo en ese repo durante él ni en los 30 min siguientes.`);
3075
+ console.log((0, index_js_1.tr) `Rama sin fusionar: existe, no está integrada y lleva ${staleDays} días sin turnos ni commits. Cambia el plazo con --stale-days.`);
3076
+ console.log((0, index_js_1.tr) `Una sesión sin commit no se cuenta otra vez en su rama. Las ramas principales (main, develop…) ya son entrega.`);
3077
+ console.log((0, index_js_1.tr) `API es lo que costaría a precio de API: mide el uso, no es dinero pagado. Real es tu cuota repartida por consumo.`);
3078
+ if (unverifiable) {
3079
+ console.log((0, index_js_1.tr) `Antes de tu primer commit reconocido no se sabe si commiteabas con otro correo: esas sesiones no cuentan. Revisa tus correos con estela author.`);
3080
+ }
3081
+ if (unknown)
3082
+ console.log((0, index_js_1.tr) `Una rama borrada sin merge pudo fusionarse con squash, que no deja rastro en git: por eso no cuenta.`);
3083
+ console.log((0, index_js_1.tr) `Un squash también hace que una rama que sigue existiendo parezca sin fusionar: revisa la lista antes de sacar conclusiones.`);
3084
+ if (unpriced)
3085
+ console.log((0, index_js_1.tr) `Incluye trabajo con Copilot: no guarda tokens, así que su coste no se conoce y no está sumado.`);
3086
+ if (partial)
3087
+ console.log((0, index_js_1.tr) `† Algún mes no tenía suscripción registrada: el dinero real solo cuenta los meses con cuota.`);
3088
+ }
3089
+ function sumSpend(lines) {
3090
+ return { microUsd: lines.reduce((s, l) => s + l.consumption.microUsd, 0) };
3091
+ }
3092
+ function undeliveredJson(reports, projects, staleDays, args) {
3093
+ const spend = (s) => ({
3094
+ apiMicroUsd: s.consumption.microUsd,
3095
+ real: s.real ? { amountMinor: s.real.amount, currency: s.real.currency } : null,
3096
+ realPartial: s.realPartial,
3097
+ });
3098
+ const line = (b) => ({ branch: b.branch, state: b.state, idleDays: b.idleDays, ...spend(b) });
3099
+ return {
3100
+ staleDays,
3101
+ period: { month: str(args, "month") ?? null, from: str(args, "from") ?? null, to: str(args, "to") ?? null },
3102
+ projects: reports.map((r) => ({
3103
+ id: r.projectId, name: projects.get(r.projectId)?.name ?? r.projectId,
3104
+ clientName: projects.get(r.projectId)?.clientName ?? null,
3105
+ total: spend(r.total),
3106
+ unverifiableSessions: { count: r.unverifiable.count, ...spend(r.unverifiable) },
3107
+ sessionsWithoutCommit: { count: r.sessionsWithoutCommit.count, ...spend(r.sessionsWithoutCommit),
3108
+ laterCommitted: { count: r.sessionsWithoutCommit.laterCommitted.count, ...spend(r.sessionsWithoutCommit.laterCommitted) } },
3109
+ unmergedBranches: { ...spend(r.unmerged), branches: r.unmerged.branches.map(line) },
3110
+ undelivered: { ...spend(r.undelivered), percentOfSpend: (0, undelivered_js_1.shareOf)(r.undelivered, r.total) },
3111
+ notCounted: r.notCounted.map(line),
3112
+ unpricedAgents: r.unpricedAgents,
3113
+ })),
3114
+ };
3115
+ }
3116
+ function periodLabel(args) {
3117
+ const month = str(args, "month");
3118
+ if (month)
3119
+ return month;
3120
+ const from = str(args, "from");
3121
+ const to = str(args, "to");
3122
+ if (!from && !to)
3123
+ return (0, index_js_1.tr) `todo el historial`;
3124
+ return `${from ?? "…"} → ${to ?? (0, index_js_1.tr) `hoy`}`;
3125
+ }
3126
+ function basisLabel(b) {
3127
+ switch (b) {
3128
+ case "agent-branch": return (0, index_js_1.tr) `rama anotada por el agente`;
3129
+ case "commit-branch": return (0, index_js_1.tr) `rama del commit, aproximada`;
3130
+ case "branch-name": return (0, index_js_1.tr) `número en el nombre de la rama`;
3131
+ case "commit-message": return (0, index_js_1.tr) `referencia en un commit`;
3132
+ case "manual": return (0, index_js_1.tr) `puesto a mano`;
3133
+ case "merge": return (0, index_js_1.tr) `commit de merge del PR`;
3134
+ }
3135
+ }
3136
+ function unitLabel(u, by) {
3137
+ if (u.key === null)
3138
+ return by === "branch" ? (0, index_js_1.tr) `Sin atribuir (sin rama)` : (0, index_js_1.tr) `Sin atribuir (sin ticket ni PR)`;
3139
+ if (by === "branch")
3140
+ return u.key;
3141
+ const fromMerge = u.basis.length === 1 && u.basis[0] === "merge";
3142
+ return u.items.map((i) => (fromMerge ? (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(i)}` : (0, shared_1.formatWorkItem)(i))).join(", ");
3143
+ }
3144
+ function printSpend(reports, by, args, hasSubs) {
3145
+ const title = by === "branch" ? (0, index_js_1.tr) `Coste de IA por rama` : (0, index_js_1.tr) `Coste de IA por ticket o PR`;
3146
+ console.log(`${title} · ${periodLabel(args)}`);
3147
+ if (reports.length === 0) {
3148
+ console.log((0, index_js_1.tr) `\nNo hay horas en ese periodo.`);
3149
+ return;
3150
+ }
3151
+ const real = (u) => hasSubs ? (u.real ? (0, shared_1.formatMoney)(u.real) : "—") + (u.realPartial ? "†" : "") : "—";
3152
+ const row = (label, u) => ` ${label.slice(0, 44).padEnd(46)}${(0, shared_1.formatDuration)(u.seconds).padStart(9)}` +
3153
+ `${((0, shared_1.formatAiCost)(u.consumption) + (u.unpricedAgents ? "*" : "")).padStart(12)}${real(u).padStart(13)}`;
3154
+ let unpriced = false;
3155
+ let partial = false;
3156
+ let noTracker = false;
3157
+ for (const r of reports) {
3158
+ console.log(`\n${r.project.name} · ${r.project.clientName}`);
3159
+ console.log(` ${"".padEnd(46)}${(0, index_js_1.tr) `Horas`.padStart(9)}${(0, index_js_1.tr) `API`.padStart(12)}${(0, index_js_1.tr) `Real`.padStart(13)}`);
3160
+ for (const u of r.units) {
3161
+ console.log(row(unitLabel(u, by), u));
3162
+ const detail = [];
3163
+ if (by === "branch") {
3164
+ if (u.items.length)
3165
+ detail.push(u.items.map(shared_1.formatWorkItem).join(", "));
3166
+ if (u.prs.length)
3167
+ detail.push(u.prs.map((p) => (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(p)}`).join(", "));
3168
+ }
3169
+ else if (u.branches.length) {
3170
+ detail.push((0, index_js_1.tr) `ramas: ${u.branches.join(", ")}`);
3171
+ }
3172
+ if (u.basis.length)
3173
+ detail.push((0, index_js_1.tr) `según: ${u.basis.map(basisLabel).join(", ")}`);
3174
+ if (detail.length)
3175
+ console.log(` ${detail.join(" · ")}`);
3176
+ }
3177
+ console.log(row((0, index_js_1.tr) `Total`, r.total));
3178
+ unpriced ||= r.total.unpricedAgents;
3179
+ partial ||= r.total.realPartial;
3180
+ if (by === "item" && !r.project.tracker)
3181
+ noTracker = true;
3182
+ }
3183
+ console.log((0, index_js_1.tr) `\nAPI es lo que costaría a precio de API: mide el uso, no es dinero pagado.`);
3184
+ if (hasSubs)
3185
+ console.log((0, index_js_1.tr) `Real es tu cuota repartida por consumo: esto sí es dinero pagado.`);
3186
+ else
3187
+ console.log((0, index_js_1.tr) `Sin suscripciones registradas no hay dinero real. Si pagas cuota fija: estela subscription add`);
3188
+ console.log((0, index_js_1.tr) `Lo que no se puede atribuir va a "Sin atribuir" y no se reparte entre lo demás.`);
3189
+ if (unpriced)
3190
+ console.log((0, index_js_1.tr) `* Incluye trabajo con Copilot: sus horas cuentan, pero su coste no se conoce y no está sumado.`);
3191
+ if (partial)
3192
+ console.log((0, index_js_1.tr) `† Algún mes no tenía suscripción registrada: el dinero real solo cuenta los meses con cuota.`);
3193
+ if (noTracker) {
3194
+ console.log((0, index_js_1.tr) `Sin gestor configurado solo se reconocen tickets inequívocos (AB#12, PROJ-12) y los PR de los merges.`);
3195
+ console.log((0, index_js_1.tr) `Para leer #12 y feat/12-login: estela project tracker --project <id> --system github`);
3196
+ }
3197
+ }
3198
+ function spendJson(reports, by, args) {
3199
+ const realJson = (m) => (m ? { amountMinor: m.amount, currency: m.currency } : null);
3200
+ return {
3201
+ by,
3202
+ period: { month: str(args, "month") ?? null, from: str(args, "from") ?? null, to: str(args, "to") ?? null },
3203
+ projects: reports.map((r) => ({
3204
+ id: r.project.id, name: r.project.name, clientId: r.project.clientId, clientName: r.project.clientName,
3205
+ units: r.units.map((u) => ({
3206
+ key: u.key, attributed: u.key !== null, branches: u.branches, items: u.items, prs: u.prs,
3207
+ basis: u.basis, seconds: u.seconds, apiMicroUsd: u.consumption.microUsd,
3208
+ real: realJson(u.real), realPartial: u.realPartial, unpricedAgents: u.unpricedAgents,
3209
+ })),
3210
+ total: {
3211
+ seconds: r.total.seconds, apiMicroUsd: r.total.consumption.microUsd, real: realJson(r.total.real),
3212
+ realPartial: r.total.realPartial, unpricedAgents: r.total.unpricedAgents,
3213
+ },
3214
+ })),
3215
+ };
3216
+ }
2150
3217
  /**
2151
3218
  * El proyecto de un comando: el de --project, o el del repositorio en el que
2152
3219
  * se está. Es lo natural para lo que se pega en el README de ese repositorio.
@@ -2590,6 +3657,9 @@ async function main() {
2590
3657
  case "client add":
2591
3658
  cmdClientAdd(args, dbPath);
2592
3659
  break;
3660
+ case "client alias":
3661
+ cmdClientAlias(args, dbPath);
3662
+ break;
2593
3663
  case "project add":
2594
3664
  cmdProjectAdd(args, dbPath);
2595
3665
  break;
@@ -2615,7 +3685,7 @@ async function main() {
2615
3685
  cmdSubscriptionAdd(args, dbPath);
2616
3686
  break;
2617
3687
  case "ai-cost":
2618
- cmdAiCost(dbPath);
3688
+ await cmdAiCost(args, dbPath);
2619
3689
  break;
2620
3690
  case "quota":
2621
3691
  await cmdQuota(args, dbPath);
@@ -2656,6 +3726,12 @@ async function main() {
2656
3726
  case "unpublish":
2657
3727
  await cmdUnpublish(args, dbPath);
2658
3728
  break;
3729
+ case "delivery":
3730
+ await cmdDelivery(args, dbPath);
3731
+ break;
3732
+ case "summary":
3733
+ await cmdSummary(args, dbPath);
3734
+ break;
2659
3735
  case "plan":
2660
3736
  cmdPlan(args, dbPath);
2661
3737
  break;