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/i18n/en.js CHANGED
@@ -625,5 +625,171 @@ exports.EN = {
625
625
  "Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.": "No data yet. Install the status line (estela statusline install) and come back in a few days.",
626
626
  "Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.": "They're only collected in Claude Code in the terminal: the VS Code extension doesn't run the status line.",
627
627
  "{0} $": "${0}",
628
+ // ── cli.ts, metrics/spend.ts (ai-cost --by) ──
629
+ "--by va como branch o item.": "--by takes branch or item.",
630
+ "No existe el cliente \"{0}\".": "There's no client \"{0}\".",
631
+ "Falta el fichero: --csv <fichero.csv>": "Missing the file: --csv <file.csv>",
632
+ "CSV guardado en {0}": "CSV saved to {0}",
633
+ "todo el historial": "all history",
634
+ "rama anotada por el agente": "branch recorded by the agent",
635
+ "rama del commit, aproximada": "commit's branch, approximate",
636
+ "número en el nombre de la rama": "number in the branch name",
637
+ "referencia en un commit": "reference in a commit",
638
+ "puesto a mano": "set by hand",
639
+ "commit de merge del PR": "the PR's merge commit",
640
+ "Sin atribuir (sin rama)": "Unattributed (no branch)",
641
+ "Sin atribuir (sin ticket ni PR)": "Unattributed (no ticket or PR)",
642
+ "PR {0}": "PR {0}",
643
+ "Coste de IA por rama": "AI cost by branch",
644
+ "Coste de IA por ticket o PR": "AI cost by ticket or PR",
645
+ "\nNo hay horas en ese periodo.": "\nNo hours in that period.",
646
+ "Horas": "Hours",
647
+ "API": "API",
648
+ "Real": "Real",
649
+ "ramas: {0}": "branches: {0}",
650
+ "según: {0}": "based on: {0}",
651
+ "\nAPI es lo que costaría a precio de API: mide el uso, no es dinero pagado.": "\nAPI is what it would cost at API prices: it measures usage, it isn't money paid.",
652
+ "Real es tu cuota repartida por consumo: esto sí es dinero pagado.": "Real is your subscription split by usage: this is money actually paid.",
653
+ "Sin suscripciones registradas no hay dinero real. Si pagas cuota fija: estela subscription add": "With no subscriptions recorded there's no real money. If you pay a flat fee: estela subscription add",
654
+ "Lo que no se puede atribuir va a \"Sin atribuir\" y no se reparte entre lo demás.": "What can't be attributed goes to \"Unattributed\" and isn't spread over the rest.",
655
+ "* Incluye trabajo con Copilot: sus horas cuentan, pero su coste no se conoce y no está sumado.": "* Includes Copilot work: its hours count, but its cost is unknown and not added.",
656
+ "† Algún mes no tenía suscripción registrada: el dinero real solo cuenta los meses con cuota.": "† Some month had no subscription recorded: real money only counts the months with a fee.",
657
+ "Sin gestor configurado solo se reconocen tickets inequívocos (AB#12, PROJ-12) y los PR de los merges.": "With no tracker set, only unambiguous tickets (AB#12, PROJ-12) and the PRs in merges are recognised.",
658
+ "Para leer #12 y feat/12-login: estela project tracker --project <id> --system github": "To read #12 and feat/12-login: estela project tracker --project <id> --system github",
659
+ "Suscripciones en monedas distintas en el periodo ({0} y {1}). ": "Subscriptions in different currencies in the period ({0} and {1}). ",
660
+ // ── cli.ts (ai-cost --undelivered) ──
661
+ "--by y --undelivered van por separado: son dos informes distintos.": "--by and --undelivered go separately: they're two different reports.",
662
+ "--stale-days va como un número entero de días, 1 o más.": "--stale-days takes a whole number of days, 1 or more.",
663
+ "Gasto de IA sin entrega · {0} · ramas sin fusionar tras {1} días": "Undelivered AI spend · {0} · branches unmerged after {1} days",
664
+ "Es una señal para revisar, no un juicio: explorar, probar y descartar también es trabajo.": "It's a signal to review, not a verdict: exploring, trying and discarding is work too.",
665
+ "\nNo hay gasto de IA en ese periodo.": "\nNo AI spend in that period.",
666
+ "% gasto": "% spend",
667
+ "Gasto del periodo": "Spend in the period",
668
+ "Sesiones sin commit ({0})": "Sessions without a commit ({0})",
669
+ " {0} con commit después en su rama ({1}) · {2} sin commit después": " {0} with a later commit on their branch ({1}) · {2} with none later",
670
+ " No cuentan otras {0} ({1}): son de antes de tu primer commit reconocido en el repo.": " {0} more don't count ({1}): they predate your first recognised commit in the repo.",
671
+ "Ramas sin fusionar tras {0} días ({1})": "Branches unmerged after {0} days ({1})",
672
+ " {0} · sin actividad hace {1} días · {2}": " {0} · idle for {1} days · {2}",
673
+ " … y {0} más (--json para verlas todas)": " … and {0} more (--json to see them all)",
674
+ "Sin entrega en total": "Undelivered in total",
675
+ " No cuenta: {0} ramas abiertas hace menos de {1} días ({2}).": " Not counted: {0} branches active within the last {1} days ({2}).",
676
+ " No cuenta: {0} ramas borradas sin merge registrado ({1}): {2}{3}": " Not counted: {0} branches deleted with no recorded merge ({1}): {2}{3}",
677
+ "\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.": "\nSession without a commit: a stretch of one chat (no breaks over 30 min) with no commit of yours in that repo during it or in the 30 min after.",
678
+ "Rama sin fusionar: existe, no está integrada y lleva {0} días sin turnos ni commits. Cambia el plazo con --stale-days.": "Unmerged branch: it exists, isn't integrated, and has had no turns or commits for {0} days. Change the period with --stale-days.",
679
+ "Una sesión sin commit no se cuenta otra vez en su rama. Las ramas principales (main, develop…) ya son entrega.": "A session without a commit isn't counted again in its branch. Main branches (main, develop…) count as delivered.",
680
+ "API es lo que costaría a precio de API: mide el uso, no es dinero pagado. Real es tu cuota repartida por consumo.": "API is what it would cost at API prices: it measures usage, it isn't money paid. Real is your subscription split by usage.",
681
+ "Antes de tu primer commit reconocido no se sabe si commiteabas con otro correo: esas sesiones no cuentan. Revisa tus correos con estela author.": "Before your first recognised commit there's no telling whether you committed with another email: those sessions don't count. Check your emails with estela author.",
682
+ "Una rama borrada sin merge pudo fusionarse con squash, que no deja rastro en git: por eso no cuenta.": "A branch deleted with no merge may have been squash-merged, which leaves no trace in git: that's why it doesn't count.",
683
+ "Un squash también hace que una rama que sigue existiendo parezca sin fusionar: revisa la lista antes de sacar conclusiones.": "A squash also makes a branch that still exists look unmerged: check the list before drawing conclusions.",
684
+ "Incluye trabajo con Copilot: no guarda tokens, así que su coste no se conoce y no está sumado.": "Includes Copilot work: it doesn't store tokens, so its cost is unknown and not added.",
685
+ // ── cli.ts (delivery, project tracker --plan-label) ──
686
+ "Primero el gestor: estela project tracker --project {0} --system github": "Set the tracker first: estela project tracker --project {0} --system github",
687
+ "Lo planificado de \"{0}\" es lo que lleva la etiqueta \"{1}\".": "What's planned for \"{0}\" is whatever carries the \"{1}\" label.",
688
+ "\"{0}\" ya no tiene etiqueta de plan.": "\"{0}\" no longer has a plan label.",
689
+ "Falta la etiqueta: --plan-label <etiqueta> (o none para quitarla).": "Missing the label: --plan-label <label> (or none to remove it).",
690
+ "--label y --milestone van por separado: elige una forma de marcar el plan.": "--label and --milestone go separately: pick one way to mark the plan.",
691
+ "Este proyecto no tiene GitHub como gestor, así que no se lee el plan. Para leerlo:": "This project doesn't use GitHub as its tracker, so the plan isn't read. To read it:",
692
+ " estela project tracker --project {0} --system github --plan-label <etiqueta>": " estela project tracker --project {0} --system github --plan-label <label>",
693
+ "La carpeta del repo de este proyecto ya no existe: gh no tiene dónde mirar.": "This project's repo folder no longer exists: gh has nowhere to look.",
694
+ "--offline: no se consulta GitHub.": "--offline: GitHub isn't queried.",
695
+ "GitHub (gh), leído ahora": "GitHub (gh), read just now",
696
+ "GitHub, lo guardado el {0} (sin conexión)": "GitHub, as saved on {0} (offline)",
697
+ "git: los PR de los commits de merge y de los squash con (#N)": "git: the PRs from merge commits and from squashes ending in (#N)",
698
+ "Sin etiqueta ni milestone de plan solo se enseña lo entregado. Indícalo con --label o --milestone,": "With no plan label or milestone only what was delivered is shown. Set one with --label or --milestone,",
699
+ "o déjalo fijo: estela project tracker --project {0} --plan-label <etiqueta>": "or keep it: estela project tracker --project {0} --plan-label <label>",
700
+ "Con --to hace falta --from.": "--to needs --from.",
701
+ "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": "gh isn't installed: without it the GitHub plan can't be read. Install it from https://cli.github.com and run: gh auth login",
702
+ "gh no tiene sesión iniciada. Ejecuta: gh auth login": "gh isn't logged in. Run: gh auth login",
703
+ "gh no reconoce un repo de GitHub en la carpeta del proyecto ({0}).": "gh doesn't recognise a GitHub repo in the project folder ({0}).",
704
+ "sin plan": "no plan",
705
+ "etiqueta \"{0}\"": "label \"{0}\"",
706
+ "milestone \"{0}\"": "milestone \"{0}\"",
707
+ "PR #{0}": "PR #{0}",
708
+ "Planificado frente a entregado · {0} · {1} → {2}": "Planned versus delivered · {0} · {1} → {2}",
709
+ "Plan: {0} · Fuente: {1}": "Plan: {0} · Source: {1}",
710
+ " (nada)": " (nothing)",
711
+ "fusionado {0}": "merged {0}",
712
+ "abierto": "open",
713
+ "cerrado sin fusionar": "closed without merging",
714
+ "entregada por {0}": "delivered by {0}",
715
+ "cerrada {0}": "closed {0}",
716
+ "abierta": "open",
717
+ "cerrada sin hacer": "closed as not done",
718
+ "coste según: {0}": "cost based on: {0}",
719
+ "rama del PR": "the PR's branch",
720
+ "número en rama o commit": "number in a branch or commit",
721
+ "sin horas unidas": "no hours linked",
722
+ "Total del bloque": "Block total",
723
+ "Planificado y entregado ({0} de {1})": "Planned and delivered ({0} of {1})",
724
+ "Planificado sin entregar ({0})": "Planned, not delivered ({0})",
725
+ "Entregado sin planificar ({0})": "Delivered, not planned ({0})",
726
+ "Entregado ({0})": "Delivered ({0})",
727
+ "\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.": "\nEach line's cost is that of the hours linked to it: the PR's branch, and its number in branches, commits or set by hand.",
728
+ "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.": "It includes work done before the period for that delivery. If an hour is linked to two lines, the block total counts it once.",
729
+ // ── cli.ts (delivery --by client, client alias) ──
730
+ "Las peticiones de clientes de \"{0}\" llevan la etiqueta \"{1}<nombre>\".": "Client requests in \"{0}\" carry the \"{1}<name>\" label.",
731
+ "Falta el prefijo: --client-label <prefijo> (o none para volver a \"cliente:\").": "Missing the prefix: --client-label <prefix> (or none to go back to \"cliente:\").",
732
+ "Falta --label <nombre>": "Missing --label <name>",
733
+ "\"{0}\" ya no apunta a ningún cliente.": "\"{0}\" no longer points to any client.",
734
+ "\"{0}\" no tenía alias.": "\"{0}\" had no alias.",
735
+ "Sin alias. Las etiquetas se comparan con el id y el nombre de cada cliente.": "No aliases. Labels are matched against each client's id and name.",
736
+ "Hacen falta --client <id> y --label <nombre>.": "Both --client <id> and --label <name> are needed.",
737
+ "La etiqueta necesita al menos una letra o un número.": "The label needs at least one letter or digit.",
738
+ "La etiqueta \"{0}\" es {1}.": "The \"{0}\" label is {1}.",
739
+ "--by en delivery solo admite client.": "--by in delivery only takes client.",
740
+ "Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.": "Without GitHub there are no labels: there's no telling what each client asked for, so it all goes to the rest.",
741
+ "Peticiones de clientes · {0} · {1} → {2}": "Client requests · {0} · {1} → {2}",
742
+ "Etiqueta: \"{0}<nombre>\" · Fuente: {1}": "Label: \"{0}<name>\" · Source: {1}",
743
+ "Estela no conoce ingresos ni contratos: esto solo une el gasto de IA con quién pidió el trabajo.": "Estela knows nothing about revenue or contracts: this only links AI spend to who asked for the work.",
744
+ "\"{0}\" (sin mapear)": "\"{0}\" (unmapped)",
745
+ "Varios: {0}": "Several: {0}",
746
+ " · y {0} más": " · and {0} more",
747
+ " (ninguna petición de cliente con horas en el periodo)": " (no client request with hours in the period)",
748
+ "Resto (sin petición de cliente)": "Rest (no client request)",
749
+ "Total del periodo": "Period total",
750
+ "\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).": "\nAn hour counts for whoever asked for the issue or PR it's linked to (the PR's branch, its number in branches, commits or set by hand).",
751
+ "Si sirvió a peticiones de varios clientes va a \"Varios\", sin repartirla. Un PR hereda las etiquetas de las issues que cierra.": "If it served requests from several clients it goes to \"Several\", unsplit. A PR inherits the labels of the issues it closes.",
752
+ "Para unir \"{0}\" con un cliente de Estela: estela client alias --client <id> --label \"{1}\"": "To link \"{0}\" to an Estela client: estela client alias --client <id> --label \"{1}\"",
753
+ // ── export/finance.ts, cli.ts (summary) ──
754
+ "--format va como md, csv o json.": "--format takes md, csv or json.",
755
+ "Informe guardado en {0}": "Report saved to {0}",
756
+ "# Gasto en IA · {0}": "# AI spend · {0}",
757
+ "## Resumen": "## Summary",
758
+ "- **Pagado en IA:** {0}, la cuota de las herramientas de IA repartida según su uso.": "- **Paid for AI:** {0}, the AI tools' subscription split by how it was used.",
759
+ "- **Gasto en IA:** {0} a precio de lista. No hay cuota registrada, así que es una estimación, no lo pagado.": "- **AI spend:** {0} at list price. No subscription is recorded, so this is an estimate, not what was paid.",
760
+ "1 cliente": "1 client",
761
+ "{0} clientes": "{0} clients",
762
+ "- **Horas de trabajo con IA:** {0} en {1} de {2}.": "- **Hours of AI-assisted work:** {0} across {1} for {2}.",
763
+ "- **Fuera de proyectos:** {0} ({1}) en trabajo que no es de ningún proyecto registrado.": "- **Outside projects:** {0} ({1}) on work that belongs to no registered project.",
764
+ "- **Sin entregar todavía:** {0} del gasto en proyectos. Es una señal para revisar, no un juicio: explorar y descartar también es trabajo.": "- **Not delivered yet:** {0} of project spend. It's a signal to review, not a verdict: exploring and discarding is work too.",
765
+ "## Por cliente": "## By client",
766
+ "| Cliente | Proyecto | Horas | Gasto | % del total |": "| Client | Project | Hours | Spend | % of total |",
767
+ "| **{0}** | **total** | **{1}** | **{2}** | **{3}** |": "| **{0}** | **total** | **{1}** | **{2}** | **{3}** |",
768
+ "| Fuera de proyectos | — | — | {0} | {1} |": "| Outside projects | — | — | {0} | {1} |",
769
+ "| **Total** | | **{0}** | **{1}** | **100 %** |": "| **Total** | | **{0}** | **{1}** | **100 %** |",
770
+ "## Qué se entregó": "## What was delivered",
771
+ "Del plan ({0}) se entregaron {1} de {2}. Lo pendiente lleva {3} de gasto; lo entregado fuera del plan, {4}.": "Of the plan ({0}), {1} of {2} were delivered. What's pending has {3} of spend; what was delivered outside the plan, {4}.",
772
+ "| Entrega | Horas | Gasto |": "| Delivery | Hours | Spend |",
773
+ "| … y {0} más | | |": "| … and {0} more | | |",
774
+ "_Fuente: GitHub, leído el {0} con estela delivery._": "_Source: GitHub, read on {0} with estela delivery._",
775
+ "_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._": "_Source: the git history (merged PRs). Without GitHub there's no telling what was planned._",
776
+ "## Trabajo pedido por clientes": "## Work requested by clients",
777
+ "Qué parte del gasto fue a peticiones concretas de un cliente. No incluye ingresos ni contratos: Estela no los conoce.": "How much of the spend went to a client's specific requests. It includes no revenue or contracts: Estela doesn't know them.",
778
+ "| Proyecto | Quién lo pidió | Horas | Gasto |": "| Project | Requested by | Hours | Spend |",
779
+ "{0} (sin identificar)": "{0} (unidentified)",
780
+ "| {0} | sin petición de cliente | {1} | {2} |": "| {0} | no client request | {1} | {2} |",
781
+ "## Gasto que no ha llegado a entregarse": "## Spend that hasn't been delivered",
782
+ "Trabajo que todavía no está en la versión principal del producto. Es una señal para revisar, no un juicio: explorar, probar y descartar también es trabajo, y parte llegará más adelante.": "Work that isn't in the main version of the product yet. It's a signal to review, not a verdict: exploring, trying and discarding is work too, and some of it will land later.",
783
+ "| Proyecto | Sin entregar | % del proyecto | Trabajo sin consolidar | Líneas de trabajo paradas |": "| Project | Not delivered | % of project | Work not consolidated | Stalled lines of work |",
784
+ "## Cómo se calcula": "## How it's calculated",
785
+ "- Las herramientas de IA (Claude Code, Codex, Copilot) dejan en el ordenador un registro de cada conversación. Estela lo lee junto con git, sin enviar nada fuera.": "- AI tools (Claude Code, Codex, Copilot) leave a log of each conversation on the computer. Estela reads it together with git, without sending anything out.",
786
+ "- **Gasto:** la cuota mensual de las suscripciones registradas, repartida según el uso (tokens a precio de lista) de cada proyecto ese mes. El precio de lista sirve solo para repartir: con cuota fija no es lo que se paga.": "- **Spend:** the monthly fee of the recorded subscriptions, split by each project's usage (tokens at list price) that month. List price is only used to split: with a flat fee it isn't what's paid.",
787
+ "- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.": "- **Spend:** tokens at each model's list price. If a flat fee is paid, the real spend is the fee: record it with estela subscription add.",
788
+ "- **Valor de uso a precio de lista:** {0} en todo el periodo.": "- **Usage value at list price:** {0} for the whole period.",
789
+ "- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.": "- **Hours:** the time between messages in each work session, leaving out breaks over 30 minutes. Two tasks at once share the same stretch of time.",
790
+ "- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.": "- **Deliveries:** hours are linked to an issue or PR by the PR's branch and by its number in branches or commits. Whatever links to nothing isn't spread around.",
791
+ "- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras {0} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.": "- **Not delivered:** sessions with no commit and branches unmerged after {0} days. What can't be known (a deleted branch that may have been squash-merged) isn't counted.",
792
+ "- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.": "- Copilot doesn't record tokens: its hours count, but its cost is unknown and not added.",
793
+ "- † Algún mes no tenía cuota registrada: el importe solo cuenta los meses con cuota.": "- † Some month had no subscription recorded: the amount only counts the months with a fee.",
628
794
  };
