@metyatech/task-tracker 0.2.3 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +15 -5
  2. package/dist/cli.js +130 -51
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -30,7 +30,9 @@ npm link
30
30
 
31
31
  Tasks are stored in `<git-repo-root>/.tasks.jsonl` (JSONL format, one JSON object per line). All commands must be run from within a git repository. The file is created automatically on first `add`.
32
32
 
33
- Each task: `{ id, description, stage, createdAt, updatedAt }`
33
+ Each task: `{ id, description, stage, committedEventId?, createdAt, updatedAt }`
34
+
35
+ `committedEventId` is a unique ID written when the task transitions to `committed`. It is used to locate the git commit that introduced the event into `.tasks.jsonl`, enabling accurate `pushed` detection even when `update --stage committed` is called before the actual commit.
34
36
 
35
37
  To sync tasks across PCs, commit `.tasks.jsonl` and push/pull like any other file.
36
38
 
@@ -56,9 +58,9 @@ task-tracker list --json
56
58
  ### Update a task
57
59
 
58
60
  ```bash
59
- task-tracker update <id> --stage implemented
61
+ task-tracker update <id> --stage committed
60
62
  task-tracker update <id> --description "Updated description"
61
- task-tracker update <id> --stage verified --json
63
+ task-tracker update <id> --stage released --json
62
64
  ```
63
65
 
64
66
  ### Mark done
@@ -89,9 +91,17 @@ The `check` command:
89
91
 
90
92
  ## Lifecycle Stages
91
93
 
92
- `pending` → `in-progress` → `implemented` → `verified` → `committed` → `pushed` → `pr-created` → `merged` → `released` → `published` → `done`
94
+ Persisted stages: `pending` → `in-progress` → `committed` → `released` → `done`
95
+
96
+ The `pushed` stage is **derived** and cannot be set manually. When a task is in `committed`
97
+ stage, `list` and `check` display its effective stage as `pushed` automatically once the
98
+ `.tasks.jsonl` commit that first introduced the task's `committedEventId` is reachable from
99
+ the remote upstream branch (`git merge-base --is-ancestor`). This means derivation is
100
+ accurate even when `update --stage committed` is called before the actual git commit.
101
+
102
+ To filter by effective `pushed` stage: `task-tracker list --stage pushed`
93
103
 
94
- Stages can be set in any order.
104
+ Setting `--stage pushed` on `add` or `update` is an error.
95
105
 
96
106
  ## Dev Commands
97
107
 
package/dist/cli.js CHANGED
@@ -3,15 +3,16 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
- import { dirname as dirname3, join as join4 } from "path";
6
+ import { dirname as dirname3, join as join5 } from "path";
7
7
  import { readFileSync as readFileSync3 } from "fs";
8
8
 
9
9
  // src/storage.ts
10
10
  import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
11
- import { dirname, join } from "path";
11
+ import { dirname, join as join2 } from "path";
12
12
 
13
13
  // src/git.ts
14
14
  import { execSync } from "child_process";
