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 +9 -1
- package/dist/tracker.js +165 -6
- package/package.json +1 -1
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 {
|
|
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.
|
|
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",
|