trackerctl 0.1.0 → 0.3.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
@@ -28,16 +28,19 @@ bun run build # bundle to dist/tracker.js (what the npm package ships)
28
28
 
29
29
  ## Configuration
30
30
 
31
- Copy `tracker.config.example.json` to `tracker.config.json` and fill in your instance:
31
+ In the project root, scaffold a config:
32
32
 
33
33
  ```sh
34
- cp tracker.config.example.json tracker.config.json
34
+ tracker init --base-url https://gitlab.example.com --project group/project
35
+ # or `tracker init` alone, then edit the placeholders
35
36
  ```
36
37
 
37
- `tracker.config.json` is **gitignored** (it carries instance/project identifiers); the
38
- committed example holds the shape. The token is also never committed (env var or gitignored
39
- `.env`). Config discovery walks up from the current directory, so any subdirectory of the
40
- project works.
38
+ `init` writes `tracker.config.json` and inside a git repository — git-ignores the
39
+ local-only files (`tracker.config.json`, `.tracker/`, `.env`) so instance and project
40
+ identifiers can never be committed. The committed `tracker.config.example.json` holds the
41
+ same shape if you prefer copying it by hand. The token is also never committed (env var or
42
+ gitignored `.env`). Config discovery walks up from the current directory, so any
43
+ subdirectory of the project works.
41
44
 
42
45
  ```json
43
46
  {
@@ -86,6 +89,8 @@ Read commands serve from a local sqlite cache that auto-syncs when older than
86
89
  | `tracker release <id>` | Clear assignee + label, tombstone claim tokens |
87
90
  | `tracker close <id> [--reason <text>]` | Close (clears assignee + in-progress label) |
88
91
  | `tracker comment <id> <text>` | Post a comment on an item |
92
+ | `tracker spend <id> <duration>` | Add time spent (`1h30m`, `2d`; `-30m` subtracts) |
93
+ | `tracker estimate <id> <duration>` | Set the time estimate (`0` clears it) |
89
94
  | `tracker dep <id> --blocked-by <o> \| --blocks <o>` | Add a dependency edge |
90
95
  | `tracker parent <child> <parent>` | Re-parent an item |
91
96
  | `tracker remember <key> <text>` | Store a project memory |
@@ -101,6 +106,8 @@ tracker search --state closed # state alone is a valid filter
101
106
  tracker search "payment timeout" --label backend --state open
102
107
  tracker comment 42 "blocked on design review"
103
108
  tracker comments 42 --json
109
+ tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
110
+ tracker estimate 42 2d
104
111
  tracker search checkout --remote --json # fresher, server-side
105
112
  tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,backend
106
113
  tracker claim 42 && do-the-work || echo "someone else got it"
@@ -144,6 +151,8 @@ domain refusal (e.g. lost a claim race) — pick different work, don't retry.
144
151
  - Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
145
152
  `tracker epic-status <id> --json`, `tracker comments <id> --json`
146
153
  - Discuss: `tracker comment <id> "<note for humans or other agents>"`
154
+ - Track time: `tracker spend <id> 1h30m` after finishing work (estimate vs spent
155
+ feeds later evaluations; durations use 1d=8h, -30m subtracts)
147
156
  - Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
148
157
  `tracker forget <key>`
149
158
 
@@ -168,7 +177,9 @@ Never edit the `📌 Project Memory` issue or `🔒/🔓` notes by hand.
168
177
  "blockedBy": ["7", "9"], // ids of open OR closed blockers; `ready` checks openness
169
178
  "url": "https://gitlab…/issues/42",
170
179
  "description": "…",
171
- "updatedAt": "2026-06-10T17:21:33.000Z"
180
+ "updatedAt": "2026-06-10T17:21:33.000Z",
181
+ "timeSpentSeconds": 3600, // 0 = none recorded
182
+ "timeEstimateSeconds": 57600 // 0 = no estimate
172
183
  }
173
184
  ```
174
185
 
package/bin/tracker.mjs CHANGED
File without changes
package/dist/tracker.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli/index.ts
5
- import { existsSync as existsSync3 } from "fs";
6
- import { resolve as resolve3 } from "path";
5
+ import { existsSync as existsSync4 } from "fs";
6
+ import { resolve as resolve4 } from "path";
7
7
 
8
8
  // src/errors.ts
