trackerctl 0.1.0 β†’ 0.2.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 CHANGED
@@ -86,6 +86,8 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
86
86
  | `tracker release <id>` | Clear assignee + label, tombstone claim tokens |
87
87
  | `tracker close <id> [--reason <text>]` | Close (clears assignee + in-progress label) |
88
88
  | `tracker comment <id> <text>` | Post a comment on an item |
89
+ | `tracker spend <id> <duration>` | Add time spent (`1h30m`, `2d`; `-30m` subtracts) |
90
+ | `tracker estimate <id> <duration>` | Set the time estimate (`0` clears it) |
89
91
  | `tracker dep <id> --blocked-by <o> \| --blocks <o>` | Add a dependency edge |
90
92
  | `tracker parent <child> <parent>` | Re-parent an item |
91
93
  | `tracker remember <key> <text>` | Store a project memory |
@@ -101,6 +103,8 @@ tracker search --state closed # state alone is a valid filter
101
103
  tracker search "payment timeout" --label backend --state open
102
104
  tracker comment 42 "blocked on design review"
103
105
  tracker comments 42 --json
106
+ tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
107
+ tracker estimate 42 2d
104
108
  tracker search checkout --remote --json # fresher, server-side
105
109
  tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,backend
106
110
  tracker claim 42 && do-the-work || echo "someone else got it"
@@ -144,6 +148,8 @@ domain refusal (e.g. lost a claim race) β€” pick different work, don't retry.
144
148
  - Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
145
149
  `tracker epic-status <id> --json`, `tracker comments <id> --json`
146
150
  - Discuss: `tracker comment <id> "<note for humans or other agents>"`
151
+ - Track time: `tracker spend <id> 1h30m` after finishing work (estimate vs spent
152
+ feeds later evaluations; durations use 1d=8h, -30m subtracts)
147
153
  - Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
148
154
  `tracker forget <key>`
149
155
 
@@ -168,7 +174,9 @@ Never edit the `πŸ“Œ Project Memory` issue or `πŸ”’/πŸ”“` notes by hand.
168
174
  "blockedBy": ["7", "9"], // ids of open OR closed blockers; `ready` checks openness
169
175
  "url": "https://gitlab…/issues/42",
170
176
  "description": "…",
171
- "updatedAt": "2026-06-10T17:21:33.000Z"
177
+ "updatedAt": "2026-06-10T17:21:33.000Z",
178
+ "timeSpentSeconds": 3600, // 0 = none recorded
179
+ "timeEstimateSeconds": 57600 // 0 = no estimate
172
180
  }
