estela 0.3.3 → 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 (44) 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 +100 -0
  8. package/dist/db/store.d.ts +46 -0
  9. package/dist/db/store.js +179 -5
  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 +171 -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 +68 -8
  28. package/dist/setup.js +1 -1
  29. package/dist/watchers/git.d.ts +40 -0
  30. package/dist/watchers/git.js +87 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  32. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  33. package/node_modules/@estela/shared/dist/money.js +7 -7
  34. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  35. package/node_modules/@estela/shared/dist/types.d.ts +7 -0
  36. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  37. package/node_modules/@estela/shared/dist/types.js.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  39. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  40. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  41. package/package.json +1 -1
  42. package/web/app.css +4 -0
  43. package/web/app.js +67 -0
  44. package/web/i18n.js +24 -0
package/README.md CHANGED
@@ -241,6 +241,103 @@ estela ai-cost
241
241
  Cache is counted separately because it's usually most of the bill — ignoring it
242
242
  underestimated spend fivefold.
243
243
 
244
+ ### What that spend delivered
245
+
246
+ `--by` breaks the cost down by delivery: by branch, or by ticket and PR.
247
+
248
+ ```sh
249
+ estela ai-cost --by branch --month 2026-10 # by branch
250
+ estela ai-cost --by item --client acme --json # by ticket or PR, for one client
251
+ estela ai-cost --by branch --from 2026-10-01 --csv cost.csv
252
+ ```
253
+
254
+ Each row has its hours and two figures: the cost at API prices, which measures
255
+ usage, and your subscription split by usage, which is the money paid. It also
256
+ says what the attribution is based on: the branch the agent recorded, the number
257
+ in the branch (`feat/12-login`), a `#12` in a commit, set by hand with `estela
258
+ entry link`, or the PR's merge commit ("Merge pull request #31 from …"). Anything
259
+ without a basis goes whole to "Unattributed": nothing is spread by guesswork.
260
+
261
+ A PR merged with squash leaves no merge in git, so its branch isn't linked to the
262
+ PR. Copilot doesn't store tokens, so its hours count but its cost is unknown
263
+ (marked with `*`). The report only reads what's recorded: it never recalculates
264
+ approved or invoiced days.
265
+
266
+ ### Planned versus delivered
267
+
268
+ ```sh
269
+ estela project tracker --project shop --system github --plan-label planned
270
+ estela delivery --project shop --month 2026-10
271
+ estela delivery --project shop --milestone "Sprint 4" --json
272
+ ```
273
+
274
+ Three blocks, each with its hours and AI cost: what was planned and delivered in
275
+ the period (an issue closed as completed, or closed by a merged PR), what was
276
+ planned and not delivered, and what was delivered without being planned. Planned
277
+ means it carries a GitHub label or milestone. Each line's cost is that of the
278
+ hours linked to it: the PR's branch (squash merges included) and its number in
279
+ branches, commits or set by hand.
280
+
281
+ It reads GitHub with `gh`, the official CLI, if it's installed and logged in.
282
+ That's the only call off your machine, only for projects with `--system github`,
283
+ and `--offline` avoids it. What it reads is saved, so the report works offline and
284
+ `--undelivered` knows which branches were squash-merged. Without `gh` the command
285
+ says so and shows what git says was delivered.
286
+
287
+ ### What each client asked for
288
+
289
+ ```sh
290
+ estela delivery --project shop --by client --month 2026-10
291
+ estela client alias --client acme --label "acme-retail"
292
+ ```
293
+
294
+ An issue or PR labelled `cliente:<name>` is that client's request, and a PR
295
+ inherits the labels of the issues it closes. The report gives the period's AI
296
+ spend on each client's requests, against the rest. An hour that served requests
297
+ from two clients goes to "Several", unsplit. The label's name is matched against
298
+ your clients' ids and names; if nothing matches, it shows as "unmapped" until you
299
+ give it an alias. Change the prefix with `estela project tracker --client-label`.
300
+
301
+ Estela knows nothing about revenue or contracts, and doesn't estimate them: it
302
+ only links spend to who asked for the work.
303
+
304
+ ### The report for finance
305
+
306
+ ```sh
307
+ estela summary # the last closed month, in Markdown
308
+ estela summary --month 2026-10 --out october.csv # as CSV, for a spreadsheet
309
+ estela summary --client acme --format json
310
+ ```
311
+
312
+ For someone who doesn't code: what was paid for AI, by client and project, what
313
+ was delivered (and the plan, if `estela delivery` read it), what each client asked
314
+ for, and what never got delivered. The headlines talk about money and deliveries;
315
+ tokens, sessions and list prices go at the end, under how it's calculated. A closed
316
+ month adds up to exactly that month's fee, and the clients plus what falls outside
317
+ projects make up the total. It never calls anything off your machine.
318
+
319
+ ### The spend that never got delivered
320
+
321
+ ```sh
322
+ estela ai-cost --undelivered # all history, branches idle 14 days
323
+ estela ai-cost --undelivered --month 2026-10 --stale-days 30 --json
324
+ ```
325
+
326
+ Per project, as a figure and as a percentage of spend, two blocks that never
327
+ overlap:
328
+
329
+ - **Sessions without a commit:** a stretch of one chat with no commit of yours in
330
+ that repo during it or in the half hour after. The ones that did get a commit on
331
+ their branch later are shown apart: iterating before you're happy is work too.
332
+ - **Unmerged branches:** they exist, aren't integrated, and have been idle for N
333
+ days. Only their cost that isn't already in a session without a commit.
334
+
335
+ It's a signal to review, not a verdict. What can't be known isn't counted and is
336
+ shown apart: a branch deleted with no recorded merge may have been squash-merged,
337
+ and before your first recognised commit in a repo you may have been committing
338
+ with another email (check it with `estela author`). A squash also makes a branch
339
+ that still exists look unmerged.
340
+
244
341
  ## Which project is eating your quota