9
9
  class DomainError extends Error {
@@ -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) {
@@ -698,7 +758,7 @@ function parseConfig(json, rootDir) {
698
758
  function loadConfig(startDir = process.cwd()) {
699
759
  const file = findConfigFile(startDir);
700
760
  if (!file) {
701
- throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 create one (see README) or cd into the project.`);
761
+ throw new UsageError(`no ${CONFIG_FILENAME} found in ${startDir} or any parent directory \u2014 run \`tracker init\` to create one, or cd into the project.`);
702
762
  }
703
763
  return parseConfig(readFileSync2(file, "utf8"), dirname3(file));
704
764
  }
@@ -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(/^#/, "");
@@ -1004,11 +1105,83 @@ async function ensureFresh(adapter, cache, staleMs, nowMs = Date.now(), onSync)
1004
1105
  return true;
1005
1106
  }
1006
1107
 
1108
+ // src/init.ts
1109
+ import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
1110
+ import { dirname as dirname4, join as join3, resolve as resolve3 } from "path";
1111
+ var PLACEHOLDER_URL = "https://gitlab.example.com";
1112
+ var PLACEHOLDER_PROJECT = "group/project";
1113
+ function configTemplate(baseUrl, project) {
1114
+ const config = {
1115
+ provider: "gitlab",
1116
+ gitlab: {
1117
+ base_url: baseUrl.replace(/\/+$/, ""),
1118
+ project,
1119
+ token_env: ["TRACKER_GITLAB_TOKEN", "GITLAB_PERSONAL_ACCESS_TOKEN"],
1120
+ native_blocking: true
1121
+ },
1122
+ labels: { in_progress: "status::in-progress" },
1123
+ memory: { enabled: true, title: "\uD83D\uDCCC Project Memory", label: "meta::memory" },
1124
+ cache: { path: ".tracker/cache.sqlite", stale_minutes: 15 }
1125
+ };
1126
+ return `${JSON.stringify(config, null, 2)}
1127
+ `;
1128
+ }
1129
+ function ensureIgnored(dir, warnings) {
1130
+ const gitRoot = findGitRoot(dir);
1131
+ if (!gitRoot)
1132
+ return [];
1133
+ const entries = [
1134
+ [CONFIG_FILENAME, join3(dir, CONFIG_FILENAME)],
1135
+ [".tracker/", join3(dir, ".tracker", "cache.sqlite")],
1136
+ [".env", join3(dir, ".env")]
1137
+ ];
1138
+ const added = [];
1139
+ for (const [pattern, path] of entries) {
1140
+ const ignored = isGitIgnored(gitRoot, path);
1141
+ if (ignored === null) {
1142
+ warnings.push(`git not available \u2014 verify ${pattern} is git-ignored yourself`);
1143
+ return added;
1144
+ }
1145
+ if (ignored)
1146
+ continue;
1147
+ const gitignorePath = join3(dir, ".gitignore");
1148
+ const existing = existsSync3(gitignorePath) ? readFileSync3(gitignorePath, "utf8") : "";
1149
+ const prefix = existing && !existing.endsWith(`
1150
+ `) ? `
1151
+ ` : "";
1152
+ appendFileSync2(gitignorePath, `${prefix}${pattern}
1153
+ `);
1154
+ added.push(pattern);
1155
+ }
1156
+ return added;
1157
+ }
1158
+ function initProject(targetDir, opts) {
1159
+ const dir = resolve3(targetDir);
1160
+ const configPath = join3(dir, CONFIG_FILENAME);
1161
+ if (existsSync3(configPath)) {
1162
+ throw new UsageError(`${configPath} already exists \u2014 edit it instead of re-running init`);
1163
+ }
1164
+ const warnings = [];
1165
+ const parentConfig = findConfigFile(dirname4(dir));
1166
+ if (parentConfig) {
1167
+ warnings.push(`a ${CONFIG_FILENAME} already exists in ${dirname4(parentConfig)} \u2014 the one created here will shadow it for everything under ${dir}`);
1168
+ }
1169
+ const placeholders = !opts.baseUrl || !opts.project;
1170
+ writeFileSync(configPath, configTemplate(opts.baseUrl ?? PLACEHOLDER_URL, opts.project ?? PLACEHOLDER_PROJECT));
1171
+ const ignoreAdded = ensureIgnored(dir, warnings);
1172
+ return { configPath, placeholders, ignoreAdded, warnings };
1173
+ }
1174
+
1007
1175
  // src/cli/help.ts
1008
1176
  var HELP = `tracker \u2014 multi-backend issue tracking for humans and AI agents
1009
1177
 
1010
1178
  usage: tracker <command> [args]
1011
1179
 
1180
+ setup:
1181
+ init [--base-url <url>] [--project <group/project>]
1182
+ scaffold tracker.config.json + .gitignore entries
1183
+ doctor verify config, token, connectivity, capabilities
1184
+
1012
1185
  read commands (local cache, auto-sync when stale; all accept --json):
1013
1186
  sync refresh the local cache from the provider
1014
1187
  ready [--parent <id>] open + unblocked + unassigned + not in-progress
@@ -1020,7 +1193,6 @@ read commands (local cache, auto-sync when stale; all accept --json):
1020
1193
  users <query> resolve usernames/names to user ids
1021
1194
  whoami the authenticated user
1022
1195
  memories [filter] list project memories
1023
- doctor verify config, token, connectivity, capabilities
1024
1196
 
1025
1197
  write commands:
1026
1198
  create -t <title> [-d <desc>] [--parent <id>] [--epic <id>] [-l a,b]
@@ -1029,6 +1201,8 @@ write commands:
1029
1201
  release <id> clear assignee/label, tombstone live claim tokens
1030
1202
  close <id> [--reason <text>]
1031
1203
  comment <id> <text> post a comment on an item
1204
+ spend <id> <duration> add time spent (1h30m, 45m, 2d; -30m subtracts)
1205
+ estimate <id> <duration> set the time estimate (0 clears it)
1032
1206
  dep <id> --blocked-by <other> | --blocks <other>
1033
1207
  parent <child-id> <parent-id>
1034
1208
  remember <key> <text> store a project memory (key has no whitespace)
@@ -1098,6 +1272,21 @@ Lists the item's comments oldest-first (system notes are filtered out). Claim
1098
1272
  notes (\uD83D\uDD12/\uD83D\uDD13) and memory entries (\uD83D\uDCCC) appear here too \u2014 useful for debugging.
1099
1273
 
1100
1274
  example: tracker comments 42 --json`,
1275
+ spend: `usage: tracker spend <id> <duration>
1276
+
1277
+ Adds to the item's time spent. Durations use GitLab conventions:
1278
+ units w/d/h/m/s with 1d = 8h and 1w = 5d. A leading "-" subtracts.
1279
+
1280
+ examples:
1281
+ tracker spend 42 1h30m
1282
+ tracker spend 42 -30m # logged too much, take 30m back`,
1283
+ estimate: `usage: tracker estimate <id> <duration>
1284
+
1285
+ Sets the item's time estimate (same duration format as spend). 0 clears it.
1286
+
1287
+ examples:
1288
+ tracker estimate 42 2d
1289
+ tracker estimate 42 0`,
1101
1290
  dep: `usage: tracker dep <id> --blocked-by <other> | --blocks <other>
1102
1291
 
1103
1292
  examples:
@@ -1133,7 +1322,17 @@ example: tracker users mehmet`,
1133
1322
  whoami: "usage: tracker whoami [--json]",
1134
1323
  doctor: `usage: tracker doctor [--json]
1135
1324
 
1136
- Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`
1325
+ Verifies config, token, REST/GraphQL connectivity, capabilities, cache.`,
1326
+ init: `usage: tracker init [--base-url <url>] [--project <group/project>]
1327
+
1328
+ Scaffolds tracker.config.json in the current directory (placeholders when the
1329
+ flags are omitted) and, inside a git repository, git-ignores the local-only
1330
+ files (tracker.config.json, .tracker/, .env) so instance and project
1331
+ identifiers can never be committed. Refuses to overwrite an existing config.
1332
+
1333
+ examples:
1334
+ tracker init --base-url https://gitlab.example.com --project group/project
1335
+ tracker init # then edit the placeholders in tracker.config.json`
1137
1336
  };