15
+ import { join, relative } from "path";
15
16
  function tryExec(cmd, cwd) {
16
17
  try {
17
18
  return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
@@ -55,11 +56,36 @@ function getRepoStatus(repoPath) {
55
56
  }
56
57
  return status;
57
58
  }
59
+ function isCommitReachableFromUpstream(repoPath, commit) {
60
+ if (!/^[0-9a-f]{4,40}$/i.test(commit)) return false;
61
+ const result = tryExec(`git merge-base --is-ancestor ${commit} @{u}`, repoPath);
62
+ return result !== null;
63
+ }
64
+ function findCommitByEventId(repoPath, storageFile, eventId) {
65
+ if (!/^[A-Za-z0-9_-]{4,64}$/.test(eventId)) return null;
66
+ const relPath = relative(repoPath, storageFile).replace(/\\/g, "/");
67
+ const result = tryExec(`git log -S "${eventId}" --format=%H -- "${relPath}"`, repoPath);
68
+ if (!result) return null;
69
+ const hash = result.split("\n")[0].trim();
70
+ return hash || null;
71
+ }
72
+ function deriveEffectiveStage(task, repoPath) {
73
+ if (task.stage === "committed") {
74
+ if (task.committedEventId) {
75
+ const storageFile = join(repoPath, ".tasks.jsonl");
76
+ const commit = findCommitByEventId(repoPath, storageFile, task.committedEventId);
77
+ if (commit && isCommitReachableFromUpstream(repoPath, commit)) {
78
+ return "pushed";
79
+ }
80
+ }
81
+ }
82
+ return task.stage;
83
+ }
58
84
 
59
85
  // src/storage.ts
60
86
  function getStoragePath() {
61
87
  const root = getRepoRoot();
62
- return join(root, ".tasks.jsonl");
88
+ return join2(root, ".tasks.jsonl");
63
89
  }
64
90
  function ensureStorageDir(storagePath) {
65
91
  const dir = dirname(storagePath);
@@ -74,7 +100,15 @@ function readTasks(storagePath) {
74
100
  }
75
101
  const content = readFileSync(storagePath, "utf-8").trim();
76
102
  if (!content) return [];
77
- return content.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
103
+ return content.split("\n").filter((line) => line.trim()).map((line) => {
104
+ try {
105
+ return JSON.parse(line);
106
+ } catch {
107
+ process.stderr.write(`[task-tracker] Warning: skipping malformed line in tasks file
108
+ `);
109
+ return null;
110
+ }
111
+ }).filter((task) => task !== null);
78
112
  }
79
113
  function writeTasks(storagePath, tasks) {
80
114
  ensureStorageDir(storagePath);
@@ -90,19 +124,7 @@ function addTaskToStorage(storagePath, task) {
90
124
  import { nanoid } from "nanoid";
91
125
 
92
126
  // src/types.ts
93
- var STAGES = [
94
- "pending",
95
- "in-progress",
96
- "implemented",
97
- "verified",
98
- "committed",
99
- "pushed",
100
- "pr-created",
101
- "merged",
102
- "released",
103
- "published",
104
- "done"
105
- ];
127
+ var STAGES = ["pending", "in-progress", "committed", "released", "done"];
106
128
  var DONE_STAGES = ["done"];
107
129
 
108
130
  // src/tasks.ts
@@ -135,6 +157,9 @@ function updateTask(storagePath, id, updates) {
135
157
  const task = tasks[idx];
136
158
  if (updates.stage !== void 0) task.stage = updates.stage;
137
159
  if (updates.description !== void 0) task.description = updates.description;
160
+ if (updates.stage === "committed" && !task.committedEventId) {
161
+ task.committedEventId = nanoid(16);
162
+ }
138
163
  task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
139
164
  writeTasks(storagePath, tasks);
140
165
  return task;
@@ -176,32 +201,28 @@ import chalk from "chalk";
176
201
  var STAGE_COLORS = {
177
202
  pending: (s) => chalk.gray(s),
178
203
  "in-progress": (s) => chalk.blue(s),
179
- implemented: (s) => chalk.cyan(s),
180
- verified: (s) => chalk.yellow(s),
181
204
  committed: (s) => chalk.magenta(s),
182
205
  pushed: (s) => chalk.green(s),
183
- "pr-created": (s) => chalk.greenBright(s),
184
- merged: (s) => chalk.green(s),
185
206
  released: (s) => chalk.blueBright(s),
186
- published: (s) => chalk.greenBright(s),
187
207
  done: (s) => chalk.dim(s)
188
208
  };
189
209
  function stageColor(stage) {
190
210
  const fn = STAGE_COLORS[stage] ?? ((s) => chalk.white(s));
191
211
  return fn(stage);
192
212
  }
193
- function formatTask(task) {
194
- const stage = stageColor(task.stage);
213
+ function formatTask(task, effectiveStage) {
214
+ const displayStage = effectiveStage ?? task.stage;
215
+ const stage = stageColor(displayStage);
195
216
  const id = chalk.bold(task.id);
196
217
  return `${id} ${stage} ${task.description}`;
197
218
  }
198
- function formatTaskTable(tasks) {
219
+ function formatTaskTable(tasks, getEffectiveStage) {
199
220
  if (tasks.length === 0) {
200
221
  return chalk.dim("No tasks found.");
201
222
  }
202
- return tasks.map(formatTask).join("\n");
223
+ return tasks.map((t) => formatTask(t, getEffectiveStage?.(t))).join("\n");
203
224
  }
204
- function formatCheckReport(activeTasks, repoStatus) {
225
+ function formatCheckReport(activeTasks, repoStatus, getEffectiveStage) {
205
226
  const lines = [];
206
227
  lines.push(chalk.bold("=== Task Tracker Check ==="));
207
228
  lines.push("");
@@ -210,7 +231,7 @@ function formatCheckReport(activeTasks, repoStatus) {
210
231
  lines.push(chalk.dim(" No active tasks."));
211
232
  } else {
212
233
  for (const t of activeTasks) {
213
- lines.push(" " + formatTask(t));
234
+ lines.push(" " + formatTask(t, getEffectiveStage?.(t)));
214
235
  }
215
236
  }
216
237
  lines.push("");
@@ -242,25 +263,25 @@ function formatCheckReport(activeTasks, repoStatus) {
242
263
  // src/gui.ts
243
264
  import { createServer } from "http";
244
265
  import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
245
- import { join as join3, dirname as dirname2, resolve } from "path";
266
+ import { join as join4, dirname as dirname2, resolve } from "path";
246
267
  import { fileURLToPath } from "url";
247
268
  import { exec } from "child_process";
248
269
 
249
270
  // src/scanner.ts
250
271
  import { existsSync as existsSync2, readdirSync, statSync } from "fs";
251
- import { join as join2, basename } from "path";
272
+ import { join as join3, basename } from "path";
252
273
  function scanTaskFiles(dir) {
253
- const rootFile = join2(dir, ".tasks.jsonl");
274
+ const rootFile = join3(dir, ".tasks.jsonl");
254
275
  const root = existsSync2(rootFile) ? { path: rootFile, dir, name: basename(dir) } : null;
255
276
  const repos = [];
256
277
  try {
257
278
  const entries = readdirSync(dir);
258
279
  for (const entry of entries) {
259
280
  if (entry.startsWith(".")) continue;
260
- const subDir = join2(dir, entry);
281
+ const subDir = join3(dir, entry);
261
282
  try {
262
283
  if (statSync(subDir).isDirectory()) {
263
- const taskFile = join2(subDir, ".tasks.jsonl");
284
+ const taskFile = join3(subDir, ".tasks.jsonl");
264
285
  if (existsSync2(taskFile)) {
265
286
  repos.push({ path: taskFile, dir: subDir, name: entry });
266
287
  }
@@ -278,8 +299,8 @@ var __filename = fileURLToPath(import.meta.url);
278
299
  var __dirname = dirname2(__filename);
279
300
  function getHtmlPath() {
280
301
  const candidates = [
281
- join3(__dirname, "public", "index.html"),
282
- join3(__dirname, "..", "public", "index.html")
302
+ join4(__dirname, "public", "index.html"),
303
+ join4(__dirname, "..", "public", "index.html")
283
304
  ];
284
305
  for (const p of candidates) {
285
306
  if (existsSync3(p)) return p;
@@ -387,7 +408,7 @@ function startGui(dir, port = 3333) {
387
408
  if (pathname === "/api/tasks/purge" && method === "POST") {
388
409
  const body = await parseBody(req);
389
410
  const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
390
- const taskFile = join3(targetDir, ".tasks.jsonl");
411
+ const taskFile = join4(targetDir, ".tasks.jsonl");
391
412
  const result = purgeTasks(taskFile);
392
413
  sendJson(res, { count: result.count, ids: result.purged.map((t) => t.id) });
393
414
  return;
@@ -399,7 +420,7 @@ function startGui(dir, port = 3333) {
399
420
  return;
400
421
  }
401
422
  const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
402
- const taskFile = join3(targetDir, ".tasks.jsonl");
423
+ const taskFile = join4(targetDir, ".tasks.jsonl");
403
424
  const stage = typeof body.stage === "string" && STAGES.includes(body.stage) ? body.stage : void 0;
404
425
  const task = createTask(taskFile, body.description, { stage });
405
426
  sendJson(res, task, 201);
@@ -411,7 +432,7 @@ function startGui(dir, port = 3333) {
411
432
  if (method === "PUT") {
412
433
  const body = await parseBody(req);
413
434
  const targetDir = typeof body.dir === "string" ? body.dir : resolvedDir;
414
- const taskFile = join3(targetDir, ".tasks.jsonl");
435
+ const taskFile = join4(targetDir, ".tasks.jsonl");
415
436
  const updates = {};
416
437
  if (typeof body.stage === "string" && STAGES.includes(body.stage)) {
417
438
  updates.stage = body.stage;
@@ -429,7 +450,7 @@ function startGui(dir, port = 3333) {
429
450
  }
430
451
  if (method === "DELETE") {
431
452
  const queryDir = url.searchParams.get("dir") ?? resolvedDir;
432
- const taskFile = join3(queryDir, ".tasks.jsonl");
453
+ const taskFile = join4(queryDir, ".tasks.jsonl");
433
454
  const removed = removeTask(taskFile, id);
434
455
  if (!removed) {
435
456
  sendError(res, "Task not found", 404);
@@ -465,13 +486,20 @@ var __filename2 = fileURLToPath2(import.meta.url);
465
486
  var __dirname2 = dirname3(__filename2);
466
487
  var version = "0.0.0";
467
488
  try {
468
- const pkg = JSON.parse(readFileSync3(join4(__dirname2, "..", "package.json"), "utf-8"));
489
+ const pkg = JSON.parse(readFileSync3(join5(__dirname2, "..", "package.json"), "utf-8"));
469
490
  version = pkg.version;
470
491
  } catch {
471
492
  }
472
493
  function isValidStage(s) {
473
494
  return STAGES.includes(s);
474
495
  }
496
+ function isDerivedStage(s) {
497
+ return s === "pushed";
498
+ }
499
+ function derivedStageError(stage) {
500
+ return `\`${stage}\` is a derived stage and cannot be set manually.
501
+ Set stage to \`committed\` instead; \`${stage}\` is derived automatically when the commit containing \`committedEventId\` is reachable from the upstream branch.`;
502
+ }
475
503
  function getStorage() {
476
504
  try {
477
505
  return getStoragePath();
@@ -482,7 +510,15 @@ function getStorage() {
482
510
  }
483
511
  var program = new Command();
484
512
  program.name("task-tracker").description("Persistent task lifecycle tracker for AI agent sessions").version(version, "-V, --version");
485
- program.command("add <description>").description("Add a new task").option("--stage <stage>", "Initial stage", "pending").option("--json", "Output created task as JSON").action((description, opts) => {
513
+ program.command("add <description>").description("Add a new task").option(
514
+ "--stage <stage>",
515
+ `Initial stage (valid: ${STAGES.join(", ")}; \`pushed\` is derived, not settable)`,
516
+ "pending"
517
+ ).option("--json", "Output created task as JSON").action((description, opts) => {
518
+ if (isDerivedStage(opts.stage)) {
519
+ console.error(derivedStageError(opts.stage));
520
+ process2.exit(1);
521
+ }
486
522
  if (!isValidStage(opts.stage)) {
487
523
  console.error(`Invalid stage: ${opts.stage}
488
524
  Valid stages: ${STAGES.join(", ")}`);
@@ -495,30 +531,63 @@ Valid stages: ${STAGES.join(", ")}`);
495
531
  console.log("Created: " + formatTask(task));
496
532
  }
497
533
  });
498
- program.command("list").description("List tasks").option("--all", "Include completed/done tasks").option("--stage <stage>", "Filter by stage").option("--json", "JSON output").action((opts) => {
499
- const stage = opts.stage && isValidStage(opts.stage) ? opts.stage : void 0;
500
- if (opts.stage && !stage) {
534
+ program.command("list").description("List tasks").option("--all", "Include completed/done tasks").option("--stage <stage>", `Filter by stage; use \`pushed\` to filter by derived effective stage`).option("--json", "JSON output").action((opts) => {
535
+ const storage = getStorage();
536
+ const repoRoot = dirname3(storage);
537
+ if (opts.stage === "pushed") {
538
+ const committed = listTasks(storage, { all: opts.all, stage: "committed" });
539
+ const tasks2 = committed.filter((t) => deriveEffectiveStage(t, repoRoot) === "pushed");
540
+ if (opts.json) {
541
+ console.log(
542
+ JSON.stringify(
543
+ tasks2.map((t) => ({ ...t, effectiveStage: "pushed" })),
544
+ null,
545
+ 2
546
+ )
547
+ );
548
+ } else {
549
+ console.log(formatTaskTable(tasks2, () => "pushed"));
550
+ }
551
+ return;
552
+ }
553
+ if (opts.stage && !isValidStage(opts.stage)) {
501
554
  console.error(`Invalid stage: ${opts.stage}
502
555
  Valid stages: ${STAGES.join(", ")}`);
503
556
  process2.exit(1);
504
557
  }
505
- const tasks = listTasks(getStorage(), { all: opts.all, stage });
558
+ const stage = opts.stage ? opts.stage : void 0;
559
+ const tasks = listTasks(storage, { all: opts.all, stage });
560
+ const getEffective = (t) => deriveEffectiveStage(t, repoRoot);
506
561
  if (opts.json) {
507
- console.log(JSON.stringify(tasks, null, 2));
562
+ console.log(
563
+ JSON.stringify(
564
+ tasks.map((t) => ({ ...t, effectiveStage: getEffective(t) })),
565
+ null,
566
+ 2
567
+ )
568
+ );
508
569
  } else {
509
- console.log(formatTaskTable(tasks));
570
+ console.log(formatTaskTable(tasks, getEffective));
510
571
  }
511
572
  });
512
- program.command("update <id>").description("Update a task").option("--stage <stage>", "Set lifecycle stage").option("--description <text>", "Update description").option("--json", "JSON output").action((id, opts) => {
573
+ program.command("update <id>").description("Update a task").option(
574
+ "--stage <stage>",
575
+ `Set lifecycle stage (valid: ${STAGES.join(", ")}; \`pushed\` is derived, not settable)`
576
+ ).option("--description <text>", "Update description").option("--json", "JSON output").action((id, opts) => {
577
+ if (opts.stage && isDerivedStage(opts.stage)) {
578
+ console.error(derivedStageError(opts.stage));
579
+ process2.exit(1);
580
+ }
513
581
  if (opts.stage && !isValidStage(opts.stage)) {
514
582
  console.error(`Invalid stage: ${opts.stage}
515
583
  Valid stages: ${STAGES.join(", ")}`);
516
584
  process2.exit(1);
517
585
  }
586
+ const storage = getStorage();
518
587
  const updates = {};
519
588
  if (opts.stage) updates.stage = opts.stage;
520
589
  if (opts.description) updates.description = opts.description;
521
- const task = updateTask(getStorage(), id, updates);
590
+ const task = updateTask(storage, id, updates);
522
591
  if (!task) {
523
592
  console.error(`Task not found: ${id}`);
524
593
  process2.exit(1);
@@ -555,10 +624,20 @@ program.command("check").description("Show active tasks and git status for this
555
624
  const repoRoot = dirname3(storage);
556
625
  const activeTasks = listTasks(storage, { all: false });
557
626
  const repoStatus = getRepoStatus(repoRoot);
627
+ const getEffective = (t) => deriveEffectiveStage(t, repoRoot);
558
628
  if (opts.json) {
559
- console.log(JSON.stringify({ activeTasks, repoStatus }, null, 2));
629
+ console.log(
630
+ JSON.stringify(
631
+ {
632
+ activeTasks: activeTasks.map((t) => ({ ...t, effectiveStage: getEffective(t) })),
633
+ repoStatus
634
+ },
635
+ null,
636
+ 2
637
+ )
638
+ );
560
639
  } else {
561
- console.log(formatCheckReport(activeTasks, repoStatus));
640
+ console.log(formatCheckReport(activeTasks, repoStatus, getEffective));
562
641
  }
563
642
  });
564
643
  program.command("purge").description("Remove all done tasks from storage").option("--dry-run", "Show what would be removed without removing").option("--keep <n>", "Keep N most recent done tasks, purge the rest").option("--json", "JSON output").action((opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metyatech/task-tracker",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Persistent task lifecycle tracker for AI agent sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,7 @@
53
53
  "vitest": "^4.0.0"
54
54
  },
55
55
  "overrides": {
56
- "minimatch": "^10.2.2"
56
+ "minimatch": "10.2.4"
57
57
  },
58
58
  "lint-staged": {
59
59
  "src/**/*.ts": [