estela 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,9 +117,9 @@ Two things trip people up before Estela even starts:
117
117
  administrator terminal. `where.exe npm` should list a single path.
118
118
 
119
119
  A session in a subfolder or a git worktree counts for its repository's
120
- project. If an import would add hours to what you had already imported, Estela
121
- shows you how many per project and asks first (`estela import --yes` skips the
122
- question); it never moves hours between projects or touches an approved day.
120
+ project. Two branches at once in the same project share that time, so the total
121
+ is real time; different projects don't share. An import never moves hours between
122
+ projects or touches an approved day.
123
123
 
124
124
  ## Reporting to a client
125
125
 
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
- import type { CommitRecord, TimeEntry } from "@estela/shared";
2
+ import type { AgentTurn, CommitRecord, TimeEntry } from "@estela/shared";
3
3
  import { type WorkBlock } from "./sessionize.js";
4
4
  /**
5
5
  * De bloques de trabajo a imputaciones: a qué proyecto va cada uno y con qué
@@ -13,7 +13,11 @@ import { type WorkBlock } from "./sessionize.js";
13
13
  * mira primero, así que nada de lo ya asignado cambia de proyecto.
14
14
  * 2. Un día aprobado o facturado de ese proyecto no se toca: las horas que se
15
15
  * recuperarían ahí se informan (`blocked`), no se aplican.
16
- * 3. Recuperar es opcional (`recover`): la primera vez lo confirma la persona.
16
+ *
17
+ * Y dos ramas a la vez en el mismo proyecto no suman dos horas por cada hora de
18
+ * reloj: lo que registra Estela es tiempo real, y ese rato se reparte entre
19
+ * ellas (ver `sharedSeconds`). Cobrar un precio pactado por encima de lo medido
20
+ * es un ajuste a mano con su motivo, que conserva lo medido al lado.
17
21
  */