1138
1337
  function commandHelp(cmd) {
1139
1338
  return PER_COMMAND[cmd] ?? HELP;
@@ -1168,6 +1367,11 @@ function printItemDetail(item, blocks) {
1168
1367
  console.log(`parent: ${item.parent ? `#${item.parent}` : "-"}`);
1169
1368
  console.log(`blocked by: ${item.blockedBy.map((b) => `#${b}`).join(", ") || "-"}`);
1170
1369
  console.log(`blocks: ${blocks.map((b) => `#${b}`).join(", ") || "-"}`);
1370
+ if (item.timeSpentSeconds || item.timeEstimateSeconds) {
1371
+ const spent = formatDuration(item.timeSpentSeconds);
1372
+ const estimate = item.timeEstimateSeconds ? ` / estimate ${formatDuration(item.timeEstimateSeconds)}` : "";
1373
+ console.log(`time: spent ${spent}${estimate}`);
1374
+ }
1171
1375
  console.log(`updated: ${item.updatedAt}`);
1172
1376
  if (item.description) {
1173
1377
  console.log("---");
@@ -1185,7 +1389,7 @@ function parseArgs(args, spec, aliases = {}) {
1185
1389
  const positionals = [];
1186
1390
  for (let i = 0;i < args.length; i++) {
1187
1391
  const arg = args[i];
1188
- if (!arg.startsWith("-") || arg === "-") {
1392
+ if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
1189
1393
  positionals.push(arg);
1190
1394
  continue;
1191
1395
  }
@@ -1217,8 +1421,8 @@ function buildCtx() {
1217
1421
  token,
1218
1422
  nativeBlocking: config.gitlab.native_blocking
1219
1423
  });
1220
- const cachePath = resolve3(config.rootDir, config.cache.path);
1221
- if (!existsSync3(cachePath)) {
1424
+ const cachePath = resolve4(config.rootDir, config.cache.path);
1425
+ if (!existsSync4(cachePath)) {
1222
1426
  const guard = guardCacheIgnored(config.rootDir, cachePath);
1223
1427
  if (guard.action === "added" || guard.action === "warn")
1224
1428
  console.error(`(${guard.detail})`);
@@ -1244,7 +1448,7 @@ async function cmdSync(ctx) {
1244
1448
  const t0 = performance.now();
1245
1449
  const { count } = await syncCache(ctx.adapter, ctx.cache);
1246
1450
  const ms = Math.round(performance.now() - t0);
1247
- console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve3(ctx.config.rootDir, ctx.config.cache.path)}`);
1451
+ console.log(`synced ${count} items in ${ms}ms \u2192 ${resolve4(ctx.config.rootDir, ctx.config.cache.path)}`);
1248
1452
  }
1249
1453
  async function cmdReady(ctx, args) {
1250
1454
  await freshen(ctx);
@@ -1381,6 +1585,38 @@ async function cmdMemories(ctx, args) {
1381
1585
  for (const m of memories)
1382
1586
  console.log(`${m.key} ${m.ts} ${m.text}`);
1383
1587
  }
1588
+ function requireTimeTracking(ctx) {
1589
+ if (!ctx.adapter.capabilities().timeTracking) {
1590
+ throw new UsageError(`the ${ctx.adapter.provider} adapter does not support time tracking`);
1591
+ }
1592
+ }
1593
+ async function cmdSpend(ctx, args) {
1594
+ requireTimeTracking(ctx);
1595
+ const [idArg, durationArg] = args.positionals;
1596
+ const id = normalizeId(idArg);
1597
+ if (!durationArg)
1598
+ throw new UsageError(commandHelp("spend"));
1599
+ const seconds = parseDuration(durationArg);
1600
+ if (seconds === 0)
1601
+ throw new UsageError("spend needs a non-zero duration (e.g. 1h30m or -30m)");
1602
+ await ctx.adapter.addTimeSpent(id, seconds);
1603
+ invalidate(ctx);
1604
+ const item = await ctx.adapter.get(id);
1605
+ console.log(`#${id} ${seconds > 0 ? "spent" : "subtracted"} ${formatDuration(Math.abs(seconds))} \u2192 total ${formatDuration(item.timeSpentSeconds)}`);
1606
+ }
1607
+ async function cmdEstimate(ctx, args) {
1608
+ requireTimeTracking(ctx);
1609
+ const [idArg, durationArg] = args.positionals;
1610
+ const id = normalizeId(idArg);
1611
+ if (durationArg === undefined)
1612
+ throw new UsageError(commandHelp("estimate"));
1613
+ const seconds = parseDuration(durationArg);
1614
+ if (seconds < 0)
1615
+ throw new UsageError("an estimate cannot be negative (use 0 to clear it)");
1616
+ await ctx.adapter.setTimeEstimate(id, seconds);
1617
+ invalidate(ctx);
1618
+ console.log(seconds === 0 ? `#${id} estimate cleared` : `#${id} estimate ${formatDuration(seconds)}`);
1619
+ }
1384
1620
  async function cmdComment(ctx, args) {
1385
1621
  const [idArg, ...textParts] = args.positionals;
1386
1622
  const id = normalizeId(idArg);
@@ -1462,7 +1698,7 @@ async function cmdDoctor(args) {
1462
1698
  name: "config",
1463
1699
  status: "fail",
1464
1700
  detail: e.message,
1465
- fix: "create tracker.config.json (see README) in the project root"
1701
+ fix: "run `tracker init` in the project root to create tracker.config.json"
1466
1702
  });
1467
1703
  }
1468
1704
  let ctx = null;
@@ -1529,7 +1765,7 @@ async function cmdDoctor(args) {
1529
1765
  status: "ok",
1530
1766
  detail: last ? `${ctx.cache.count()} items, synced ${Math.round((Date.now() - last) / 60000)}m ago` : "empty (run: tracker sync)"
1531
1767
  });
1532
- const cachePath = resolve3(config.rootDir, config.cache.path);
1768
+ const cachePath = resolve4(config.rootDir, config.cache.path);
1533
1769
  const gitRoot = findGitRoot(config.rootDir);
1534
1770
  if (gitRoot) {
1535
1771
  const ignored = isGitIgnored(gitRoot, cachePath);
@@ -1557,6 +1793,21 @@ all good.`);
1557
1793
  }
1558
1794
  return failed ? 1 : 0;
1559
1795
  }
1796
+ function cmdInit(args) {
1797
+ const result = initProject(process.cwd(), {
1798
+ baseUrl: str(args, "--base-url"),
1799
+ project: str(args, "--project")
1800
+ });
1801
+ console.log(`created ${result.configPath}`);
1802
+ for (const pattern of result.ignoreAdded)
1803
+ console.log(`added "${pattern}" to .gitignore`);
1804
+ for (const warning of result.warnings)
1805
+ console.error(`warning: ${warning}`);
1806
+ if (result.placeholders) {
1807
+ console.log("next: edit gitlab.base_url and gitlab.project in the config");
1808
+ }
1809
+ console.log("then: export TRACKER_GITLAB_TOKEN (or add it to .env) and run: tracker doctor");
1810
+ }
1560
1811
  var VALUE_FLAGS = {
1561
1812
  ready: { "--parent": "value", "--json": "bool" },
1562
1813
  show: { "--json": "bool" },
@@ -1586,9 +1837,12 @@ var VALUE_FLAGS = {
1586
1837
  users: { "--json": "bool" },
1587
1838
  whoami: { "--json": "bool" },
1588
1839
  doctor: { "--json": "bool" },
1840
+ init: { "--base-url": "value", "--project": "value" },
1589
1841
  memories: { "--json": "bool" },
1590
1842
  comment: {},
1591
1843
  comments: { "--json": "bool" },
1844
+ spend: {},
1845
+ estimate: {},
1592
1846
  sync: {},
1593
1847
  claim: {},
1594
1848
  release: {},
@@ -1624,6 +1878,10 @@ ${HELP}`);
1624
1878
  return 0;
1625
1879
  }
1626
1880
  const args = parseArgs(rest, spec, ALIASES[cmd] ?? {});
1881
+ if (cmd === "init") {
1882
+ cmdInit(args);
1883
+ return 0;
1884
+ }
1627
1885
  if (cmd === "doctor")
1628
1886
  return cmdDoctor(args);
1629
1887
  const ctx = buildCtx();
@@ -1644,6 +1902,8 @@ ${HELP}`);
1644
1902
  memories: cmdMemories,
1645
1903
  comment: cmdComment,
1646
1904
  comments: cmdComments,
1905
+ spend: cmdSpend,
1906
+ estimate: cmdEstimate,
1647
1907
  search: cmdSearch,
1648
1908
  users: cmdUsers,
1649
1909
  whoami: cmdWhoami
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackerctl",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",