trekoon 0.1.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.
Files changed (45) hide show
  1. package/.agents/skills/trekoon/SKILL.md +91 -0
  2. package/AGENTS.md +54 -0
  3. package/CONTRIBUTING.md +18 -0
  4. package/README.md +151 -0
  5. package/bin/trekoon +5 -0
  6. package/bun.lock +28 -0
  7. package/package.json +24 -0
  8. package/src/commands/arg-parser.ts +93 -0
  9. package/src/commands/dep.ts +105 -0
  10. package/src/commands/epic.ts +539 -0
  11. package/src/commands/help.ts +61 -0
  12. package/src/commands/init.ts +24 -0
  13. package/src/commands/quickstart.ts +61 -0
  14. package/src/commands/subtask.ts +187 -0
  15. package/src/commands/sync.ts +128 -0
  16. package/src/commands/task.ts +554 -0
  17. package/src/commands/wipe.ts +39 -0
  18. package/src/domain/tracker-domain.ts +576 -0
  19. package/src/domain/types.ts +99 -0
  20. package/src/index.ts +21 -0
  21. package/src/io/human-table.ts +191 -0
  22. package/src/io/output.ts +70 -0
  23. package/src/runtime/cli-shell.ts +158 -0
  24. package/src/runtime/command-types.ts +33 -0
  25. package/src/storage/database.ts +35 -0
  26. package/src/storage/migrations.ts +46 -0
  27. package/src/storage/path.ts +22 -0
  28. package/src/storage/schema.ts +116 -0
  29. package/src/storage/types.ts +15 -0
  30. package/src/sync/branch-db.ts +49 -0
  31. package/src/sync/event-writes.ts +49 -0
  32. package/src/sync/git-context.ts +67 -0
  33. package/src/sync/service.ts +654 -0
  34. package/src/sync/types.ts +31 -0
  35. package/tests/commands/dep.test.ts +101 -0
  36. package/tests/commands/epic.test.ts +383 -0
  37. package/tests/commands/subtask.test.ts +132 -0
  38. package/tests/commands/sync/sync-command.test.ts +1 -0
  39. package/tests/commands/sync.test.ts +199 -0
  40. package/tests/commands/task.test.ts +474 -0
  41. package/tests/integration/sync-workflow.test.ts +279 -0
  42. package/tests/io/human-table.test.ts +81 -0
  43. package/tests/runtime/output-mode.test.ts +54 -0
  44. package/tests/storage/database.test.ts +91 -0
  45. package/tsconfig.json +19 -0