245
342
 
246
343
  On Claude Max/Pro or Codex with a subscription, the quota runs out before you
@@ -73,5 +73,15 @@ export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly Com
73
73
  * nueva puede devolverlo a su carpeta real.
74
74
  */
75
75
  export declare function knownTurns(db: DatabaseSync, scanned: readonly AgentTurn[]): AgentTurn[];
76
- export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[]): AssignmentPlan;
76
+ /**
77
+ * Vuelve a juntar los bloques que `sessionize` partió por un cambio de rama, si
78
+ * caen en un día ya cerrado de su proyecto: aprobado, facturado o ajustado.
79
+ *
80
+ * Partir reparte el mismo rato entre las ramas en que de verdad se trabajó, y
81
+ * el total no cambia. Pero un día cerrado ya tiene su cifra en la rama en que
82
+ * quedó (la última del bloque), y una imputación nueva al lado la contaría dos
83
+ * veces. Así que ahí todo sigue como estaba.
84
+ */
85
+ export declare function rejoinLockedDays(db: DatabaseSync, blocks: readonly WorkBlock[], projectOf: (repo: string | null) => string | null): WorkBlock[];
86
+ export declare function planEntries(db: DatabaseSync, input: readonly WorkBlock[]): AssignmentPlan;
77
87
  //# sourceMappingURL=assign.d.ts.map
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.entryId = entryId;
37
37
  exports.commitOnlyBlocks = commitOnlyBlocks;
38
38
  exports.knownTurns = knownTurns;
39
+ exports.rejoinLockedDays = rejoinLockedDays;
39
40
  exports.planEntries = planEntries;
40
41
  const shared_1 = require("@estela/shared");
41
42
  const store = __importStar(require("../db/store.js"));
@@ -140,6 +141,62 @@ function findDuplicates(db, raw, planned, projectOf) {
140
141
  }
141
142
  return { stale, protectedDuplicates };