629
795
  //# sourceMappingURL=en.js.map
@@ -0,0 +1,83 @@
1
+ import type { AiCost, Money, Subscription, TimeEntry, WorkItemTracker } from "@estela/shared";
2
+ import type { MergeRecord } from "../watchers/git.js";
3
+ /**
4
+ * Coste de IA por entrega: por rama, o por ticket y PR.
5
+ *
6
+ * Función pura sobre las imputaciones ya guardadas, no sobre los turnos: así
7
+ * las cifras cuadran con `estela ai-cost` y con las facturas, y un día
8
+ * aprobado o facturado se lee tal cual quedó, sin recalcular nada.
9
+ *
10
+ * Nada se reparte a ojo. Una imputación ya es de una sola rama (la sesión que
11
+ * cambia de rama queda partida turno a turno al importar). Lo que no tiene
12
+ * rama, o con --by item no tiene ticket ni PR, va a "sin atribuir". Y cada
13
+ * atribución dice en qué se basa, porque no todas valen lo mismo: la rama que
14
+ * anotó el agente es un hecho; la de un commit sin agente la deduce git.
15
+ */
16
+ export type SpendBy = "branch" | "item";
17
+ /** De dónde sale una atribución. */
18
+ export type Basis =
19
+ /** La rama que anotó el agente en el transcript. */
20
+ "agent-branch"
21
+ /** La rama que git asocia al commit (--source): aproximada. */
22
+ | "commit-branch"
23
+ /** El número de ticket en el nombre de la rama. */
24
+ | "branch-name"
25
+ /** `#12`, `AB#12` o `PROJ-12` en un mensaje de commit. */
26
+ | "commit-message"
27
+ /** Puesto a mano con `estela entry link`. */
28
+ | "manual"
29
+ /** El commit de merge del PR ("Merge pull request #12 from …"). */
30
+ | "merge";
31
+ export interface SpendProject {
32
+ readonly id: string;
33
+ readonly name: string;
34
+ readonly clientId: string;
35
+ readonly clientName: string;
36
+ readonly repoPaths: readonly string[];
37
+ readonly tracker?: WorkItemTracker | null;
38
+ }
39
+ export interface SpendUnit {
40
+ /** Rama o tickets; `null` es "sin atribuir". */
41
+ readonly key: string | null;
42
+ readonly branches: readonly string[];
43
+ /** Tickets (`github:12`, `jira:PROJ-4`) y PRs (`github:30` del merge). */
44
+ readonly items: readonly string[];
45
+ readonly prs: readonly string[];
46
+ readonly basis: readonly Basis[];
47
+ readonly seconds: number;
48
+ /** A precio de API: medida de uso, no dinero pagado. */
49
+ readonly consumption: AiCost;
50
+ /** La cuota repartida por consumo. `null` sin suscripción registrada. */
51
+ readonly real: Money | null;
52
+ /** Algún mes del bloque no tenía suscripción: `real` cuenta solo el resto. */
53
+ readonly realPartial: boolean;
54
+ /** Hay trabajo de un agente sin coste conocido (Copilot): el coste se queda corto. */
55
+ readonly unpricedAgents: boolean;
56
+ }
57
+ export interface SpendProjectReport {
58
+ readonly project: SpendProject;
59
+ readonly units: readonly SpendUnit[];
60
+ readonly total: Omit<SpendUnit, "key" | "branches" | "items" | "prs" | "basis">;
61
+ }
62
+ export interface SpendInput {
63
+ readonly entries: readonly TimeEntry[];
64
+ readonly projects: readonly SpendProject[];
65
+ readonly merges: readonly MergeRecord[];
66
+ readonly subscriptions: readonly Subscription[];
67
+ /** Consumo de toda la máquina por mes: el denominador de la cuota (ver amortize). */
68
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
69
+ }
70
+ export declare function spendByUnit(input: SpendInput, by: SpendBy): SpendProjectReport[];
71
+ /**
72
+ * Dinero real: la cuota del mes por la fracción de todo el consumo de ese mes
73
+ * que es de esta parte. La fórmula de `amortize`, solo que con una parte
74
+ * cualquiera (una rama, unas sesiones) en vez del proyecto; redondea por parte,
75
+ * así que la suma puede diferir en un céntimo de la de `ai-cost`.
76
+ *
77
+ * `partial`: algún mes con consumo no tenía cuota, y `real` cuenta solo el resto.
78
+ */
79
+ export declare function realFromMonths(byMonth: ReadonlyMap<string, number>, subscriptions: readonly Subscription[], monthTotal: (month: string) => number): {
80
+ real: Money | null;
81
+ partial: boolean;
82
+ };
83
+ //# sourceMappingURL=spend.d.ts.map
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spendByUnit = spendByUnit;
4
+ exports.realFromMonths = realFromMonths;
5
+ const shared_1 = require("@estela/shared");
6
+ const amortize_js_1 = require("../billing/amortize.js");
7
+ const index_js_1 = require("../i18n/index.js");
8
+ const paths_js_1 = require("../paths.js");
9
+ /** Agentes cuyo transcript no trae tokens: su trabajo cuenta horas pero no coste. */
10
+ const UNPRICED_AGENTS = new Set(["copilot"]);
11
+ function spendByUnit(input, by) {
12
+ const reports = [];
13
+ // Como en `amortize`: el denominador nunca baja de lo imputado ese mes, o un
14
+ // mes sin turnos guardados le daría la cuota entera a cada unidad.
15
+ const assignedByMonth = new Map();
16
+ for (const e of input.entries) {
17
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
18
+ assignedByMonth.set(month, (assignedByMonth.get(month) ?? 0) + e.aiCost.microUsd);
19
+ }
20
+ for (const project of input.projects) {
21
+ const entries = input.entries.filter((e) => e.projectId === project.id);
22
+ if (entries.length === 0)
23
+ continue;
24
+ const prsByBranch = mergedPrs(input.merges, project.repoPaths);
25
+ const groups = new Map();
26
+ for (const entry of entries) {
27
+ const a = attribute(entry, project.tracker ?? null, prsByBranch);
28
+ const key = by === "branch" ? entry.branch : a.items.length ? a.items.join(",") : null;
29
+ const id = key ?? "\u0000";
30
+ let acc = groups.get(id);
31
+ if (!acc) {
32
+ acc = newAcc(key);
33
+ groups.set(id, acc);
34
+ }
35
+ add(acc, entry, a, by);
36
+ }
37
+ const units = [...groups.values()].map((acc) => finish(acc, input, assignedByMonth));
38
+ units.sort((x, y) => (x.key === null ? 1 : 0) - (y.key === null ? 1 : 0)
39
+ || y.consumption.microUsd - x.consumption.microUsd || y.seconds - x.seconds);
40
+ reports.push({ project, units, total: sumUnits(units) });
41
+ }
42
+ return reports;
43
+ }
44
+ /** PRs fusionados por rama, de los merges de los repos del proyecto. */
45
+ function mergedPrs(merges, repos) {
46
+ const out = new Map();
47
+ for (const m of merges) {
48
+ if (!m.sourceBranch || !m.ref)
49
+ continue;
50
+ if (!repos.some((r) => (0, paths_js_1.isSameOrInside)(m.repoPath, r) || (0, paths_js_1.isSameOrInside)(r, m.repoPath)))
51
+ continue;
52
+ const list = out.get(m.sourceBranch) ?? [];
53
+ if (!list.includes(m.ref))
54
+ list.push(m.ref);
55
+ out.set(m.sourceBranch, list);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Los tickets de una imputación y en qué se basan. Primero los que ya tiene
61
+ * (rama, commits o a mano); si no tiene ninguno, el PR con que se fusionó su
62
+ * rama. Un PR no sustituye a un ticket que ya se conoce: son cosas distintas.
63
+ */
64
+ function attribute(entry, tracker, prsByBranch) {
65
+ const prs = entry.branch ? prsByBranch.get(entry.branch) ?? [] : [];
66
+ const known = entry.workItems ?? [];
67
+ if (known.length) {
68
+ const fromBranch = new Set((0, shared_1.extractWorkItems)(entry.branch, [], tracker));
69
+ const itemBasis = entry.workItemsManual
70
+ ? ["manual"]
71
+ : known.map((k) => (fromBranch.has(k) ? "branch-name" : "commit-message"));
72
+ return { items: [...known], prs, itemBasis };
73
+ }
74
+ if (prs.length)
75
+ return { items: [...prs], prs, itemBasis: ["merge"] };
76
+ return { items: [], prs, itemBasis: [] };
77
+ }
78
+ function newAcc(key) {
79
+ return { key, branches: new Set(), items: new Set(), prs: new Set(), basis: new Set(),
80
+ seconds: 0, microUsd: 0, byMonth: new Map(), unpricedAgents: false };
81
+ }
82
+ function add(acc, entry, a, by) {
83
+ acc.seconds += entry.seconds;
84
+ acc.microUsd += entry.aiCost.microUsd;
85
+ // El mes de started_at, el mismo que usa `estela ai-cost` para repartir.
86
+ const month = (0, amortize_js_1.monthOf)(entry.startedAt);
87
+ acc.byMonth.set(month, (acc.byMonth.get(month) ?? 0) + entry.aiCost.microUsd);
88
+ if (entry.agents.some((x) => UNPRICED_AGENTS.has(x)))
89
+ acc.unpricedAgents = true;
90
+ if (entry.branch)
91
+ acc.branches.add(entry.branch);
92
+ // Por rama, los PRs van en su sitio: el del merge no se repite como ticket.
93
+ const fromMerge = a.itemBasis.length === 1 && a.itemBasis[0] === "merge";
94
+ if (by === "item" || !fromMerge)
95
+ for (const i of a.items)
96
+ acc.items.add(i);
97
+ for (const p of a.prs)
98
+ acc.prs.add(p);
99
+ if (by === "branch") {
100
+ if (entry.branch)
101
+ acc.basis.add(entry.agents.length ? "agent-branch" : "commit-branch");
102
+ }
103
+ else if (a.items.length) {
104
+ for (const b of a.itemBasis)
105
+ acc.basis.add(b);
106
+ }
107
+ }
108
+ /**
109
+ * Dinero real: la cuota del mes por la fracción de todo el consumo de ese mes
110
+ * que es de esta parte. La fórmula de `amortize`, solo que con una parte
111
+ * cualquiera (una rama, unas sesiones) en vez del proyecto; redondea por parte,
112
+ * así que la suma puede diferir en un céntimo de la de `ai-cost`.
113
+ *
114
+ * `partial`: algún mes con consumo no tenía cuota, y `real` cuenta solo el resto.
115
+ */
116
+ function realFromMonths(byMonth, subscriptions, monthTotal) {
117
+ let real = null;
118
+ let partial = false;
119
+ if (!subscriptions.length)
120
+ return { real, partial };
121
+ for (const [month, micro] of byMonth) {
122
+ const fee = (0, amortize_js_1.feeForMonth)(subscriptions, month);
123
+ if (!fee) {
124
+ if (micro > 0)
125
+ partial = true;
126
+ continue;
127
+ }
128
+ const total = Math.max(monthTotal(month), micro);
129
+ const part = total === 0 ? 0 : Math.round(fee.amount * (micro / total));
130
+ if (real && real.currency !== fee.currency) {
131
+ throw new TypeError((0, index_js_1.tr) `Suscripciones en monedas distintas en el periodo (${real.currency} y ${fee.currency}). ` +
132
+ (0, index_js_1.tr) `Unifica la moneda de las suscripciones: no se convierten automáticamente.`);
133
+ }
134
+ real = (0, shared_1.money)((real?.amount ?? 0) + part, fee.currency);
135
+ }
136
+ return { real, partial };
137
+ }
138
+ function finish(acc, input, assignedByMonth) {
139
+ const { real, partial } = realFromMonths(acc.byMonth, input.subscriptions, (month) => Math.max(input.monthTotals.get(month)?.microUsd ?? 0, assignedByMonth.get(month) ?? 0));
140
+ return {
141
+ key: acc.key,
142
+ branches: [...acc.branches].sort(),
143
+ items: [...acc.items],
144
+ prs: [...acc.prs],
145
+ basis: [...acc.basis],
146
+ seconds: acc.seconds,
147
+ consumption: { microUsd: acc.microUsd },
148
+ real,
149
+ realPartial: partial,
150
+ unpricedAgents: acc.unpricedAgents,
151
+ };
152
+ }
153
+ function sumUnits(units) {
154
+ let real = null;
155
+ for (const u of units) {
156
+ if (u.real)
157
+ real = (0, shared_1.money)((real?.amount ?? 0) + u.real.amount, u.real.currency);
158
+ }
159
+ return {
160
+ seconds: units.reduce((s, u) => s + u.seconds, 0),
161
+ consumption: { microUsd: units.reduce((s, u) => s + u.consumption.microUsd, 0) },
162
+ real,
163
+ realPartial: units.some((u) => u.realPartial),
164
+ unpricedAgents: units.some((u) => u.unpricedAgents),
165
+ };
166
+ }
167
+ //# sourceMappingURL=spend.js.map
@@ -0,0 +1,104 @@
1
+ import type { AiCost, AgentKind, Money, Subscription } from "@estela/shared";
2
+ /**
3
+ * Gasto de IA sin entrega: lo que no ha llegado (todavía) a la rama principal.
4
+ *
5
+ * Es una señal para revisar, no un juicio. Explorar, probar y descartar es
6
+ * trabajo, y una sesión en la que Claude cambia algo cinco veces antes de que
7
+ * lo des por bueno vale lo mismo aunque aún no tenga commit. Por eso nada de
8
+ * esto resta horas ni toca imputaciones: solo pone una cifra al lado.
9
+ *
10
+ * Se mide sobre los turnos, no sobre las imputaciones: una sesión es un rato
11
+ * de un chat, y una imputación es un día de una rama que puede juntar varias.
12
+ * Dos bloques que no se pisan:
13
+ *
14
+ * 1. Sesiones sin commit: un rato de trabajo de un chat (sin pausas de más de
15
+ * 30 min) en el que no hiciste ningún commit en ese repo, ni en los 30 min
16
+ * siguientes. Se separan las que luego sí tuvieron commit en su rama. Las
17
+ * anteriores a tu primer commit conocido en el repo no se juzgan: quizá
18
+ * commiteabas con otro correo.
19
+ * 2. Ramas sin fusionar: el resto del coste de una rama que sigue existiendo,
20
+ * no está integrada y lleva N días sin actividad. Una rama borrada sin
21
+ * merge registrado puede ser un squash: su estado es "desconocido" y no
22
+ * cuenta, porque contarla inflaría la cifra.
23
+ */
24
+ export interface UndeliveredTurn {
25
+ readonly projectId: string;
26
+ readonly sessionId: string;
27
+ readonly at: Date;
28
+ /** Raíz git (el repo principal, también para un worktree). */
29
+ readonly root: string;
30
+ readonly branch: string | null;
31
+ readonly microUsd: number;
32
+ readonly agent: AgentKind;
33
+ }
34
+ /** Un commit tuyo, con su raíz git ya resuelta igual que la de los turnos. */
35
+ export interface CommitFact {
36
+ readonly root: string;
37
+ readonly at: Date;
38
+ readonly branch: string | null;
39
+ }
40
+ export interface BranchFacts {
41
+ /** Integrada: la rama base, `git branch --merged` o un commit de merge. */
42
+ readonly merged: boolean;
43
+ /** `null` si el repo ya no se puede leer: no se sabe. */
44
+ readonly exists: boolean | null;
45
+ }
46
+ export interface UndeliveredInput {
47
+ readonly turns: readonly UndeliveredTurn[];
48
+ readonly commits: readonly CommitFact[];
49
+ readonly branchFacts: (root: string, branch: string) => BranchFacts;
50
+ readonly staleDays: number;
51
+ readonly now: Date;
52
+ /** Fechas locales; sin ellas, todo. Se mira la fecha de inicio de la sesión. */
53
+ readonly from?: string;
54
+ readonly to?: string;
55
+ readonly subscriptions: readonly Subscription[];
56
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
57
+ }
58
+ export interface Spend {
59
+ readonly consumption: AiCost;
60
+ readonly real: Money | null;
61
+ readonly realPartial: boolean;
62
+ }
63
+ export type BranchState = "unmerged" | "open" | "unknown";
64
+ export interface BranchLine extends Spend {
65
+ readonly branch: string;
66
+ readonly state: BranchState;
67
+ /** Días desde la última actividad (turno o commit) en la rama. */
68
+ readonly idleDays: number;
69
+ }
70
+ export interface UndeliveredReport {
71
+ readonly projectId: string;
72
+ /** Todo el gasto del periodo en el proyecto: la base del porcentaje. */
73
+ readonly total: Spend;
74
+ /**
75
+ * Sesiones sin commit que no se pueden juzgar: no se conoce ningún commit tuyo
76
+ * en ese repo, o la sesión es anterior al primero. Puede ser que commitearas
77
+ * con otro correo (ver `estela author`), así que no cuentan.
78
+ */
79
+ readonly unverifiable: Spend & {
80
+ readonly count: number;
81
+ };
82
+ readonly sessionsWithoutCommit: Spend & {
83
+ readonly count: number;
84
+ /** De ellas, las que luego tuvieron commit en su rama (dentro de N días). */
85
+ readonly laterCommitted: Spend & {
86
+ readonly count: number;
87
+ };
88
+ };
89
+ /** Ramas que cuentan: sin fusionar tras N días. */
90
+ readonly unmerged: Spend & {
91
+ readonly branches: readonly BranchLine[];
92
+ };
93
+ /** No cuentan: abiertas hace menos de N días, o de estado desconocido. */
94
+ readonly notCounted: readonly BranchLine[];
95
+ /** Suma de los dos bloques que cuentan. */
96
+ readonly undelivered: Spend;
97
+ readonly unpricedAgents: boolean;
98
+ }
99
+ /** Ramas donde se integra: trabajar directamente en ellas ya es entregar. */
100
+ export declare const BASE_BRANCHES: Set<string>;
101
+ export declare function undeliveredSpend(input: UndeliveredInput): UndeliveredReport[];
102
+ /** El porcentaje de una parte sobre el total, en tanto por ciento. `null` sin total. */
103
+ export declare function shareOf(part: Spend, total: Spend): number | null;
104
+ //# sourceMappingURL=undelivered.d.ts.map