@@ -0,0 +1,554 @@
1
+ import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
+
3
+ import { DomainError, type TaskRecord } from "../domain/types";
4
+ import { TrackerDomain } from "../domain/tracker-domain";
5
+ import { formatHumanTable } from "../io/human-table";
6
+ import { failResult, okResult } from "../io/output";
7
+ import { type CliContext, type CliResult } from "../runtime/command-types";
8
+ import { openTrekoonDatabase } from "../storage/database";
9
+
10
+ function formatTask(task: TaskRecord): string {
11
+ return `${task.id} | epic=${task.epicId} | ${task.title} | ${task.status}`;
12
+ }
13
+
14
+ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
15
+ const LIST_VIEW_MODES = ["table", "compact"] as const;
16
+ const DEFAULT_TASK_LIST_LIMIT = 10;
17
+ const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
18
+
19
+ function parseIdsOption(rawIds: string | undefined): string[] {
20
+ if (rawIds === undefined) {
21
+ return [];
22
+ }
23
+
24
+ return rawIds
25
+ .split(",")
26
+ .map((value) => value.trim())
27
+ .filter((value) => value.length > 0);
28
+ }
29
+
30
+ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
31
+ if (rawStatuses === undefined) {
32
+ return undefined;
33
+ }
34
+
35
+ return rawStatuses
36
+ .split(",")
37
+ .map((value) => value.trim())
38
+ .filter((value) => value.length > 0);
39
+ }
40
+
41
+ function taskStatusPriority(status: string): number {
42
+ if (status === "in_progress" || status === "in-progress") {
43
+ return 0;
44
+ }
45
+
46
+ if (status === "todo") {
47
+ return 1;
48
+ }
49
+
50
+ return 2;
51
+ }
52
+
53
+ function filterSortAndLimitTasks(
54
+ tasks: readonly TaskRecord[],
55
+ statuses: readonly string[] | undefined,
56
+ limit: number | undefined,
57
+ ): TaskRecord[] {
58
+ const allowedStatuses = statuses === undefined ? undefined : new Set(statuses);
59
+ const filtered = allowedStatuses === undefined ? [...tasks] : tasks.filter((task) => allowedStatuses.has(task.status));
60
+ const sorted = [...filtered].sort((left, right) => taskStatusPriority(left.status) - taskStatusPriority(right.status));
61
+
62
+ if (limit === undefined) {
63
+ return sorted;
64
+ }
65
+
66
+ return sorted.slice(0, limit);
67
+ }
68
+
69
+ function appendLine(existing: string, line: string): string {
70
+ return existing.length > 0 ? `${existing}\n${line}` : line;
71
+ }
72
+
73
+ function formatTaskListTable(tasks: readonly TaskRecord[]): string {
74
+ const rows = tasks.map((task) => [task.id, task.epicId, task.title, task.status]);
75
+ return formatHumanTable(["ID", "EPIC", "TITLE", "STATUS"], rows, { wrapColumns: [2] });
76
+ }
77
+
78
+ function formatTaskShowDetail(taskTree: {
79
+ id: string;
80
+ epicId: string;
81
+ title: string;
82
+ description: string;
83
+ status: string;
84
+ subtasks: ReadonlyArray<{ id: string; title: string; description: string; status: string }>;
85
+ }): string {
86
+ const humanLines: string[] = [
87
+ `${taskTree.id} | epic=${taskTree.epicId} | ${taskTree.title} | ${taskTree.status} | desc=${taskTree.description}`,
88
+ ];
89
+
90
+ for (const subtask of taskTree.subtasks) {
91
+ humanLines.push(` subtask ${subtask.id} | ${subtask.title} | ${subtask.status} | desc=${subtask.description}`);
92
+ }
93
+
94
+ return humanLines.join("\n");
95
+ }
96
+
97
+ function formatTaskShowTree(taskTree: {
98
+ id: string;
99
+ epicId: string;
100
+ title: string;
101
+ status: string;
102
+ subtasks: ReadonlyArray<{ id: string; title: string; status: string }>;
103
+ }): string {
104
+ const humanLines: string[] = [`${taskTree.id} | epic=${taskTree.epicId} | ${taskTree.title} | ${taskTree.status}`];
105
+ for (const subtask of taskTree.subtasks) {
106
+ humanLines.push(` subtask ${subtask.id} | ${subtask.title} | ${subtask.status}`);
107
+ }
108
+
109
+ return humanLines.join("\n");
110
+ }
111
+
112
+ function formatTaskShowTable(taskTree: {
113
+ id: string;
114
+ epicId: string;
115
+ title: string;
116
+ description: string;
117
+ status: string;
118
+ subtasks: ReadonlyArray<{ id: string; title: string; description: string; status: string }>;
119
+ }): string {
120
+ const sections: string[] = [];
121
+ sections.push("TASK");
122
+ sections.push(
123
+ formatHumanTable(
124
+ ["ID", "EPIC", "TITLE", "STATUS", "DESCRIPTION"],
125
+ [[taskTree.id, taskTree.epicId, taskTree.title, taskTree.status, taskTree.description]],
126
+ { wrapColumns: [2, 4] },
127
+ ),
128
+ );
129
+
130
+ if (taskTree.subtasks.length === 0) {
131
+ sections.push("\nSUBTASKS\nNo subtasks found.");
132
+ return sections.join("\n");
133
+ }
134
+
135
+ sections.push("\nSUBTASKS");
136
+ sections.push(
137
+ formatHumanTable(
138
+ ["ID", "TITLE", "STATUS", "DESCRIPTION"],
139
+ taskTree.subtasks.map((subtask) => [subtask.id, subtask.title, subtask.status, subtask.description]),
140
+ { wrapColumns: [1, 3] },
141
+ ),
142
+ );
143
+ return sections.join("\n");
144
+ }
145
+
146
+ function failFromError(error: unknown): CliResult {
147
+ if (error instanceof DomainError) {
148
+ return failResult({
149
+ command: "task",
150
+ human: error.message,
151
+ data: {
152
+ code: error.code,
153
+ ...(error.details ?? {}),
154
+ },
155
+ error: {
156
+ code: error.code,
157
+ message: error.message,
158
+ },
159
+ });
160
+ }
161
+
162
+ return failResult({
163
+ command: "task",
164
+ human: "Unexpected task command failure",
165
+ data: {},
166
+ error: {
167
+ code: "internal_error",
168
+ message: "Unexpected task command failure",
169
+ },
170
+ });
171
+ }
172
+
173
+ function failMissingOptionValue(command: string, option: string): CliResult {
174
+ return failResult({
175
+ command,
176
+ human: `Option --${option} requires a value.`,
177
+ data: {
178
+ code: "invalid_input",
179
+ option,
180
+ },
181
+ error: {
182
+ code: "invalid_input",
183
+ message: `Option --${option} requires a value`,
184
+ },
185
+ });
186
+ }
187
+
188
+ export async function runTask(context: CliContext): Promise<CliResult> {
189
+ const database = openTrekoonDatabase(context.cwd);
190
+
191
+ try {
192
+ const parsed = parseArgs(context.args);
193
+ const subcommand: string | undefined = parsed.positional[0];
194
+ const domain = new TrackerDomain(database.db);
195
+
196
+ switch (subcommand) {
197
+ case "create": {
198
+ const missingCreateOption =
199
+ readMissingOptionValue(parsed.missingOptionValues, "epic", "e") ??
200
+ readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
201
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s");
202
+ if (missingCreateOption !== undefined) {
203
+ return failMissingOptionValue("task.create", missingCreateOption);
204
+ }
205
+
206
+ const epicId: string | undefined = readOption(parsed.options, "epic", "e");
207
+ const title: string | undefined = readOption(parsed.options, "title", "t");
208
+ const description: string | undefined = readOption(parsed.options, "description", "d");
209
+ const status: string | undefined = readOption(parsed.options, "status", "s");
210
+ const task = domain.createTask({
211
+ epicId: epicId ?? "",
212
+ title: title ?? "",
213
+ description: description ?? "",
214
+ status,
215
+ });
216
+
217
+ return okResult({
218
+ command: "task.create",
219
+ human: `Created task ${formatTask(task)}`,
220
+ data: { task },
221
+ });
222
+ }
223
+ case "list": {
224
+ const missingListOption =
225
+ readMissingOptionValue(parsed.missingOptionValues, "view") ??
226
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
227
+ readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
228
+ readMissingOptionValue(parsed.missingOptionValues, "epic", "e");
229
+ if (missingListOption !== undefined) {
230
+ return failMissingOptionValue("task.list", missingListOption);
231
+ }
232
+
233
+ const rawView: string | undefined = readOption(parsed.options, "view");
234
+ const view = readEnumOption(parsed.options, VIEW_MODES, "view");
235
+ const includeAll = hasFlag(parsed.flags, "all");
236
+ const rawStatuses = readOption(parsed.options, "status", "s");
237
+ const rawLimit = readOption(parsed.options, "limit", "l");
238
+
239
+ if (rawView !== undefined && view === undefined) {
240
+ return failResult({
241
+ command: "task.list",
242
+ human: "Invalid --view value. Use: table, compact",
243
+ data: { view: rawView, allowedViews: LIST_VIEW_MODES },
244
+ error: {
245
+ code: "invalid_input",
246
+ message: "Invalid --view value",
247
+ },
248
+ });
249
+ }
250
+
251
+ if (view !== undefined && view !== "table" && view !== "compact") {
252
+ return failResult({
253
+ command: "task.list",
254
+ human: "Invalid --view for task list. Use: table, compact",
255
+ data: { view, allowedViews: LIST_VIEW_MODES },
256
+ error: {
257
+ code: "invalid_input",
258
+ message: "Invalid --view for task list",
259
+ },
260
+ });
261
+ }
262
+
263
+ if (includeAll && rawStatuses !== undefined) {
264
+ return failResult({
265
+ command: "task.list",
266
+ human: "Use either --all or --status, not both.",
267
+ data: { code: "invalid_input", flags: ["all", "status"] },
268
+ error: {
269
+ code: "invalid_input",
270
+ message: "--all and --status are mutually exclusive",
271
+ },
272
+ });
273
+ }
274
+
275
+ if (includeAll && rawLimit !== undefined) {
276
+ return failResult({
277
+ command: "task.list",
278
+ human: "Use either --all or --limit, not both.",
279
+ data: { code: "invalid_input", flags: ["all", "limit"] },
280
+ error: {
281
+ code: "invalid_input",
282
+ message: "--all and --limit are mutually exclusive",
283
+ },
284
+ });
285
+ }
286
+
287
+ const statuses = parseStatusCsv(rawStatuses);
288
+ if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
289
+ return failResult({
290
+ command: "task.list",
291
+ human: "Provide at least one status with --status.",
292
+ data: { code: "invalid_input", status: rawStatuses },
293
+ error: {
294
+ code: "invalid_input",
295
+ message: "Invalid --status value",
296
+ },
297
+ });
298
+ }
299
+
300
+ const parsedLimit = parseStrictPositiveInt(rawLimit);
301
+ if (Number.isNaN(parsedLimit)) {
302
+ return failResult({
303
+ command: "task.list",
304
+ human: "Invalid --limit value. Use an integer >= 1.",
305
+ data: { code: "invalid_input", limit: rawLimit },
306
+ error: {
307
+ code: "invalid_input",
308
+ message: "Invalid --limit value",
309
+ },
310
+ });
311
+ }
312
+
313
+ const epicId: string | undefined = readOption(parsed.options, "epic", "e");
314
+ const selectedStatuses = includeAll
315
+ ? undefined
316
+ : statuses ?? [...DEFAULT_OPEN_TASK_STATUSES];
317
+ const selectedLimit = includeAll
318
+ ? undefined
319
+ : parsedLimit ?? DEFAULT_TASK_LIST_LIMIT;
320
+ const tasks = filterSortAndLimitTasks(domain.listTasks(epicId), selectedStatuses, selectedLimit);
321
+ const listView = view ?? "table";
322
+ const human = tasks.length === 0 ? "No tasks found." : listView === "compact" ? tasks.map(formatTask).join("\n") : formatTaskListTable(tasks);
323
+
324
+ return okResult({
325
+ command: "task.list",
326
+ human,
327
+ data: { tasks },
328
+ });
329
+ }
330
+ case "show": {
331
+ const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
332
+ if (missingShowOption !== undefined) {
333
+ return failMissingOptionValue("task.show", missingShowOption);
334
+ }
335
+
336
+ const taskId: string = parsed.positional[1] ?? "";
337
+ const includeAll: boolean = hasFlag(parsed.flags, "all");
338
+ const rawView: string | undefined = readOption(parsed.options, "view");
339
+ const view = readEnumOption(parsed.options, VIEW_MODES, "view");
340
+ if (rawView !== undefined && view === undefined) {
341
+ return failResult({
342
+ command: "task.show",
343
+ human: "Invalid --view value. Use: table, compact, tree, detail",
344
+ data: { view: rawView, allowedViews: VIEW_MODES },
345
+ error: {
346
+ code: "invalid_input",
347
+ message: "Invalid --view value",
348
+ },
349
+ });
350
+ }
351
+
352
+ const existingTask = domain.getTask(taskId);
353
+ if (!existingTask) {
354
+ const matchingEpic = domain.getEpic(taskId);
355
+ if (matchingEpic) {
356
+ return failResult({
357
+ command: "task.show",
358
+ human: `ID belongs to an epic. Use: trekoon epic show ${taskId}`,
359
+ data: {
360
+ code: "wrong_entity_type",
361
+ id: taskId,
362
+ expected: "task",
363
+ actual: "epic",
364
+ hint: `trekoon epic show ${taskId}`,
365
+ },
366
+ error: {
367
+ code: "wrong_entity_type",
368
+ message: `ID belongs to epic '${taskId}', not a task`,
369
+ },
370
+ });
371
+ }
372
+ }
373
+
374
+ const effectiveView = view ?? (context.mode === "human" ? "table" : includeAll ? "detail" : "tree");
375
+
376
+ if (effectiveView === "compact") {
377
+ const task = existingTask ?? domain.getTaskOrThrow(taskId);
378
+
379
+ return okResult({
380
+ command: "task.show",
381
+ human: formatTask(task),
382
+ data: { task, includeAll: false },
383
+ });
384
+ }
385
+
386
+ const taskTree = domain.buildTaskTreeDetailed(taskId);
387
+
388
+ if (effectiveView === "tree") {
389
+ return okResult({
390
+ command: "task.show",
391
+ human: formatTaskShowTree(taskTree),
392
+ data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
393
+ });
394
+ }
395
+
396
+ return okResult({
397
+ command: "task.show",
398
+ human: effectiveView === "table" ? formatTaskShowTable(taskTree) : formatTaskShowDetail(taskTree),
399
+ data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
400
+ });
401
+ }
402
+ case "update": {
403
+ const missingUpdateOption =
404
+ readMissingOptionValue(parsed.missingOptionValues, "ids") ??
405
+ readMissingOptionValue(parsed.missingOptionValues, "append") ??
406
+ readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
407
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s");
408
+ if (missingUpdateOption !== undefined) {
409
+ return failMissingOptionValue("task.update", missingUpdateOption);
410
+ }
411
+
412
+ const taskId: string = parsed.positional[1] ?? "";
413
+ const updateAll: boolean = hasFlag(parsed.flags, "all");
414
+ const rawIds: string | undefined = readOption(parsed.options, "ids");
415
+ const ids = parseIdsOption(rawIds);
416
+ const title: string | undefined = readOption(parsed.options, "title", "t");
417
+ const description: string | undefined = readOption(parsed.options, "description", "d");
418
+ const append: string | undefined = readOption(parsed.options, "append");
419
+ const status: string | undefined = readOption(parsed.options, "status", "s");
420
+
421
+ if (updateAll && ids.length > 0) {
422
+ return failResult({
423
+ command: "task.update",
424
+ human: "Use either --all or --ids, not both.",
425
+ data: { code: "invalid_input", target: ["all", "ids"] },
426
+ error: {
427
+ code: "invalid_input",
428
+ message: "--all and --ids are mutually exclusive",
429
+ },
430
+ });
431
+ }
432
+
433
+ if (append !== undefined && description !== undefined) {
434
+ return failResult({
435
+ command: "task.update",
436
+ human: "Use either --append or --description, not both.",
437
+ data: { code: "invalid_input", fields: ["append", "description"] },
438
+ error: {
439
+ code: "invalid_input",
440
+ message: "--append and --description are mutually exclusive",
441
+ },
442
+ });
443
+ }
444
+
445
+ const hasBulkTarget = updateAll || ids.length > 0;
446
+ if (hasBulkTarget) {
447
+ if (taskId.length > 0) {
448
+ return failResult({
449
+ command: "task.update",
450
+ human: "Do not pass a task id when using --all or --ids.",
451
+ data: { code: "invalid_input", id: taskId },
452
+ error: {
453
+ code: "invalid_input",
454
+ message: "Positional id is not allowed with --all/--ids",
455
+ },
456
+ });
457
+ }
458
+
459
+ if (title !== undefined || description !== undefined) {
460
+ return failResult({
461
+ command: "task.update",
462
+ human: "Bulk update supports only --append and/or --status.",
463
+ data: { code: "invalid_input" },
464
+ error: {
465
+ code: "invalid_input",
466
+ message: "Bulk update supports only --append and --status",
467
+ },
468
+ });
469
+ }
470
+
471
+ if (append === undefined && status === undefined) {
472
+ return failResult({
473
+ command: "task.update",
474
+ human: "Bulk update requires --append and/or --status.",
475
+ data: { code: "invalid_input" },
476
+ error: {
477
+ code: "invalid_input",
478
+ message: "Missing bulk update fields",
479
+ },
480
+ });
481
+ }
482
+
483
+ const targets = updateAll ? [...domain.listTasks()] : ids.map((id) => domain.getTaskOrThrow(id));
484
+ const tasks = targets.map((target) =>
485
+ domain.updateTask(target.id, {
486
+ status,
487
+ description: append === undefined ? undefined : appendLine(target.description, append),
488
+ }),
489
+ );
490
+
491
+ return okResult({
492
+ command: "task.update",
493
+ human: `Updated ${tasks.length} task(s)`,
494
+ data: {
495
+ tasks,
496
+ target: updateAll ? "all" : "ids",
497
+ ids: tasks.map((task) => task.id),
498
+ },
499
+ });
500
+ }
501
+
502
+ if (taskId.length === 0) {
503
+ return failResult({
504
+ command: "task.update",
505
+ human: "Provide a task id, or use --all/--ids for bulk update.",
506
+ data: { code: "invalid_input" },
507
+ error: {
508
+ code: "invalid_input",
509
+ message: "Missing task id",
510
+ },
511
+ });
512
+ }
513
+
514
+ const nextDescription =
515
+ append === undefined
516
+ ? description
517
+ : appendLine(domain.getTaskOrThrow(taskId).description, append);
518
+ const task = domain.updateTask(taskId, { title, description: nextDescription, status });
519
+
520
+ return okResult({
521
+ command: "task.update",
522
+ human: `Updated task ${formatTask(task)}`,
523
+ data: { task },
524
+ });
525
+ }
526
+ case "delete": {
527
+ const taskId: string = parsed.positional[1] ?? "";
528
+ domain.deleteTask(taskId);
529
+
530
+ return okResult({
531
+ command: "task.delete",
532
+ human: `Deleted task ${taskId}`,
533
+ data: { id: taskId },
534
+ });
535
+ }
536
+ default:
537
+ return failResult({
538
+ command: "task",
539
+ human: "Usage: trekoon task <create|list|show|update|delete>",
540
+ data: {
541
+ args: context.args,
542
+ },
543
+ error: {
544
+ code: "invalid_subcommand",
545
+ message: "Invalid task subcommand",
546
+ },
547
+ });
548
+ }
549
+ } catch (error: unknown) {
550
+ return failFromError(error);
551
+ } finally {
552
+ database.close();
553
+ }
554
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+
3
+ import { failResult, okResult } from "../io/output";
4
+ import { type CliContext, type CliResult } from "../runtime/command-types";
5
+ import { resolveStoragePaths } from "../storage/path";
6
+
7
+ export async function runWipe(context: CliContext): Promise<CliResult> {
8
+ const confirmed: boolean = context.args.includes("--yes");
9
+
10
+ if (!confirmed) {
11
+ return failResult({
12
+ command: "wipe",
13
+ human: "Refusing to wipe local state without --yes.",
14
+ data: {
15
+ confirmed,
16
+ },
17
+ error: {
18
+ code: "confirmation_required",
19
+ message: "Wipe requires --yes",
20
+ },
21
+ });
22
+ }
23
+
24
+ const paths = resolveStoragePaths(context.cwd);
25
+ const existed: boolean = existsSync(paths.storageDir);
26
+
27
+ rmSync(paths.storageDir, { recursive: true, force: true });
28
+
29
+ return okResult({
30
+ command: "wipe",
31
+ human: existed
32
+ ? `Removed local Trekoon state at ${paths.storageDir}`
33
+ : `No local Trekoon state found at ${paths.storageDir}`,
34
+ data: {
35
+ storageDir: paths.storageDir,
36
+ wiped: existed,
37
+ },
38
+ });
39
+ }