142
143
  }
144
+ /**
145
+ * Vuelve a juntar los bloques que `sessionize` partió por un cambio de rama, si
146
+ * caen en un día ya cerrado de su proyecto: aprobado, facturado o ajustado.
147
+ *
148
+ * Partir reparte el mismo rato entre las ramas en que de verdad se trabajó, y
149
+ * el total no cambia. Pero un día cerrado ya tiene su cifra en la rama en que
150
+ * quedó (la última del bloque), y una imputación nueva al lado la contaría dos
151
+ * veces. Así que ahí todo sigue como estaba.
152
+ */
153
+ function rejoinLockedDays(db, blocks, projectOf) {
154
+ if (!blocks.some((b) => b.continuesPrevious))
155
+ return [...blocks];
156
+ const closed = db.prepare(`
157
+ SELECT 1 FROM time_entries WHERE project_id = ? AND local_date = ?
158
+ AND (approved = 1 OR invoice_id IS NOT NULL OR adjust_reason IS NOT NULL) LIMIT 1`);
159
+ const isClosed = (b) => {
160
+ const p = projectOf(b.repoPath);
161
+ return p !== null && closed.get(p, (0, shared_1.localDate)(b.startedAt)) !== undefined;
162
+ };
163
+ // Cadenas: un bloque que continúa al anterior del mismo repo va con él.
164
+ const chains = [];
165
+ const lastChain = new Map();
166
+ for (const b of [...blocks].sort((x, y) => x.startedAt.getTime() - y.startedAt.getTime())) {
167
+ const key = b.repoPath ?? "";
168
+ const previous = lastChain.get(key);
169
+ const tail = previous?.[previous.length - 1];
170
+ if (b.continuesPrevious && previous && tail && tail.endedAt.getTime() === b.startedAt.getTime()) {
171
+ previous.push(b);
172
+ continue;
173
+ }
174
+ const chain = [b];
175
+ chains.push(chain);
176
+ lastChain.set(key, chain);
177
+ }
178
+ return chains.flatMap((chain) => chain.length > 1 && chain.some(isClosed) ? [joinChain(chain)] : chain);
179
+ }
180
+ function joinChain(chain) {
181
+ const first = chain[0];
182
+ const last = chain[chain.length - 1];
183
+ const union = (pick) => [...new Set(chain.flatMap((b) => pick(b) ?? []))];
184
+ return {
185
+ startedAt: first.startedAt,
186
+ endedAt: last.endedAt,
187
+ seconds: Math.round((last.endedAt.getTime() - first.startedAt.getTime()) / 1000),
188
+ repoPath: first.repoPath,
189
+ // La de antes de partir: la última del bloque.
190
+ branch: last.branch ?? first.branch,
191
+ turnCount: chain.reduce((n, b) => n + b.turnCount, 0),
192
+ aiCost: { microUsd: chain.reduce((n, b) => n + b.aiCost.microUsd, 0) },
193
+ models: union((b) => b.models).sort(),
194
+ sessionIds: union((b) => b.sessionIds),
195
+ commits: chain.flatMap((b) => b.commits),
196
+ unpricedModels: union((b) => b.unpricedModels).sort(),
197
+ agents: union((b) => b.agents).sort(),
198
+ };
199
+ }
143
200
  /** Cuánto de [from, to] cae dentro de los bloques, sin contar dos veces un solape. */
144
201
  function coveredMs(blocks, from, to) {
145
202
  const intervals = blocks
@@ -155,7 +212,7 @@ function coveredMs(blocks, from, to) {
155
212
  }
156
213
  return ms;
157
214
  }
