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,576 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { Database } from "bun:sqlite";
4
+
5
+ import {
6
+ type DependencyRecord,
7
+ DomainError,
8
+ type EpicTreeDetailed,
9
+ type EpicRecord,
10
+ type EpicTree,
11
+ type NodeKind,
12
+ type SubtaskRecord,
13
+ type TaskTreeDetailed,
14
+ type TaskRecord,
15
+ } from "./types";
16
+
17
+ const DEFAULT_STATUS = "todo";
18
+
19
+ interface EpicRow {
20
+ id: string;
21
+ title: string;
22
+ description: string;
23
+ status: string;
24
+ created_at: number;
25
+ updated_at: number;
26
+ }
27
+
28
+ interface TaskRow extends EpicRow {
29
+ epic_id: string;
30
+ }
31
+
32
+ interface SubtaskRow extends EpicRow {
33
+ task_id: string;
34
+ }
35
+
36
+ interface DependencyRow {
37
+ id: string;
38
+ source_id: string;
39
+ source_kind: "task" | "subtask";
40
+ depends_on_id: string;
41
+ depends_on_kind: "task" | "subtask";
42
+ created_at: number;
43
+ updated_at: number;
44
+ }
45
+
46
+ function assertNonEmpty(field: string, value: string | undefined | null): string {
47
+ const normalized: string = (value ?? "").trim();
48
+ if (!normalized) {
49
+ throw new DomainError({
50
+ code: "invalid_input",
51
+ message: `${field} must be a non-empty string`,
52
+ details: { field },
53
+ });
54
+ }
55
+
56
+ return normalized;
57
+ }
58
+
59
+ function normalizeStatus(value: string | undefined): string {
60
+ if (value === undefined) {
61
+ return DEFAULT_STATUS;
62
+ }
63
+
64
+ return assertNonEmpty("status", value);
65
+ }
66
+
67
+ function mapEpic(row: EpicRow): EpicRecord {
68
+ return {
69
+ id: row.id,
70
+ title: row.title,
71
+ description: row.description,
72
+ status: row.status,
73
+ createdAt: row.created_at,
74
+ updatedAt: row.updated_at,
75
+ };
76
+ }
77
+
78
+ function mapTask(row: TaskRow): TaskRecord {
79
+ return {
80
+ id: row.id,
81
+ epicId: row.epic_id,
82
+ title: row.title,
83
+ description: row.description,
84
+ status: row.status,
85
+ createdAt: row.created_at,
86
+ updatedAt: row.updated_at,
87
+ };
88
+ }
89
+
90
+ function mapSubtask(row: SubtaskRow): SubtaskRecord {
91
+ return {
92
+ id: row.id,
93
+ taskId: row.task_id,
94
+ title: row.title,
95
+ description: row.description,
96
+ status: row.status,
97
+ createdAt: row.created_at,
98
+ updatedAt: row.updated_at,
99
+ };
100
+ }
101
+
102
+ function mapDependency(row: DependencyRow): DependencyRecord {
103
+ return {
104
+ id: row.id,
105
+ sourceId: row.source_id,
106
+ sourceKind: row.source_kind,
107
+ dependsOnId: row.depends_on_id,
108
+ dependsOnKind: row.depends_on_kind,
109
+ createdAt: row.created_at,
110
+ updatedAt: row.updated_at,
111
+ };
112
+ }
113
+
114
+ export class TrackerDomain {
115
+ readonly #db: Database;
116
+
117
+ constructor(db: Database) {
118
+ this.#db = db;
119
+ }
120
+
121
+ createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
122
+ const now: number = Date.now();
123
+ const id: string = randomUUID();
124
+ const title: string = assertNonEmpty("title", input.title);
125
+ const description: string = assertNonEmpty("description", input.description);
126
+ const status: string = normalizeStatus(input.status);
127
+
128
+ this.#db
129
+ .query(
130
+ "INSERT INTO epics (id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, 1);",
131
+ )
132
+ .run(id, title, description, status, now, now);
133
+
134
+ return this.getEpicOrThrow(id);
135
+ }
136
+
137
+ listEpics(): readonly EpicRecord[] {
138
+ const rows = this.#db
139
+ .query("SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC;")
140
+ .all() as EpicRow[];
141
+ return rows.map(mapEpic);
142
+ }
143
+
144
+ getEpic(id: string): EpicRecord | null {
145
+ const row = this.#db
146
+ .query("SELECT id, title, description, status, created_at, updated_at FROM epics WHERE id = ?;")
147
+ .get(id) as EpicRow | null;
148
+ return row ? mapEpic(row) : null;
149
+ }
150
+
151
+ getEpicOrThrow(id: string): EpicRecord {
152
+ const epic: EpicRecord | null = this.getEpic(id);
153
+ if (!epic) {
154
+ throw new DomainError({
155
+ code: "not_found",
156
+ message: `epic not found: ${id}`,
157
+ details: { entity: "epic", id },
158
+ });
159
+ }
160
+
161
+ return epic;
162
+ }
163
+
164
+ updateEpic(
165
+ id: string,
166
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
167
+ ): EpicRecord {
168
+ const existing: EpicRecord = this.getEpicOrThrow(id);
169
+ const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
170
+ const nextDescription: string =
171
+ input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
172
+ const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
173
+ const now: number = Date.now();
174
+
175
+ this.#db
176
+ .query("UPDATE epics SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
177
+ .run(nextTitle, nextDescription, nextStatus, now, id);
178
+
179
+ return this.getEpicOrThrow(id);
180
+ }
181
+
182
+ deleteEpic(id: string): void {
183
+ this.getEpicOrThrow(id);
184
+ this.#db.query("DELETE FROM epics WHERE id = ?;").run(id);
185
+ }
186
+
187
+ createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
188
+ const now: number = Date.now();
189
+ const id: string = randomUUID();
190
+ const epicId: string = assertNonEmpty("epicId", input.epicId);
191
+ const title: string = assertNonEmpty("title", input.title);
192
+ const description: string = assertNonEmpty("description", input.description);
193
+ const status: string = normalizeStatus(input.status);
194
+
195
+ this.getEpicOrThrow(epicId);
196
+
197
+ this.#db
198
+ .query(
199
+ "INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
200
+ )
201
+ .run(id, epicId, title, description, status, now, now);
202
+
203
+ return this.getTaskOrThrow(id);
204
+ }
205
+
206
+ listTasks(epicId?: string): readonly TaskRecord[] {
207
+ if (epicId) {
208
+ this.getEpicOrThrow(epicId);
209
+ const rows = this.#db
210
+ .query(
211
+ "SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC;",
212
+ )
213
+ .all(epicId) as TaskRow[];
214
+ return rows.map(mapTask);
215
+ }
216
+
217
+ const rows = this.#db
218
+ .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC;")
219
+ .all() as TaskRow[];
220
+ return rows.map(mapTask);
221
+ }
222
+
223
+ getTask(id: string): TaskRecord | null {
224
+ const row = this.#db
225
+ .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?;")
226
+ .get(id) as TaskRow | null;
227
+ return row ? mapTask(row) : null;
228
+ }
229
+
230
+ getTaskOrThrow(id: string): TaskRecord {
231
+ const task: TaskRecord | null = this.getTask(id);
232
+ if (!task) {
233
+ throw new DomainError({
234
+ code: "not_found",
235
+ message: `task not found: ${id}`,
236
+ details: { entity: "task", id },
237
+ });
238
+ }
239
+
240
+ return task;
241
+ }
242
+
243
+ updateTask(
244
+ id: string,
245
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
246
+ ): TaskRecord {
247
+ const existing: TaskRecord = this.getTaskOrThrow(id);
248
+ const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
249
+ const nextDescription: string =
250
+ input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
251
+ const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
252
+ const now: number = Date.now();
253
+
254
+ this.#db
255
+ .query("UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
256
+ .run(nextTitle, nextDescription, nextStatus, now, id);
257
+
258
+ return this.getTaskOrThrow(id);
259
+ }
260
+
261
+ deleteTask(id: string): void {
262
+ this.getTaskOrThrow(id);
263
+ this.#db.query("DELETE FROM tasks WHERE id = ?;").run(id);
264
+ }
265
+
266
+ createSubtask(
267
+ input: { taskId: string; title: string; description?: string | undefined; status?: string | undefined },
268
+ ): SubtaskRecord {
269
+ const now: number = Date.now();
270
+ const id: string = randomUUID();
271
+ const taskId: string = assertNonEmpty("taskId", input.taskId);
272
+ const title: string = assertNonEmpty("title", input.title);
273
+ const description: string = input.description === undefined ? "" : assertNonEmpty("description", input.description);
274
+ const status: string = normalizeStatus(input.status);
275
+
276
+ this.getTaskOrThrow(taskId);
277
+
278
+ this.#db
279
+ .query(
280
+ "INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
281
+ )
282
+ .run(id, taskId, title, description, status, now, now);
283
+
284
+ return this.getSubtaskOrThrow(id);
285
+ }
286
+
287
+ listSubtasks(taskId?: string): readonly SubtaskRecord[] {
288
+ if (taskId) {
289
+ this.getTaskOrThrow(taskId);
290
+ const rows = this.#db
291
+ .query(
292
+ "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC;",
293
+ )
294
+ .all(taskId) as SubtaskRow[];
295
+ return rows.map(mapSubtask);
296
+ }
297
+
298
+ const rows = this.#db
299
+ .query(
300
+ "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC;",
301
+ )
302
+ .all() as SubtaskRow[];
303
+ return rows.map(mapSubtask);
304
+ }
305
+
306
+ getSubtask(id: string): SubtaskRecord | null {
307
+ const row = this.#db
308
+ .query("SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE id = ?;")
309
+ .get(id) as SubtaskRow | null;
310
+ return row ? mapSubtask(row) : null;
311
+ }
312
+
313
+ getSubtaskOrThrow(id: string): SubtaskRecord {
314
+ const subtask: SubtaskRecord | null = this.getSubtask(id);
315
+ if (!subtask) {
316
+ throw new DomainError({
317
+ code: "not_found",
318
+ message: `subtask not found: ${id}`,
319
+ details: { entity: "subtask", id },
320
+ });
321
+ }
322
+
323
+ return subtask;
324
+ }
325
+
326
+ updateSubtask(
327
+ id: string,
328
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
329
+ ): SubtaskRecord {
330
+ const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
331
+ const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
332
+ const nextDescription: string =
333
+ input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
334
+ const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
335
+ const now: number = Date.now();
336
+
337
+ this.#db
338
+ .query("UPDATE subtasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
339
+ .run(nextTitle, nextDescription, nextStatus, now, id);
340
+
341
+ return this.getSubtaskOrThrow(id);
342
+ }
343
+
344
+ deleteSubtask(id: string): void {
345
+ this.getSubtaskOrThrow(id);
346
+ this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
347
+ }
348
+
349
+ buildEpicTree(epicId: string): EpicTree {
350
+ const epic: EpicRecord = this.getEpicOrThrow(epicId);
351
+ const tasks: readonly TaskRecord[] = this.listTasks(epicId);
352
+ const taskIds = new Set(tasks.map((task) => task.id));
353
+ const subtasks = this.#db
354
+ .query(
355
+ "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC;",
356
+ )
357
+ .all(epicId) as SubtaskRow[];
358
+
359
+ const subtasksByTask = new Map<string, SubtaskRecord[]>();
360
+ for (const row of subtasks) {
361
+ if (!taskIds.has(row.task_id)) {
362
+ continue;
363
+ }
364
+ const mapped: SubtaskRecord = mapSubtask(row);
365
+ const existing = subtasksByTask.get(mapped.taskId) ?? [];
366
+ existing.push(mapped);
367
+ subtasksByTask.set(mapped.taskId, existing);
368
+ }
369
+
370
+ return {
371
+ id: epic.id,
372
+ title: epic.title,
373
+ status: epic.status,
374
+ tasks: tasks.map((task) => ({
375
+ id: task.id,
376
+ title: task.title,
377
+ status: task.status,
378
+ subtasks: (subtasksByTask.get(task.id) ?? []).map((subtask) => ({
379
+ id: subtask.id,
380
+ title: subtask.title,
381
+ status: subtask.status,
382
+ })),
383
+ })),
384
+ };
385
+ }
386
+
387
+ buildTaskTreeDetailed(taskId: string): TaskTreeDetailed {
388
+ const task: TaskRecord = this.getTaskOrThrow(taskId);
389
+ const subtasks: readonly SubtaskRecord[] = this.listSubtasks(task.id);
390
+
391
+ return {
392
+ id: task.id,
393
+ epicId: task.epicId,
394
+ title: task.title,
395
+ description: task.description,
396
+ status: task.status,
397
+ subtasks: subtasks.map((subtask) => ({
398
+ id: subtask.id,
399
+ taskId: subtask.taskId,
400
+ title: subtask.title,
401
+ description: subtask.description,
402
+ status: subtask.status,
403
+ })),
404
+ };
405
+ }
406
+
407
+ buildEpicTreeDetailed(epicId: string): EpicTreeDetailed {
408
+ const epic: EpicRecord = this.getEpicOrThrow(epicId);
409
+ const tasks: readonly TaskRecord[] = this.listTasks(epic.id);
410
+
411
+ return {
412
+ id: epic.id,
413
+ title: epic.title,
414
+ description: epic.description,
415
+ status: epic.status,
416
+ tasks: tasks.map((task) => this.buildTaskTreeDetailed(task.id)),
417
+ };
418
+ }
419
+
420
+ resolveNodeKind(id: string): "task" | "subtask" {
421
+ const task = this.getTask(id);
422
+ if (task) {
423
+ return "task";
424
+ }
425
+
426
+ const subtask = this.getSubtask(id);
427
+ if (subtask) {
428
+ return "subtask";
429
+ }
430
+
431
+ throw new DomainError({
432
+ code: "not_found",
433
+ message: `node not found: ${id}`,
434
+ details: { id, expectedKinds: ["task", "subtask"] },
435
+ });
436
+ }
437
+
438
+ addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
439
+ const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
440
+ const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
441
+
442
+ if (normalizedSourceId === normalizedDependsOnId) {
443
+ throw new DomainError({
444
+ code: "invalid_dependency",
445
+ message: "dependency cycle detected",
446
+ details: { sourceId: normalizedSourceId, dependsOnId: normalizedDependsOnId },
447
+ });
448
+ }
449
+
450
+ const sourceKind = this.resolveNodeKind(normalizedSourceId);
451
+ const dependsOnKind = this.resolveNodeKind(normalizedDependsOnId);
452
+
453
+ const existing = this.#db
454
+ .query(
455
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
456
+ )
457
+ .get(normalizedSourceId, normalizedDependsOnId) as DependencyRow | null;
458
+
459
+ if (existing) {
460
+ return mapDependency(existing);
461
+ }
462
+
463
+ if (this.wouldCreateCycle(normalizedSourceId, normalizedDependsOnId)) {
464
+ throw new DomainError({
465
+ code: "invalid_dependency",
466
+ message: "dependency cycle detected",
467
+ details: { sourceId: normalizedSourceId, dependsOnId: normalizedDependsOnId },
468
+ });
469
+ }
470
+
471
+ const id: string = randomUUID();
472
+ const now: number = Date.now();
473
+
474
+ this.#db
475
+ .query(
476
+ "INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
477
+ )
478
+ .run(id, normalizedSourceId, sourceKind, normalizedDependsOnId, dependsOnKind, now, now);
479
+
480
+ return this.getDependencyOrThrow(id);
481
+ }
482
+
483
+ removeDependency(sourceId: string, dependsOnId: string): number {
484
+ const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
485
+ const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
486
+ const result = this.#db
487
+ .query("DELETE FROM dependencies WHERE source_id = ? AND depends_on_id = ?;")
488
+ .run(normalizedSourceId, normalizedDependsOnId);
489
+
490
+ return result.changes;
491
+ }
492
+
493
+ listDependencies(sourceId: string): readonly DependencyRecord[] {
494
+ const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
495
+ this.resolveNodeKind(normalizedSourceId);
496
+
497
+ const rows = this.#db
498
+ .query(
499
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? ORDER BY created_at ASC;",
500
+ )
501
+ .all(normalizedSourceId) as DependencyRow[];
502
+
503
+ return rows.map(mapDependency);
504
+ }
505
+
506
+ private getDependencyOrThrow(id: string): DependencyRecord {
507
+ const row = this.#db
508
+ .query(
509
+ "SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE id = ?;",
510
+ )
511
+ .get(id) as DependencyRow | null;
512
+
513
+ if (!row) {
514
+ throw new DomainError({
515
+ code: "not_found",
516
+ message: `dependency not found: ${id}`,
517
+ details: { entity: "dependency", id },
518
+ });
519
+ }
520
+
521
+ return mapDependency(row);
522
+ }
523
+
524
+ private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
525
+ const adjacency = new Map<string, string[]>();
526
+ const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies;").all() as Array<{
527
+ source_id: string;
528
+ depends_on_id: string;
529
+ }>;
530
+
531
+ for (const row of rows) {
532
+ const existing = adjacency.get(row.source_id) ?? [];
533
+ existing.push(row.depends_on_id);
534
+ adjacency.set(row.source_id, existing);
535
+ }
536
+
537
+ const newEdges = adjacency.get(sourceId) ?? [];
538
+ newEdges.push(dependsOnId);
539
+ adjacency.set(sourceId, newEdges);
540
+
541
+ const visited = new Set<string>();
542
+ const queue: string[] = [dependsOnId];
543
+
544
+ while (queue.length > 0) {
545
+ const next = queue.shift();
546
+ if (!next) {
547
+ continue;
548
+ }
549
+ if (next === sourceId) {
550
+ return true;
551
+ }
552
+ if (visited.has(next)) {
553
+ continue;
554
+ }
555
+ visited.add(next);
556
+ const outgoing = adjacency.get(next) ?? [];
557
+ for (const neighbor of outgoing) {
558
+ queue.push(neighbor);
559
+ }
560
+ }
561
+
562
+ return false;
563
+ }
564
+ }
565
+
566
+ export function parseNodeKind(kind: string): NodeKind {
567
+ if (kind === "epic" || kind === "task" || kind === "subtask") {
568
+ return kind;
569
+ }
570
+
571
+ throw new DomainError({
572
+ code: "invalid_input",
573
+ message: `unsupported node kind: ${kind}`,
574
+ details: { kind },
575
+ });
576
+ }
@@ -0,0 +1,99 @@
1
+ export type NodeKind = "epic" | "task" | "subtask";
2
+
3
+ export interface EpicRecord {
4
+ readonly id: string;
5
+ readonly title: string;
6
+ readonly description: string;
7
+ readonly status: string;
8
+ readonly createdAt: number;
9
+ readonly updatedAt: number;
10
+ }
11
+
12
+ export interface TaskRecord {
13
+ readonly id: string;
14
+ readonly epicId: string;
15
+ readonly title: string;
16
+ readonly description: string;
17
+ readonly status: string;
18
+ readonly createdAt: number;
19
+ readonly updatedAt: number;
20
+ }
21
+
22
+ export interface SubtaskRecord {
23
+ readonly id: string;
24
+ readonly taskId: string;
25
+ readonly title: string;
26
+ readonly description: string;
27
+ readonly status: string;
28
+ readonly createdAt: number;
29
+ readonly updatedAt: number;
30
+ }
31
+
32
+ export interface DependencyRecord {
33
+ readonly id: string;
34
+ readonly sourceId: string;
35
+ readonly sourceKind: Extract<NodeKind, "task" | "subtask">;
36
+ readonly dependsOnId: string;
37
+ readonly dependsOnKind: Extract<NodeKind, "task" | "subtask">;
38
+ readonly createdAt: number;
39
+ readonly updatedAt: number;
40
+ }
41
+
42
+ export interface EpicTree {
43
+ readonly id: string;
44
+ readonly title: string;
45
+ readonly status: string;
46
+ readonly tasks: ReadonlyArray<{
47
+ readonly id: string;
48
+ readonly title: string;
49
+ readonly status: string;
50
+ readonly subtasks: ReadonlyArray<{
51
+ readonly id: string;
52
+ readonly title: string;
53
+ readonly status: string;
54
+ }>;
55
+ }>;
56
+ }
57
+
58
+ export interface TaskTreeDetailed {
59
+ readonly id: string;
60
+ readonly epicId: string;
61
+ readonly title: string;
62
+ readonly description: string;
63
+ readonly status: string;
64
+ readonly subtasks: ReadonlyArray<{
65
+ readonly id: string;
66
+ readonly taskId: string;
67
+ readonly title: string;
68
+ readonly description: string;
69
+ readonly status: string;
70
+ }>;
71
+ }
72
+
73
+ export interface EpicTreeDetailed {
74
+ readonly id: string;
75
+ readonly title: string;
76
+ readonly description: string;
77
+ readonly status: string;
78
+ readonly tasks: ReadonlyArray<TaskTreeDetailed>;
79
+ }
80
+
81
+ export interface DomainErrorShape {
82
+ readonly code: string;
83
+ readonly message: string;
84
+ readonly details?: Record<string, unknown>;
85
+ }
86
+
87
+ export class DomainError extends Error {
88
+ readonly code: string;
89
+ readonly details?: Record<string, unknown>;
90
+
91
+ constructor(input: DomainErrorShape) {
92
+ super(input.message);
93
+ this.name = "DomainError";
94
+ this.code = input.code;
95
+ if (input.details !== undefined) {
96
+ this.details = input.details;
97
+ }
98
+ }
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { executeShell, parseInvocation, renderShellResult } from "./runtime/cli-shell";
4
+
5
+ export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
6
+ const parsed = parseInvocation(argv);
7
+ const result = await executeShell(parsed);
8
+ const rendered: string = renderShellResult(result, parsed.mode);
9
+
10
+ if (result.ok) {
11
+ process.stdout.write(`${rendered}\n`);
12
+ return;
13
+ }
14
+
15
+ process.stderr.write(`${rendered}\n`);
16
+ process.exitCode = 1;
17
+ }
18
+
19
+ if (import.meta.main) {
20
+ await run();
21
+ }