173
181
  ```
174
182
 
package/dist/tracker.js CHANGED
@@ -156,6 +156,8 @@ function mapIssue(issue, info) {
156
156
  url: issue.web_url,
157
157
  description,
158
158
  updatedAt: issue.updated_at,
159
+ timeSpentSeconds: issue.time_stats?.total_time_spent ?? 0,
160
+ timeEstimateSeconds: issue.time_stats?.time_estimate ?? 0,
159
161
  raw: issue
160
162
  };
161
163
  }
@@ -225,7 +227,12 @@ class GitLabAdapter {
225
227
  this.nativeBlocking = opts.nativeBlocking;
226
228
  }
227
229
  capabilities() {
228
- return { nativeBlocking: this.nativeBlocking, nativeHierarchy: true, serverSearch: true };
230
+ return {
231
+ nativeBlocking: this.nativeBlocking,
232
+ nativeHierarchy: true,
233
+ serverSearch: true,
234
+ timeTracking: true
235
+ };
229
236
  }
230
237
  async getFullPath() {
231
238
  if (this.fullPath)
@@ -414,6 +421,47 @@ class GitLabAdapter {
414
421
  throw new DomainError(`workItemUpdate: ${data.workItemUpdate.errors.join("; ")}`);
415
422
  }
416
423
  }
424
+ static glDuration(seconds) {
425
+ const sign = seconds < 0 ? "-" : "";
426
+ let rest = Math.abs(seconds);
427
+ const parts = [];
428
+ const h = Math.floor(rest / 3600);
429
+ rest %= 3600;
430
+ const m = Math.floor(rest / 60);
431
+ const s = rest % 60;
432
+ if (h)
433
+ parts.push(`${h}h`);
434
+ if (m)
435
+ parts.push(`${m}m`);
436
+ if (s)
437
+ parts.push(`${s}s`);
438
+ return sign + (parts.join("") || "0m");
439
+ }
440
+ async addTimeSpent(id, seconds) {
441
+ try {
442
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}/add_spent_time`, {
443
+ method: "POST",
444
+ body: { duration: GitLabAdapter.glDuration(seconds) }
445
+ });
446
+ } catch (e) {
447
+ if (e instanceof GitLabHttpError && e.status === 400) {
448
+ throw new DomainError(`cannot record ${GitLabAdapter.glDuration(seconds)} on #${id}: ${e.message}`);
449
+ }
450
+ throw e;
451
+ }
452
+ }
453
+ async setTimeEstimate(id, seconds) {
454
+ if (seconds === 0) {
455
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}/reset_time_estimate`, {
456
+ method: "POST"
457
+ });
458
+ return;
459
+ }
460
+ await this.client.rest(`projects/${this.projectRef}/issues/${id}/time_estimate`, {
461
+ method: "POST",
462
+ body: { duration: GitLabAdapter.glDuration(seconds) }
463
+ });
464
+ }
417
465
  async comment(id, body) {
418
466
  await this.client.rest(`projects/${this.projectRef}/issues/${id}/notes`, {
419
467
  method: "POST",
@@ -478,6 +526,8 @@ CREATE TABLE IF NOT EXISTS items (
478
526
  url TEXT NOT NULL,
479
527
  description TEXT NOT NULL,
480
528
  updated_at TEXT NOT NULL,
529
+ time_spent INTEGER NOT NULL DEFAULT 0,
530
+ time_estimate INTEGER NOT NULL DEFAULT 0,
481
531
  raw TEXT
482
532
  );
483
533
  CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent);
@@ -501,16 +551,24 @@ class Cache {
501
551
  mkdirSync(dirname(path), { recursive: true });
502
552
  this.db = new Database(path);
503
553
  this.db.exec(SCHEMA);
554
+ for (const col of [
555
+ "time_spent INTEGER NOT NULL DEFAULT 0",
556
+ "time_estimate INTEGER NOT NULL DEFAULT 0"
557
+ ]) {
558
+ try {
559
+ this.db.exec(`ALTER TABLE items ADD COLUMN ${col}`);
560
+ } catch {}
561
+ }
504
562
  }
505
563
  replaceAll(items, provider) {
506
- const insertItem = this.db.prepare(`INSERT INTO items (id, provider, kind, title, state, labels, assignees, author, parent, url, description, updated_at, raw)
507
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
564
+ const insertItem = this.db.prepare(`INSERT INTO items (id, provider, kind, title, state, labels, assignees, author, parent, url, description, updated_at, time_spent, time_estimate, raw)
565
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
508
566
  const insertLink = this.db.prepare("INSERT OR IGNORE INTO links (blocker, blocked) VALUES (?, ?)");
509
567
  const insertFts = this.db.prepare("INSERT INTO items_fts (id, title, description) VALUES (?, ?, ?)");
510
568
  const tx = this.db.transaction(() => {
511
569
  this.db.exec("DELETE FROM items; DELETE FROM links; DELETE FROM items_fts;");
512
570
  for (const item of items) {
513
- insertItem.run(item.id, provider, item.kind, item.title, item.state, JSON.stringify(item.labels), JSON.stringify(item.assignees), item.author ? JSON.stringify(item.author) : null, item.parent, item.url, item.description, item.updatedAt, item.raw === undefined ? null : JSON.stringify(item.raw));
571
+ insertItem.run(item.id, provider, item.kind, item.title, item.state, JSON.stringify(item.labels), JSON.stringify(item.assignees), item.author ? JSON.stringify(item.author) : null, item.parent, item.url, item.description, item.updatedAt, item.timeSpentSeconds, item.timeEstimateSeconds, item.raw === undefined ? null : JSON.stringify(item.raw));
514
572
  for (const blocker of item.blockedBy)
515
573
  insertLink.run(blocker, item.id);
516
574
  insertFts.run(item.id, item.title, item.description);
@@ -531,7 +589,9 @@ class Cache {
531
589
  blockedBy: this.blockersOf(row.id),
532
590
  url: row.url,
533
591
  description: row.description,
534
- updatedAt: row.updated_at
592
+ updatedAt: row.updated_at,
593
+ timeSpentSeconds: row.time_spent,
594
+ timeEstimateSeconds: row.time_estimate
535
595
  };
536
596
  }
537
597
  blockersOf(id) {
@@ -854,6 +914,47 @@ async function releaseItem(adapter, id, policy) {
854
914
  return { id, cleared };
855
915
  }
856
916
 
917
+ // src/core/duration.ts
918
+ var UNIT_SECONDS = {
919
+ w: 5 * 8 * 3600,
920
+ d: 8 * 3600,
921
+ h: 3600,
922
+ m: 60,
923
+ s: 1
924
+ };
925
+ function parseDuration(input) {
926
+ const trimmed = input.trim();
927
+ const negative = trimmed.startsWith("-");
928
+ const body = negative ? trimmed.slice(1) : trimmed;
929
+ if (body === "0")
930
+ return 0;
931
+ const parts = [...body.matchAll(/(\d+)([wdhms])/g)];
932
+ const matched = parts.map((p) => p[0]).join("");
933
+ if (parts.length === 0 || matched !== body) {
934
+ throw new UsageError(`invalid duration "${input}" \u2014 use units w/d/h/m/s, e.g. 1h30m, 45m, 2d (1d=8h, 1w=5d), -30m to subtract`);
935
+ }
936
+ const seconds = parts.reduce((sum, p) => sum + Number(p[1]) * UNIT_SECONDS[p[2]], 0);
937
+ return negative ? -seconds : seconds;
938
+ }
939
+ function formatDuration(totalSeconds) {
940
+ const sign = totalSeconds < 0 ? "-" : "";
941
+ let rest = Math.abs(Math.round(totalSeconds));
942
+ const parts = [];
943
+ const h = Math.floor(rest / 3600);
944
+ rest %= 3600;
945
+ const m = Math.floor(rest / 60);
946
+ const s = rest % 60;
947
+ if (h)
948
+ parts.push(`${h}h`);
949
+ if (m)
950
+ parts.push(`${m}m`);
951
+ if (s)
952
+ parts.push(`${s}s`);
953
+ if (parts.length === 0)
954
+ return "0m";
955
+ return sign + parts.join(" ");
956
+ }
957
+
857
958
  // src/core/ids.ts
858
959
  function normalizeId(input) {
859
960
  const id = (input ?? "").trim().replace(/^#/, "");
@@ -1029,6 +1130,8 @@ write commands:
1029
1130
  release <id> clear assignee/label, tombstone live claim tokens
1030
1131
  close <id> [--reason <text>]
1031
1132
  comment <id> <text> post a comment on an item
1133
+ spend <id> <duration> add time spent (1h30m, 45m, 2d; -30m subtracts)
1134
+ estimate <id> <duration> set the time estimate (0 clears it)
1032
1135
  dep <id> --blocked-by <other> | --blocks <other>
1033
1136
  parent <child-id> <parent-id>
1034
1137
  remember <key> <text> store a project memory (key has no whitespace)
@@ -1098,6 +1201,21 @@ Lists the item's comments oldest-first (system notes are filtered out). Claim
1098
1201
  notes (\uD83D\uDD12/\uD83D\uDD13) and memory entries (\uD83D\uDCCC) appear here too \u2014 useful for debugging.
1099
1202
 
1100
1203
  example: tracker comments 42 --json`,
1204
+ spend: `usage: tracker spend <id> <duration>
1205
+
1206
+ Adds to the item's time spent. Durations use GitLab conventions:
1207
+ units w/d/h/m/s with 1d = 8h and 1w = 5d. A leading "-" subtracts.
1208
+
1209
+ examples:
1210
+ tracker spend 42 1h30m
1211
+ tracker spend 42 -30m # logged too much, take 30m back`,
1212
+ estimate: `usage: tracker estimate <id> <duration>
1213
+
1214
+ Sets the item's time estimate (same duration format as spend). 0 clears it.
1215
+
1216
+ examples:
1217
+ tracker estimate 42 2d
1218
+ tracker estimate 42 0`,
1101
1219
  dep: `usage: tracker dep <id> --blocked-by <other> | --blocks <other>
1102
1220
 
1103
1221
  examples:
@@ -1168,6 +1286,11 @@ function printItemDetail(item, blocks) {
1168
1286
  console.log(`parent: ${item.parent ? `#${item.parent}` : "-"}`);
1169
1287
  console.log(`blocked by: ${item.blockedBy.map((b) => `#${b}`).join(", ") || "-"}`);
1170
1288
  console.log(`blocks: ${blocks.map((b) => `#${b}`).join(", ") || "-"}`);
1289
+ if (item.timeSpentSeconds || item.timeEstimateSeconds) {
1290
+ const spent = formatDuration(item.timeSpentSeconds);
1291
+ const estimate = item.timeEstimateSeconds ? ` / estimate ${formatDuration(item.timeEstimateSeconds)}` : "";
1292
+ console.log(`time: spent ${spent}${estimate}`);
1293
+ }
1171
1294
  console.log(`updated: ${item.updatedAt}`);
1172
1295
  if (item.description) {
1173
1296
  console.log("---");
@@ -1185,7 +1308,7 @@ function parseArgs(args, spec, aliases = {}) {
1185
1308
  const positionals = [];
1186
1309
  for (let i = 0;i < args.length; i++) {
1187
1310
  const arg = args[i];
1188
- if (!arg.startsWith("-") || arg === "-") {
1311
+ if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
1189
1312
  positionals.push(arg);
1190
1313
  continue;
1191
1314
  }
@@ -1381,6 +1504,38 @@ async function cmdMemories(ctx, args) {
1381
1504
  for (const m of memories)
1382
1505
  console.log(`${m.key} ${m.ts} ${m.text}`);
1383
1506
  }
1507
+ function requireTimeTracking(ctx) {
1508
+ if (!ctx.adapter.capabilities().timeTracking) {
1509
+ throw new UsageError(`the ${ctx.adapter.provider} adapter does not support time tracking`);
1510
+ }
1511
+ }
1512
+ async function cmdSpend(ctx, args) {
1513
+ requireTimeTracking(ctx);
1514
+ const [idArg, durationArg] = args.positionals;
1515
+ const id = normalizeId(idArg);
1516
+ if (!durationArg)
1517
+ throw new UsageError(commandHelp("spend"));
1518
+ const seconds = parseDuration(durationArg);
1519
+ if (seconds === 0)
1520
+ throw new UsageError("spend needs a non-zero duration (e.g. 1h30m or -30m)");
1521
+ await ctx.adapter.addTimeSpent(id, seconds);
1522
+ invalidate(ctx);
1523
+ const item = await ctx.adapter.get(id);
1524
+ console.log(`#${id} ${seconds > 0 ? "spent" : "subtracted"} ${formatDuration(Math.abs(seconds))} \u2192 total ${formatDuration(item.timeSpentSeconds)}`);
1525
+ }
1526
+ async function cmdEstimate(ctx, args) {
1527
+ requireTimeTracking(ctx);
1528
+ const [idArg, durationArg] = args.positionals;
1529
+ const id = normalizeId(idArg);
1530
+ if (durationArg === undefined)
1531
+ throw new UsageError(commandHelp("estimate"));
1532
+ const seconds = parseDuration(durationArg);
1533
+ if (seconds < 0)
1534
+ throw new UsageError("an estimate cannot be negative (use 0 to clear it)");
1535
+ await ctx.adapter.setTimeEstimate(id, seconds);
1536
+ invalidate(ctx);
1537
+ console.log(seconds === 0 ? `#${id} estimate cleared` : `#${id} estimate ${formatDuration(seconds)}`);
1538
+ }
1384
1539
  async function cmdComment(ctx, args) {
1385
1540
  const [idArg, ...textParts] = args.positionals;
1386
1541
  const id = normalizeId(idArg);
@@ -1589,6 +1744,8 @@ var VALUE_FLAGS = {
1589
1744
  memories: { "--json": "bool" },
1590
1745
  comment: {},
1591
1746
  comments: { "--json": "bool" },
1747
+ spend: {},
1748
+ estimate: {},
1592
1749
  sync: {},
1593
1750
  claim: {},
1594
1751
  release: {},
@@ -1644,6 +1801,8 @@ ${HELP}`);
1644
1801
  memories: cmdMemories,
1645
1802
  comment: cmdComment,
1646
1803
  comments: cmdComments,
1804
+ spend: cmdSpend,
1805
+ estimate: cmdEstimate,
1647
1806
  search: cmdSearch,
1648
1807
  users: cmdUsers,
1649
1808
  whoami: cmdWhoami
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackerctl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-backend issue-tracking CLI for humans and AI agents β€” claim races, dependencies, hierarchy, local FTS search, project memory. GitLab adapter first.",
5
5
  "license": "MIT",
6
6
  "author": "refo",