158
- function planEntries(db, raw) {
215
+ function planEntries(db, input) {
159
216
  const ownerOf = (0, project_of_js_1.ownerResolver)(db);
160
217
  const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
161
218
  const viaRoot = (repo) => {
@@ -164,6 +221,7 @@ function planEntries(db, raw) {
164
221
  const o = ownerOf(repo);
165
222
  return o.kind === "project" ? o.projectId : null;
166
223
  };
224
+ const raw = rejoinLockedDays(db, input, (repo) => direct(repo) ?? viaRoot(repo));
167
225
  // La carpeta tal cual. Se agrupa por PROYECTO, no por ruta: `repo/` y
168
226
  // `repo/src/UI` son el mismo proyecto, y agrupar por ruta daba dos bloques que
169
227
  // luego se pisaban al guardarse con el mismo id.
@@ -41,6 +41,12 @@ export interface WorkBlock {
41
41
  * bloque construido a mano; los de `sessionize` siempre lo traen.
42
42
  */
43
43
  readonly agents?: readonly AgentKind[];
44
+ /**
45
+ * Empieza donde acaba el bloque anterior del mismo repo, porque la sesión
46
+ * cambió de rama sin pausa. En un día aprobado o facturado se vuelven a
47
+ * juntar (ver `rejoinLockedDays`), para no cambiar lo ya cerrado.
48
+ */
49
+ readonly continuesPrevious?: boolean;
44
50
  }
45
51
  export declare function sessionize(turns: readonly AgentTurn[], options?: SessionizeOptions): WorkBlock[];
46
52
  /**
@@ -30,26 +30,44 @@ function sessionize(turns, options = {}) {
30
30
  for (const bucket of byRepo.values()) {
31
31
  bucket.sort((a, b) => a.at.getTime() - b.at.getTime());
32
32
  let current = [];
33
- const flush = () => {
33
+ let branch = null;
34
+ let continued = false;
35
+ const flush = (until) => {
34
36
  if (current.length)
35
- blocks.push(buildBlock(current, tailMs));
37
+ blocks.push(buildBlock(current, tailMs, until, continued));
36
38
  current = [];
39
+ branch = null;
37
40
  };
38
41
  for (const turn of bucket) {
39
42
  const previous = current[current.length - 1];
40
- if (previous && turn.at.getTime() - previous.at.getTime() > gapMs)
43
+ if (previous && turn.at.getTime() - previous.at.getTime() > gapMs) {
41
44
  flush();
45
+ continued = false;
46
+ }
47
+ else if (previous && namedBranch(turn.branch) && branch && namedBranch(turn.branch) !== branch) {
48
+ // Cambio de rama sin pausa: el bloque se parte en el turno exacto del
49
+ // cambio, sin cola, para que la suma sea el mismo rato de reloj. Antes
50
+ // todo el bloque se iba a la última rama, con su coste.
51
+ flush(turn.at);
52
+ continued = true;
53
+ }
42
54
  current.push(turn);
55
+ branch ??= namedBranch(turn.branch);
43
56
  }
44
57
  flush();
58
+ continued = false;
45
59
  }
46
60
  return blocks.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime());
47
61
  }
48
- function buildBlock(turns, tailMs) {
62
+ /** "HEAD" es un checkout sin rama (HEAD desprendido): no es un cambio de rama. */
63
+ function namedBranch(branch) {
64
+ return branch && branch !== "HEAD" ? branch : null;
65
+ }
66
+ function buildBlock(turns, tailMs, until, continued = false) {
49
67
  const first = turns[0];
50
68
  const last = turns[turns.length - 1];
51
69
  const startedAt = first.at;
52
- const endedAt = new Date(last.at.getTime() + tailMs);
70
+ const endedAt = until ?? new Date(last.at.getTime() + tailMs);
53
71
  let microUsd = 0;
54
72
  const models = new Set();
55
73
  const unpriced = new Set();
@@ -78,6 +96,7 @@ function buildBlock(turns, tailMs) {
78
96
  commits: [],
79
97
  unpricedModels: [...unpriced].sort(),
80
98
  agents: [...agents].sort(),
99
+ ...(continued ? { continuesPrevious: true } : {}),
81
100
  };
82
101
  }
83
102
  /**