18
22
  export interface PlannedEntry {
19
23
  readonly entry: Omit<TimeEntry, "id"> & {
@@ -37,6 +41,10 @@ export interface AssignmentPlan {
37
41
  readonly recovered: ReadonlyMap<string, number>;
38
42
  /** Lo que se recuperaría en días aprobados o facturados: no se aplica. */
39
43
  readonly blocked: readonly RecoveryBlocked[];
44
+ /** Imputaciones sin horas propias y sin revisar: se retiran. */
45
+ readonly stale: readonly string[];
46
+ /** Las mismas, pero aprobadas, ajustadas o facturadas: no se tocan, se avisa. */
47
+ readonly protectedDuplicates: readonly string[];
40
48
  }
41
49
  export declare function entryId(projectId: string, day: string, branch: string | null): string;
42
50
  /**
@@ -47,9 +55,23 @@ export declare function entryId(projectId: string, day: string, branch: string |
47
55
  * lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
48
56
  * trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
49
57
  * guarda todos los turnos: `saveTurns` no borra nada.
58
+ *
59
+ * `git log --all` desde un worktree trae la historia entera del repo, así que
60
+ * cada commit queda guardado también bajo la ruta del worktree. Por eso se
61
+ * compara por raíz git: sin ella, esa copia no la tapa ninguna sesión del repo
62
+ * y, al recuperar, sumaba horas estimadas a días ya medidos.
50
63
  */
51
64
  export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly CommitRecord[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
52
- export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[], options: {
53
- readonly recover: boolean;
54
- }): AssignmentPlan;
65
+ /**
66
+ * Todos los turnos conocidos: los de la base más los recién leídos.
67
+ *
68
+ * Una imputación se reescribe entera, así que hay que calcularla con todo lo
69
+ * que hubo ese día. Con solo lo leído del disco, un transcript que Claude Code
70
+ * ya limpió (o un `--since`) dejaba 4 h medidas convertidas en los 25 min del
71
+ * único commit que quedaba. Lo recién leído manda porque el lector sabe cosas
72
+ * que la base no: `saveTurns` no reescribe un turno ya guardado, y una versión
73
+ * nueva puede devolverlo a su carpeta real.
74
+ */
75
+ export declare function knownTurns(db: DatabaseSync, scanned: readonly AgentTurn[]): AgentTurn[];
76
+ export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[]): AssignmentPlan;
55
77
  //# sourceMappingURL=assign.d.ts.map
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.entryId = entryId;
37
37
  exports.commitOnlyBlocks = commitOnlyBlocks;
38
+ exports.knownTurns = knownTurns;
38
39
  exports.planEntries = planEntries;
39
40
  const shared_1 = require("@estela/shared");
40
41
  const store = __importStar(require("../db/store.js"));
@@ -43,8 +44,10 @@ const sessionize_js_1 = require("./sessionize.js");
43
44
  function entryId(projectId, day, branch) {
44
45
  // Determinista (proyecto + día + rama) para que reimportar actualice en vez
45
46
  // de duplicar. La fecha, la LOCAL: la misma que usa el servidor.
46
- const slug = (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
47
- return `te_${projectId}_${day}_${slug}`;
47
+ return `te_${projectId}_${day}_${branchSlug(branch)}`;
48
+ }
49
+ function branchSlug(branch) {
50
+ return (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
48
51
  }
49
52
  /**
50
53
  * Los bloques deducidos solo de commits: los que no se solapan con ninguna
@@ -54,12 +57,105 @@ function entryId(projectId, day, branch) {
54
57
  * lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
55
58
  * trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
56
59
  * guarda todos los turnos: `saveTurns` no borra nada.
60
+ *
61
+ * `git log --all` desde un worktree trae la historia entera del repo, así que
62
+ * cada commit queda guardado también bajo la ruta del worktree. Por eso se
63
+ * compara por raíz git: sin ella, esa copia no la tapa ninguna sesión del repo
64
+ * y, al recuperar, sumaba horas estimadas a días ya medidos.
57
65
  */
58
66
  function commitOnlyBlocks(db, commits, agentBlocks) {
59
67
  const saved = (0, sessionize_js_1.sessionize)(store.turnsBetween(db, new Date(0), new Date("9999-12-31")));
60
- return (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), [...agentBlocks, ...saved]);
68
+ const roots = new Map();
69
+ const rootOf = (path) => {
70
+ let root = roots.get(path);
71
+ if (root === undefined) {
72
+ root = (0, project_of_js_1.gitRoot)(path) ?? path;
73
+ roots.set(path, root);
74
+ }
75
+ return root;
76
+ };
77
+ return (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), [...agentBlocks, ...saved], rootOf);
78
+ }
79
+ /**
80
+ * Todos los turnos conocidos: los de la base más los recién leídos.
81
+ *
82
+ * Una imputación se reescribe entera, así que hay que calcularla con todo lo
83
+ * que hubo ese día. Con solo lo leído del disco, un transcript que Claude Code
84
+ * ya limpió (o un `--since`) dejaba 4 h medidas convertidas en los 25 min del
85
+ * único commit que quedaba. Lo recién leído manda porque el lector sabe cosas
86
+ * que la base no: `saveTurns` no reescribe un turno ya guardado, y una versión
87
+ * nueva puede devolverlo a su carpeta real.
88
+ */
89
+ function knownTurns(db, scanned) {
90
+ const byId = new Map(store.turnsBetween(db, new Date(0), new Date("9999-12-31")).map((t) => [t.turnId, t]));
91
+ for (const turn of scanned)
92
+ byId.set(turn.turnId, turn);
93
+ return [...byId.values()];
61
94
  }
62
- function planEntries(db, raw, options) {
95
+ /**
96
+ * Imputaciones guardadas que ya no salen en el plan y cuyas horas están dentro
97
+ * de otras del mismo proyecto.
98
+ *
99
+ * El id lleva la rama, y la rama de un bloque es la de su último turno: si la
100
+ * sesión sigue en otra rama, la siguiente importación crea otra imputación con
101
+ * el bloque entero y la anterior se quedaba, cobrando dos veces el mismo rato.
102
+ *
103
+ * Retirar horas es peor que no hacerlo si se acierta mal, así que se exige todo:
104
+ *
105
+ * - Ningún bloque de ese día lleva esa rama, tenga proyecto o no. Si lo lleva,
106
+ * esas horas siguen existiendo, como las de un worktree ya borrado.
107
+ * - Los bloques del proyecto cubren lo que dice la imputación, en su horario.
108
+ * Si no, son horas de las que no queda otro rastro.
109
+ *
110
+ * Lo aprobado, ajustado o facturado no se retira nunca: solo se avisa.
111
+ */
112
+ function findDuplicates(db, raw, planned, projectOf) {
113
+ const rows = db.prepare(`SELECT id, project_id, local_date, branch, started_at, ended_at, seconds, approved, invoice_id, adjust_reason
114
+ FROM time_entries WHERE source IN ('agent', 'commit')`).all();
115
+ const stale = [];
116
+ const protectedDuplicates = [];
117
+ const blocksByDay = new Map();
118
+ for (const block of raw) {
119
+ const day = (0, shared_1.localDate)(block.startedAt);
120
+ const bucket = blocksByDay.get(day);
121
+ if (bucket)
122
+ bucket.push(block);
123
+ else
124
+ blocksByDay.set(day, [block]);
125
+ }
126
+ for (const row of rows) {
127
+ if (planned.has(row["id"]))
128
+ continue;
129
+ const day = blocksByDay.get(row["local_date"]) ?? [];
130
+ const slug = branchSlug(row["branch"] ?? null);
131
+ if (day.some((b) => branchSlug(b.branch) === slug))
132
+ continue;
133
+ const from = new Date(row["started_at"]).getTime();
134
+ const to = new Date(row["ended_at"]).getTime();
135
+ const covered = coveredMs(day.filter((b) => projectOf(b.repoPath) === row["project_id"]), from, to) / 1000;
136
+ if (covered < row["seconds"] - 60)
137
+ continue;
138
+ const locked = row["approved"] === 1 || row["invoice_id"] !== null || row["adjust_reason"] !== null;
139
+ (locked ? protectedDuplicates : stale).push(row["id"]);
140
+ }
141
+ return { stale, protectedDuplicates };
142
+ }
143
+ /** Cuánto de [from, to] cae dentro de los bloques, sin contar dos veces un solape. */
144
+ function coveredMs(blocks, from, to) {
145
+ const intervals = blocks
146
+ .map((b) => [Math.max(b.startedAt.getTime(), from), Math.min(b.endedAt.getTime(), to)])
147
+ .sort((a, b) => a[0] - b[0]);
148
+ let ms = 0;
149
+ let until = from;
150
+ for (const [start, end] of intervals) {
151
+ const desde = Math.max(start, until);
152
+ if (end > desde)
153
+ ms += end - desde;
154
+ until = Math.max(until, end);
155
+ }
156
+ return ms;
157
+ }
158
+ function planEntries(db, raw) {
63
159
  const ownerOf = (0, project_of_js_1.ownerResolver)(db);
64
160
  const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
65
161
  const viaRoot = (repo) => {
@@ -68,20 +164,11 @@ function planEntries(db, raw, options) {
68
164
  const o = ownerOf(repo);
69
165
  return o.kind === "project" ? o.projectId : null;
70
166
  };
71
- // Lo de siempre: la carpeta tal cual. Se agrupa por PROYECTO, no por ruta:
72
- // `repo/` y `repo/src/UI` son el mismo proyecto, y agrupar por ruta daba dos
73
- // bloques que luego se pisaban al guardarse con el mismo id.
167
+ // La carpeta tal cual. Se agrupa por PROYECTO, no por ruta: `repo/` y
168
+ // `repo/src/UI` son el mismo proyecto, y agrupar por ruta daba dos bloques que
169
+ // luego se pisaban al guardarse con el mismo id.
74
170
  const keyDirect = (b) => direct(b.repoPath) ?? b.repoPath ?? "";
75
- const groupedBase = (0, sessionize_js_1.groupByBranchAndDay)(raw, keyDirect);
76
- const base = byId(groupedBase, direct);
77
- if (!options.recover) {
78
- return {
79
- entries: [...base.values()].map((g) => ({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 })),
80
- grouped: groupedBase.length,
81
- unassigned: groupedBase.filter((b) => !direct(b.repoPath)).length,
82
- recovered: new Map(), blocked: [],
83
- };
84
- }
171
+ const base = byId((0, sessionize_js_1.groupByBranchAndDay)(raw, keyDirect), direct);
85
172
  // Con la raíz git, pero solo para lo que no tenía proyecto (regla 1).
86
173
  const assigned = (repo) => direct(repo) ?? viaRoot(repo);
87
174
  const keyFull = (b) => assigned(b.repoPath) ?? b.repoPath ?? "";
@@ -105,35 +192,126 @@ function planEntries(db, raw, options) {
105
192
  }
106
193
  return lockedDays.get(k);
107
194
  };
108
- const entries = [];
109
- const recovered = new Map();
195
+ // Primero qué filas quedan y con qué bloques, y después el reparto: una sesión
196
+ // que la regla 2 deja fuera no puede restarle tiempo a la que sí cuenta.
197
+ const rows = [];
110
198
  const blocked = [];
199
+ const rawByProject = (projectOf) => {
200
+ const out = new Map();
201
+ for (const b of raw) {
202
+ const p = projectOf(b.repoPath);
203
+ if (!p)
204
+ continue;
205
+ const id = entryId(p, (0, shared_1.localDate)(b.startedAt), b.branch);
206
+ const bucket = out.get(id);
207
+ if (bucket)
208
+ bucket.push(b);
209
+ else
210
+ out.set(id, [b]);
211
+ }
212
+ return out;
213
+ };
214
+ const fullBlocks = rawByProject(assigned);
215
+ const directBlocks = rawByProject(direct);
111
216
  for (const [id, g] of full) {
112
217
  const before = base.get(id);
113
- if (!touched.has(id)) {
114
- entries.push({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 });
115
- continue;
116
- }
117
- const gain = Math.max(0, g.block.seconds - (before?.block.seconds ?? 0));
118
218
  const day = (0, shared_1.localDate)(g.block.startedAt);
119
- if (locked(g.projectId, day)) {
219
+ if (touched.has(id) && locked(g.projectId, day)) {
120
220
  // Regla 2: el día se queda como estaba; si antes no había nada, nada.
121
221
  if (before)
122
- entries.push({ entry: toEntry(db, before.projectId, before.block), recoveredSeconds: 0 });
222
+ rows.push({ id, projectId: before.projectId, block: before.block, blocks: directBlocks.get(id) ?? [], before: null });
223
+ const gain = Math.max(0, g.block.seconds - (before?.block.seconds ?? 0));
123
224
  if (gain > 0)
124
225
  blocked.push({ projectId: g.projectId, day, seconds: gain });
125
226
  continue;
126
227
  }
127
- entries.push({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: gain });
228
+ rows.push({ id, projectId: g.projectId, block: g.block, blocks: fullBlocks.get(id) ?? [],
229
+ before: touched.has(id) ? (before?.block.seconds ?? 0) : null });
230
+ }
231
+ const shares = sharedSeconds(rows, lockedEntryIds(db));
232
+ const entries = [];
233
+ const recovered = new Map();
234
+ const empty = [];
235
+ for (const row of rows) {
236
+ const seconds = shares.get(row.id) ?? row.block.seconds;
237
+ if (seconds <= 0) {
238
+ empty.push(row.id);
239
+ continue;
240
+ }
241
+ const gain = row.before === null ? 0 : Math.max(0, seconds - row.before);
242
+ entries.push({ entry: toEntry(db, row.projectId, { ...row.block, seconds }), recoveredSeconds: gain });
128
243
  if (gain > 0)
129
- recovered.set(g.projectId, (recovered.get(g.projectId) ?? 0) + gain);
244
+ recovered.set(row.projectId, (recovered.get(row.projectId) ?? 0) + gain);
130
245
  }
246
+ const duplicates = findDuplicates(db, raw, new Set(entries.map((e) => e.entry.id)), assigned);
131
247
  return {
132
248
  entries, grouped: groupedFull.length,
133
249
  unassigned: groupedFull.filter((b) => !assigned(b.repoPath)).length,
134
250
  recovered, blocked,
251
+ stale: [...duplicates.stale, ...empty],
252
+ protectedDuplicates: duplicates.protectedDuplicates,
135
253
  };
136
254
  }
255
+ /** Las imputaciones cuya cifra ya no puede cambiar. */
256
+ function lockedEntryIds(db) {
257
+ const rows = db.prepare("SELECT id FROM time_entries WHERE approved = 1 OR invoice_id IS NOT NULL").all();
258
+ return new Set(rows.map((r) => r.id));
259
+ }
260
+ /**
261
+ * Los segundos de cada imputación cuando varias ramas del mismo proyecto
262
+ * coinciden en el reloj: el rato compartido se reparte a partes iguales, y el
263
+ * total del proyecto es el tiempo real.
264
+ *
265
+ * Dos chats en dos worktrees durante una hora son una hora de quien trabaja, no
266
+ * dos. Proyectos distintos no se reparten entre sí: esa decisión es por cliente.
267
+ *
268
+ * Lo aprobado o facturado ya tiene su cifra y no cambia, así que el rato que
269
+ * cubre ya está cobrado o dado por bueno: las demás no reciben nada de él.
270
+ */
271
+ function sharedSeconds(rows, lockedIds) {
272
+ const byProject = new Map();
273
+ for (const row of rows) {
274
+ const bucket = byProject.get(row.projectId);
275
+ if (bucket)
276
+ bucket.push(row);
277
+ else
278
+ byProject.set(row.projectId, [row]);
279
+ }
280
+ const ms = new Map();
281
+ for (const projectRows of byProject.values()) {
282
+ const events = [];
283
+ for (const row of projectRows) {
284
+ for (const b of row.blocks) {
285
+ events.push({ at: b.startedAt.getTime(), id: row.id, delta: 1 });
286
+ events.push({ at: b.endedAt.getTime(), id: row.id, delta: -1 });
287
+ }
288
+ }
289
+ events.sort((a, b) => a.at - b.at);
290
+ const active = new Map();
291
+ let previous = events[0]?.at ?? 0;
292
+ for (const event of events) {
293
+ if (event.at > previous && active.size > 0) {
294
+ const span = event.at - previous;
295
+ const ids = [...active.keys()];
296
+ const lockedHere = ids.filter((id) => lockedIds.has(id));
297
+ const payees = lockedHere.length ? lockedHere : ids;
298
+ const share = lockedHere.length ? span : span / ids.length;
299
+ for (const id of payees)
300
+ ms.set(id, (ms.get(id) ?? 0) + share);
301
+ }
302
+ previous = event.at;
303
+ const count = (active.get(event.id) ?? 0) + event.delta;
304
+ if (count > 0)
305
+ active.set(event.id, count);
306
+ else
307
+ active.delete(event.id);
308
+ }
309
+ }
310
+ const seconds = new Map();
311
+ for (const row of rows)
312
+ seconds.set(row.id, Math.round((ms.get(row.id) ?? 0) / 1000));
313
+ return seconds;
314
+ }
137
315
  /** Los bloques agrupados con proyecto, por el id con el que se guardarían. */
138
316
  function byId(blocks, projectOf) {
139
317
  const out = new Map();
@@ -118,6 +118,9 @@ export declare function sessionizeCommits(commits: readonly CommitRecord[], opti
118
118
  * del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
119
119
  * y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
120
120
  * se sumaban 25 minutos estimados encima de una sesión medida.
121
+ *
122
+ * Un worktree vive fuera del repositorio y las rutas no lo dicen: `rootOf` da
123
+ * la raíz de cada carpeta, y es esa la que se compara.
121
124
  */
122
- export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
125
+ export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[], rootOf?: (path: string) => string): WorkBlock[];
123
126
  //# sourceMappingURL=sessionize.d.ts.map
@@ -118,7 +118,6 @@ function groupByBranchAndDay(blocks, ambito = (b) => b.repoPath ?? "") {
118
118
  for (const bucket of groups.values()) {
119
119
  bucket.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime());
120
120
  const first = bucket[0];
121
- const last = bucket[bucket.length - 1];
122
121
  // Ni el lapso de principio a fin —entre bloques hubo huecos en los que no
123
122
  // estabas trabajando, y facturarlos sería cobrar de más— ni la suma de
124
123
  // duraciones: los segundos de un bloque SON su intervalo de reloj, y dos
@@ -154,7 +153,9 @@ function groupByBranchAndDay(blocks, ambito = (b) => b.repoPath ?? "") {
154
153
  }
155
154
  merged.push({
156
155
  startedAt: first.startedAt,
157
- endedAt: last.endedAt,
156
+ // El último en empezar no es siempre el último en acabar: una sesión en
157
+ // una subcarpeta puede caer entera dentro de la del repo.
158
+ endedAt: new Date(Math.max(...bucket.map((b) => b.endedAt.getTime()))),
158
159
  seconds,
159
160
  repoPath: first.repoPath,
160
161
  branch: first.branch,
@@ -295,11 +296,15 @@ function sessionizeCommits(commits, options = {}) {
295
296
  * del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
296
297
  * y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
297
298
  * se sumaban 25 minutos estimados encima de una sesión medida.
299
+ *
300
+ * Un worktree vive fuera del repositorio y las rutas no lo dicen: `rootOf` da
301
+ * la raíz de cada carpeta, y es esa la que se compara.
298
302
  */
299
- function withoutOverlap(commitBlocks, agentBlocks) {
300
- return commitBlocks.filter((c) => !agentBlocks.some((a) => sameRepo(a.repoPath, c.repoPath) &&
301
- a.startedAt.getTime() < c.endedAt.getTime() &&
302
- c.startedAt.getTime() < a.endedAt.getTime()));
303
+ function withoutOverlap(commitBlocks, agentBlocks, rootOf = (path) => path) {
304
+ const root = (path) => (path ? rootOf(path) : null);
305
+ return commitBlocks.filter((c) => !agentBlocks.some((a) => a.startedAt.getTime() < c.endedAt.getTime() &&
306
+ c.startedAt.getTime() < a.endedAt.getTime() &&
307
+ sameRepo(root(a.repoPath), root(c.repoPath))));
303
308
  }
304
309
  function sameRepo(a, b) {
305
310
  if (a === b)
package/dist/cli.js CHANGED
@@ -96,12 +96,12 @@ estela — registro de horas para desarrollo asistido por IA
96
96
  Primera ejecución: detecta tus agentes y repositorios, y reconstruye
97
97
  tu historial. Dos minutos, sin preguntas y sin cuenta.
98
98
 
99
- estela import [--since YYYY-MM-DD] [--repo <ruta>] [--yes]
99
+ estela import [--since YYYY-MM-DD] [--repo <ruta>]
100
100
  Reconstruye tu historial desde los transcripts de los agentes y git.
101
101
  Una sesión en una subcarpeta o en un worktree cuenta para el
102
- proyecto de su repo. La primera vez que eso suma horas a lo ya
103
- importado, te enseña cuántas y pregunta (--yes no pregunta). Nunca
104
- mueve horas de un proyecto a otro ni toca un día aprobado.
102
+ proyecto de su repo. Dos ramas a la vez en un proyecto se reparten
103
+ ese rato: el total es tiempo real. Nunca mueve horas de un proyecto
104
+ a otro ni toca un día aprobado.
105
105
 
106
106
  estela client add --id <id> --name <nombre> --currency <EUR|USD|...>
107
107
  [--tax-id <NIF/CIF>] [--email <a@b.com>]
@@ -307,12 +307,12 @@ estela — time tracking for AI-assisted development
307
307
  First run: detects your agents and repositories, and rebuilds your
308
308
  history. Two minutes, no questions, no account.
309
309
 
310
- estela import [--since YYYY-MM-DD] [--repo <path>] [--yes]
310
+ estela import [--since YYYY-MM-DD] [--repo <path>]
311
311
  Rebuilds your history from agent transcripts and git. A session in a
312
- subfolder or a worktree counts for its repo's project. The first
313
- time that adds hours to what was already imported, it shows how many
314
- and asks (--yes skips the question). It never moves hours from one
315
- project to another or touches an approved day.
312
+ subfolder or a worktree counts for its repo's project. Two branches
313
+ at once in a project share that time: the total is real time. It
314
+ never moves hours from one project to another or touches an approved
315
+ day.
316
316
 
317
317
  estela client add --id <id> --name <name> --currency <EUR|USD|...>
318
318
  [--tax-id <tax ID>] [--email <a@b.com>]
@@ -780,18 +780,6 @@ async function cmdSetup(args, dbPath) {
780
780
  db.close();
781
781
  }
782
782
  }
783
- /** La vista previa de la primera recuperación: horas por proyecto. */
784
- function printRecoveryPreview(db, plan) {
785
- console.log((0, index_js_1.tr) `\nEstela ahora reconoce las sesiones en subcarpetas y worktrees de un repositorio.`);
786
- console.log((0, index_js_1.tr) `Estas horas no estaban imputadas y se sumarían a su proyecto:\n`);
787
- for (const [projectId, seconds] of [...plan.recovered].sort((a, b) => b[1] - a[1])) {
788
- const name = store.getProject(db, projectId)?.name ?? projectId;
789
- console.log(` ${name.padEnd(30)} +${(0, shared_1.formatDuration)(seconds)}`);
790
- }
791
- console.log((0, index_js_1.tr) `\n Nada de lo ya imputado cambia de proyecto, y los días aprobados no se tocan.`);
792
- printRecoveryBlocked(db, plan.blocked);
793
- console.log("");
794
- }
795
783
  /** Lo que se recuperaría en días aprobados o facturados: se avisa, no se aplica. */
796
784
  function printRecoveryBlocked(db, blocked) {
797
785
  if (blocked.length === 0)
@@ -890,7 +878,12 @@ async function cmdImport(args, dbPath) {
890
878
  continue;
891
879
  savedCommits += store.saveCommits(db, await (0, git_js_1.readCommits)(root, since ? { since } : {}));
892
880
  }
893
- const mine = await (0, identity_js_1.myEmailsByRepo)(db, repos);
881
+ // Todo lo conocido, no solo lo leído ahora (ver knownTurns). Con --repo, solo
882
+ // ese repo, que es lo que se ha pedido.
883
+ const known = (0, assign_js_1.knownTurns)(db, turns).filter((t) => !repoPaths || (t.repoPath !== null && repoPaths.some((p) => (0, paths_js_1.isSameOrInside)(t.repoPath, p))));
884
+ // Los correos también de los repos sin sesión reciente: sin ellos, un día
885
+ // antiguo se recalcularía sin sus commits y perdería la descripción.
886
+ const mine = await (0, identity_js_1.myEmailsByRepo)(db, new Set([...repos, ...known.flatMap((t) => t.repoPath ?? [])]));
894
887
  if (rescued > 0) {
895
888
  console.log((0, index_js_1.tr) ` ${rescued} turnos de scratchpad devueltos a su repositorio`);
896
889
  }
@@ -905,57 +898,42 @@ async function cmdImport(args, dbPath) {
905
898
  // Solo TUS commits derivan horas tuyas. Sin este filtro, los commits de un
906
899
  // compañero se convertirían en tiempo facturable a tu nombre.
907
900
  const commitsAll = (0, identity_js_1.onlyMine)(await allCommits(db), mine);
908
- const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commitsAll);
901
+ const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commitsAll);
909
902
  const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commitsAll, fromAgents);
910
903
  const raw = [...fromAgents, ...fromCommits];
911
904
  if (fromCommits.length) {
912
905
  console.log((0, index_js_1.tr) ` ${fromCommits.length} bloques deducidos solo de commits (sin agente)`);
913
906
  }
914
- // Horas de sesiones en subcarpetas y worktrees, que antes se quedaban sin
915
- // proyecto (ver billing/assign.ts). La primera vez que cambian horas ya
916
- // importadas se enseñan antes y se pide confirmación; en una base nueva no
917
- // hay nada que cambiar, así que se aplica sin preguntar.
918
- const marca = (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), ".recover-git-root");
919
- const habiaHoras = db.prepare("SELECT COUNT(*) AS n FROM time_entries").get().n > 0;
920
- let plan = (0, assign_js_1.planEntries)(db, raw, { recover: true });
921
- const conRaiz = plan;
922
- const recuperable = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
923
- if (!(0, node_fs_1.existsSync)(marca) && habiaHoras && recuperable > 0) {
924
- printRecoveryPreview(db, plan);
925
- const ok = args.flags["yes"] === true || await confirm((0, index_js_1.tr) `¿Imputar estas horas? [s/N] `);
926
- if (!ok) {
927
- plan = (0, assign_js_1.planEntries)(db, raw, { recover: false });
928
- console.log((0, index_js_1.tr) ` No se ha tocado nada. Cuando quieras: estela import --yes\n`);
929
- }
930
- else {
931
- (0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
932
- }
933
- }
934
- else {
935
- try {
936
- (0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
937
- }
938
- catch { /* sin marca, se volverá a preguntar */ }
939
- }
907
+ // Sesiones en subcarpetas y worktrees incluidas: ver billing/assign.ts.
908
+ const plan = (0, assign_js_1.planEntries)(db, raw);
940
909
  for (const { entry } of plan.entries)
941
910
  store.saveTimeEntry(db, entry);
911
+ // Con --repo el plan no ve el resto de proyectos: nada de retirar.
912
+ const retired = repoPaths ? 0 : store.deleteStaleEntries(db, plan.stale);
942
913
  const entries = plan.entries.length;
943
914
  const unassigned = plan.unassigned;
944
915
  console.log((0, index_js_1.tr) `Bloques: ${raw.length} detectados → ${plan.grouped} tras agrupar por rama y día · ` +
945
916
  (0, index_js_1.tr) `${entries} imputados · ${unassigned} sin proyecto`);
946
917
  const aplicadas = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
947
- if (aplicadas > 0 && (0, node_fs_1.existsSync)(marca)) {
918
+ if (aplicadas > 0) {
948
919
  console.log((0, index_js_1.tr) ` ${(0, shared_1.formatDuration)(aplicadas)} recuperadas de sesiones en subcarpetas y worktrees`);
949
920
  }
950
921
  printRecoveryBlocked(db, plan.blocked);
951
- // Si no se confirmó, parte de "sin proyecto" son sesiones de subcarpetas y
952
- // worktrees: a esas no les falta registrarse, les falta el --yes.
953
- const porRaiz = unassigned - conRaiz.unassigned;
954
- if (porRaiz > 0) {
955
- console.log((0, index_js_1.tr) `\n${porRaiz} de esos bloques son de subcarpetas o worktrees de un proyecto: estela import --yes los imputa.`);
956
- }
957
- if (unassigned - porRaiz > 0) {
958
- console.log((0, index_js_1.tr) `\nHay ${unassigned - porRaiz} bloques sin proyecto asignado. Regístralos con:`);
922
+ if (retired > 0) {
923
+ console.log((0, index_js_1.tr) ` ${retired} imputaciones retiradas: sus horas ya estaban en otra rama del mismo día`);
924
+ }
925
+ // Estas no se tocan, pero conviene saberlo antes de facturar lo que las repite.
926
+ const pendientes = plan.protectedDuplicates.filter((id) => {
927
+ const row = db.prepare("SELECT invoice_id FROM time_entries WHERE id = ?").get(id);
928
+ return row?.invoice_id === null;
929
+ });
930
+ if (pendientes.length) {
931
+ console.log((0, index_js_1.tr) `\n${pendientes.length} imputaciones aprobadas o ajustadas repiten horas de otra rama. Revísalas antes de facturar:`);
932
+ for (const id of pendientes.slice(0, 10))
933
+ console.log(` ${id}`);
934
+ }
935
+ if (unassigned > 0) {
936
+ console.log((0, index_js_1.tr) `\nHay ${unassigned} bloques sin proyecto asignado. Regístralos con:`);
959
937
  console.log((0, index_js_1.tr) ` estela project add --id <id> --client <id> --name <nombre> --repo <ruta>`);
960
938
  }
961
939
  }
@@ -2476,7 +2454,9 @@ function cmdInvoice(args, dbPath) {
2476
2454
  const cutoffAt = new Date(`${cutoffRaw}T23:59:59Z`);
2477
2455
  if (Number.isNaN(cutoffAt.getTime()))
2478
2456
  throw new UserError((0, index_js_1.tr) `Fecha inválida: ${cutoffRaw}`);
2479
- const dryRun = args.flags["dry-run"] === true;
2457
+ // Si va seguida de otra palabra, parseArgs la toma como su valor. Una opción
2458
+ // que protege no puede dejar de hacerlo por eso: se emitía el informe de verdad.
2459
+ const dryRun = "dry-run" in args.flags;
2480
2460
  const number = str(args, "number") ?? store.nextInvoiceNumber(db, "INF");
2481
2461
  const fx = str(args, "fx");
2482
2462
  const entries = store.getTimeEntries(db, projectId);
@@ -2531,7 +2511,7 @@ function cmdInvoice(args, dbPath) {
2531
2511
  },
2532
2512
  } : {}),
2533
2513
  ...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
2534
- withAmounts: args.flags["no-amounts"] !== true,
2514
+ withAmounts: !("no-amounts" in args.flags),
2535
2515
  }));
2536
2516
  (0, node_fs_1.writeFileSync)(pdfPath, pdf);
2537
2517
  console.log((0, index_js_1.tr) `\nPDF: ${pdfPath} (${(pdf.length / 1024).toFixed(1)} KB, ${invoice.lines.length} conceptos)`);
@@ -2577,6 +2557,12 @@ async function main() {
2577
2557
  console.log(VERSION);
2578
2558
  return;
2579
2559
  }
2560
+ // Igual de pronto: sin esto, "estela import --help" ignoraba la opción y
2561
+ // ejecutaba el comando de verdad. Pedir ayuda no puede tocar los datos.
2562
+ if ("help" in args.flags || argv.includes("-h")) {
2563
+ console.log(helpText());
2564
+ return;
2565
+ }
2580
2566
  (0, client_js_1.setClientVersion)(VERSION);
2581
2567
  const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
2582
2568
  // `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
@@ -135,6 +135,12 @@ export declare class AdjustError extends Error {
135
135
  export declare function adjustEntrySeconds(db: DatabaseSync, id: string, seconds: number, reason: string, at?: Date): void;
136
136
  /** Quita el ajuste y devuelve el bloque a lo que mide el agente. */
137
137
  export declare function clearEntryAdjustment(db: DatabaseSync, id: string, at?: Date): void;
138
+ /**
139
+ * Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
140
+ * Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
141
+ * lo facturado y lo ajustado no se borra desde ningún camino.
142
+ */
143
+ export declare function deleteStaleEntries(db: DatabaseSync, ids: readonly string[]): number;
138
144
  export declare function saveTimeEntry(db: DatabaseSync, entry: Omit<TimeEntry, "id"> & {
139
145
  id?: string;
140
146
  }, at?: Date): string;
package/dist/db/store.js CHANGED
@@ -30,6 +30,7 @@ exports.saveCommits = saveCommits;
30
30
  exports.logScan = logScan;
31
31
  exports.adjustEntrySeconds = adjustEntrySeconds;
32
32
  exports.clearEntryAdjustment = clearEntryAdjustment;
33
+ exports.deleteStaleEntries = deleteStaleEntries;
33
34
  exports.saveTimeEntry = saveTimeEntry;
34
35
  exports.setEntryWorkItems = setEntryWorkItems;
35
36
  exports.getTimeEntries = getTimeEntries;
@@ -509,6 +510,21 @@ function clearEntryAdjustment(db, id, at = new Date()) {
509
510
  WHERE id = ?
510
511
  `).run(iso(at), id);
511
512
  }
513
+ /**
514
+ * Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
515
+ * Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
516
+ * lo facturado y lo ajustado no se borra desde ningún camino.
517
+ */
518
+ function deleteStaleEntries(db, ids) {
519
+ const stmt = db.prepare(`
520
+ DELETE FROM time_entries WHERE id = ?
521
+ AND source IN ('agent', 'commit') AND invoice_id IS NULL
522
+ AND approved = 0 AND adjust_reason IS NULL`);
523
+ let deleted = 0;
524
+ for (const id of ids)
525
+ deleted += Number(stmt.run(id).changes);
526
+ return deleted;
527
+ }
512
528
  function saveTimeEntry(db, entry, at = new Date()) {
513
529
  const id = entry.id ?? `te_${(0, node_crypto_1.randomUUID)()}`;
514
530
  db.prepare(`
package/dist/i18n/en.js CHANGED
@@ -540,16 +540,12 @@ exports.EN = {
540
540
  "{0} turnos de modelos sin precio de lista cuentan como 0 en ese %.": "{0} turns from models without a list price count as 0 in that %.",
541
541
  "La ventana de 5 h es la que da Claude Code; el reparto solo incluye lo usado en esta máquina.": "The 5-hour window is the one Claude Code reports; the split only includes what was used on this machine.",
542
542
  // ── import: horas de subcarpetas y worktrees ──
543
- "\nEstela ahora reconoce las sesiones en subcarpetas y worktrees de un repositorio.": "\nEstela now recognizes sessions in a repository's subfolders and worktrees.",
544
- "Estas horas no estaban imputadas y se sumarían a su proyecto:\n": "These hours weren't assigned and would be added to their project:\n",
545
- "\n Nada de lo ya imputado cambia de proyecto, y los días aprobados no se tocan.": "\n Nothing already assigned changes project, and approved days aren't touched.",
546
543
  "\n Sin tocar, porque ese día ya está aprobado o facturado:": "\n Left alone, because that day is already approved or invoiced:",
547
544
  " {0} · {1}: {2} recuperables. Revísalo con: estela entries --project {3}": " {0} · {1}: {2} recoverable. Review it with: estela entries --project {3}",
548
- "¿Imputar estas horas? [s/N] ": "Assign these hours? [y/N] ",
549
- " No se ha tocado nada. Cuando quieras: estela import --yes\n": " Nothing was changed. Whenever you want: estela import --yes\n",
545
+ " {0} imputaciones retiradas: sus horas ya estaban en otra rama del mismo día": " {0} entries removed: their hours were already in another branch that day",
546
+ "\n{0} imputaciones aprobadas o ajustadas repiten horas de otra rama. Revísalas antes de facturar:": "\n{0} approved or adjusted entries repeat hours from another branch. Review them before invoicing:",
550
547
  " {0} recuperadas de sesiones en subcarpetas y worktrees": " {0} recovered from sessions in subfolders and worktrees",
551
548
  "{0} (sin proyecto)": "{0} (no project)",
552
- "\n{0} de esos bloques son de subcarpetas o worktrees de un proyecto: estela import --yes los imputa.": "\n{0} of those blocks are from a project's subfolders or worktrees: estela import --yes assigns them.",
553
549
  // ── publish: resumen antes de subir, y unpublish ──
554
550
  "No se ha publicado nada. Sin terminal no se puede confirmar: añade --yes para publicar.": "Nothing was published. Without a terminal there's no way to confirm: add --yes to publish.",
555
551
  "¿Publicar? [s/N] ": "Publish? [y/N] ",
package/dist/server.js CHANGED
@@ -128,7 +128,11 @@ async function importOnce(dbPath) {
128
128
  continue;
129
129
  store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
130
130
  }
131
- const mine = await (0, identity_js_1.myEmailsByRepo)(db, repos);
131
+ // Todo lo conocido, no solo lo leído ahora (ver knownTurns), y los correos
132
+ // de todos esos repos: sin ellos, un día antiguo perdería sus commits.
133
+ const known = (0, assign_js_1.knownTurns)(db, turns);
134
+ const mine = await (0, identity_js_1.myEmailsByRepo)(db, new Set([...repos, ...store.allProjectRepoPaths(db),
135
+ ...known.flatMap((t) => t.repoPath ?? [])]));
132
136
  const allRepoCommits = db.prepare("SELECT * FROM commits").all()
133
137
  .map((r) => ({
134
138
  repoPath: r["repo_path"], hash: r["hash"],
@@ -141,18 +145,15 @@ async function importOnce(dbPath) {
141
145
  let imputed = 0;
142
146
  // Solo tus commits producen horas tuyas.
143
147
  const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
144
- const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commits);
148
+ const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commits);
145
149
  const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commits, fromAgents);
146
- // Las mismas reglas que `estela import` (billing/assign.ts). Las horas de
147
- // subcarpetas y worktrees solo se recuperan si ya se confirmó una vez
148
- // desde la terminal: el panel no cambia horas importadas en segundo plano
149
- // sin que nadie haya visto antes qué iba a cambiar.
150
- const recover = (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.dirname)(dbPath), ".recover-git-root"));
151
- const plan = (0, assign_js_1.planEntries)(db, [...fromAgents, ...fromCommits], { recover });
150
+ // Las mismas reglas que `estela import` (billing/assign.ts).
151
+ const plan = (0, assign_js_1.planEntries)(db, [...fromAgents, ...fromCommits]);
152
152
  for (const { entry } of plan.entries) {
153
153
  store.saveTimeEntry(db, entry);
154
154
  imputed++;
155
155
  }
156
+ store.deleteStaleEntries(db, plan.stale);
156
157
  return imputed;
157
158
  }
158
159
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "estela",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
5
5
  "keywords": [
6
6
  "time-tracking",