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.
- package/README.md +97 -0
- package/dist/billing/assign.d.ts +11 -1
- package/dist/billing/assign.js +59 -1
- package/dist/billing/sessionize.d.ts +6 -0
- package/dist/billing/sessionize.js +24 -5
- package/dist/cli.js +1084 -8
- package/dist/db/schema.js +91 -0
- package/dist/db/store.d.ts +28 -0
- package/dist/db/store.js +120 -1
- package/dist/delivery/clients.d.ts +52 -0
- package/dist/delivery/clients.js +94 -0
- package/dist/delivery/github.d.ts +21 -0
- package/dist/delivery/github.js +123 -0
- package/dist/delivery/report.d.ts +55 -0
- package/dist/delivery/report.js +124 -0
- package/dist/delivery/source.d.ts +68 -0
- package/dist/delivery/source.js +3 -0
- package/dist/export/csv.d.ts +14 -0
- package/dist/export/csv.js +78 -0
- package/dist/export/finance.d.ts +79 -0
- package/dist/export/finance.js +208 -0
- package/dist/i18n/en.js +166 -0
- package/dist/metrics/spend.d.ts +83 -0
- package/dist/metrics/spend.js +167 -0
- package/dist/metrics/undelivered.d.ts +104 -0
- package/dist/metrics/undelivered.js +199 -0
- package/dist/server.js +1 -0
- package/dist/watchers/git.d.ts +40 -0
- package/dist/watchers/git.js +87 -0
- package/node_modules/@estela/shared/dist/money.d.ts +2 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/money.js +7 -7
- package/node_modules/@estela/shared/dist/money.js.map +1 -1
- package/node_modules/@estela/shared/dist/types.d.ts +2 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
- package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
- package/package.json +1 -1
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
|
package/dist/billing/assign.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/billing/assign.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|