trekoon 0.3.7 → 0.3.9

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.
@@ -1,7 +1,7 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
3
  import { writeTransaction } from "../storage/database";
4
- import { appendEventWithGitContext, withTransactionEventContext } from "../sync/event-writes";
4
+ import { appendEventWithGitContext, prepareEventWriteContext, withTransactionEventContext } from "../sync/event-writes";
5
5
  import { ENTITY_OPERATIONS } from "./mutation-operations";
6
6
  import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
7
7
  import {
@@ -49,6 +49,8 @@ type AtomicIdempotentMutationResult =
49
49
  | AtomicIdempotencyReplayResult
50
50
  | AtomicIdempotencyCompletedResult;
51
51
 
52
+ const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
53
+
52
54
  function countMatches(value: string, searchText: string): number {
53
55
  if (searchText.length === 0) {
54
56
  return 0;
@@ -128,7 +130,49 @@ export class MutationService {
128
130
  }
129
131
 
130
132
  #writeTransaction<T>(fn: () => T): T {
131
- return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, this.#cwd, fn));
133
+ const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
134
+ return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
135
+ }
136
+
137
+ #dependencyEventEntityId(input: {
138
+ sourceId: string;
139
+ sourceKind: string;
140
+ dependsOnId: string;
141
+ dependsOnKind: string;
142
+ }): string {
143
+ return `${input.sourceKind}:${input.sourceId}->${input.dependsOnKind}:${input.dependsOnId}`;
144
+ }
145
+
146
+ #dependencyEventFields(input: {
147
+ dependencyId?: string | undefined;
148
+ sourceId: string;
149
+ sourceKind?: string | undefined;
150
+ dependsOnId: string;
151
+ dependsOnKind?: string | undefined;
152
+ sourceEventId?: string | undefined;
153
+ }): Record<string, string> {
154
+ const fields: Record<string, string> = {
155
+ source_id: input.sourceId,
156
+ depends_on_id: input.dependsOnId,
157
+ };
158
+
159
+ if (input.dependencyId) {
160
+ fields.dependency_id = input.dependencyId;
161
+ }
162
+
163
+ if (input.sourceKind) {
164
+ fields.source_kind = input.sourceKind;
165
+ }
166
+
167
+ if (input.dependsOnKind) {
168
+ fields.depends_on_kind = input.dependsOnKind;
169
+ }
170
+
171
+ if (input.sourceEventId) {
172
+ fields.source_event_id = input.sourceEventId;
173
+ }
174
+
175
+ return fields;
132
176
  }
133
177
 
134
178
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
@@ -185,12 +229,18 @@ export class MutationService {
185
229
  }
186
230
 
187
231
  for (const dependency of created.dependencies) {
188
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
189
- source_id: dependency.sourceId,
190
- source_kind: dependency.sourceKind,
191
- depends_on_id: dependency.dependsOnId,
192
- depends_on_kind: dependency.dependsOnKind,
193
- });
232
+ this.#appendEntityEvent(
233
+ "dependency",
234
+ this.#dependencyEventEntityId(dependency),
235
+ ENTITY_OPERATIONS.dependency.added,
236
+ this.#dependencyEventFields({
237
+ dependencyId: dependency.id,
238
+ sourceId: dependency.sourceId,
239
+ sourceKind: dependency.sourceKind,
240
+ dependsOnId: dependency.dependsOnId,
241
+ dependsOnKind: dependency.dependsOnKind,
242
+ }),
243
+ );
194
244
  }
195
245
 
196
246
  return {
@@ -293,12 +343,18 @@ export class MutationService {
293
343
  }
294
344
 
295
345
  for (const dependency of created.dependencies) {
296
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
297
- source_id: dependency.sourceId,
298
- source_kind: dependency.sourceKind,
299
- depends_on_id: dependency.dependsOnId,
300
- depends_on_kind: dependency.dependsOnKind,
301
- });
346
+ this.#appendEntityEvent(
347
+ "dependency",
348
+ this.#dependencyEventEntityId(dependency),
349
+ ENTITY_OPERATIONS.dependency.added,
350
+ this.#dependencyEventFields({
351
+ dependencyId: dependency.id,
352
+ sourceId: dependency.sourceId,
353
+ sourceKind: dependency.sourceKind,
354
+ dependsOnId: dependency.dependsOnId,
355
+ dependsOnKind: dependency.dependsOnKind,
356
+ }),
357
+ );
302
358
  }
303
359
 
304
360
  return created;
@@ -349,16 +405,19 @@ export class MutationService {
349
405
  }
350
406
 
351
407
  for (const dependency of plan.touchingDependencies) {
352
- this.#appendEntityEvent(
353
- "dependency",
354
- `${dependency.sourceId}->${dependency.dependsOnId}`,
355
- ENTITY_OPERATIONS.dependency.removed,
356
- {
357
- source_id: dependency.sourceId,
358
- depends_on_id: dependency.dependsOnId,
359
- source_event_id: taskDeleteEventId,
360
- },
361
- );
408
+ this.#appendEntityEvent(
409
+ "dependency",
410
+ this.#dependencyEventEntityId(dependency),
411
+ ENTITY_OPERATIONS.dependency.removed,
412
+ this.#dependencyEventFields({
413
+ dependencyId: dependency.id,
414
+ sourceId: dependency.sourceId,
415
+ sourceKind: dependency.sourceKind,
416
+ dependsOnId: dependency.dependsOnId,
417
+ dependsOnKind: dependency.dependsOnKind,
418
+ sourceEventId: taskDeleteEventId,
419
+ }),
420
+ );
362
421
  }
363
422
 
364
423
  return {
@@ -454,13 +513,16 @@ export class MutationService {
454
513
  for (const dependency of touchingDependencies) {
455
514
  this.#appendEntityEvent(
456
515
  "dependency",
457
- `${dependency.sourceId}->${dependency.dependsOnId}`,
516
+ this.#dependencyEventEntityId(dependency),
458
517
  ENTITY_OPERATIONS.dependency.removed,
459
- {
460
- source_id: dependency.sourceId,
461
- depends_on_id: dependency.dependsOnId,
462
- source_event_id: subtaskDeleteEventId,
463
- },
518
+ this.#dependencyEventFields({
519
+ dependencyId: dependency.id,
520
+ sourceId: dependency.sourceId,
521
+ sourceKind: dependency.sourceKind,
522
+ dependsOnId: dependency.dependsOnId,
523
+ dependsOnKind: dependency.dependsOnKind,
524
+ sourceEventId: subtaskDeleteEventId,
525
+ }),
464
526
  );
465
527
  }
466
528
  return {
@@ -489,13 +551,16 @@ export class MutationService {
489
551
  for (const dependency of touchingDependencies) {
490
552
  this.#appendEntityEvent(
491
553
  "dependency",
492
- `${dependency.sourceId}->${dependency.dependsOnId}`,
554
+ this.#dependencyEventEntityId(dependency),
493
555
  ENTITY_OPERATIONS.dependency.removed,
494
- {
495
- source_id: dependency.sourceId,
496
- depends_on_id: dependency.dependsOnId,
497
- source_event_id: subtaskDeleteEventId,
498
- },
556
+ this.#dependencyEventFields({
557
+ dependencyId: dependency.id,
558
+ sourceId: dependency.sourceId,
559
+ sourceKind: dependency.sourceKind,
560
+ dependsOnId: dependency.dependsOnId,
561
+ dependsOnKind: dependency.dependsOnKind,
562
+ sourceEventId: subtaskDeleteEventId,
563
+ }),
499
564
  );
500
565
  }
501
566
 
@@ -516,12 +581,18 @@ export class MutationService {
516
581
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
517
582
  return this.#writeTransaction((): DependencyRecord => {
518
583
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
519
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
520
- source_id: dependency.sourceId,
521
- source_kind: dependency.sourceKind,
522
- depends_on_id: dependency.dependsOnId,
523
- depends_on_kind: dependency.dependsOnKind,
524
- });
584
+ this.#appendEntityEvent(
585
+ "dependency",
586
+ this.#dependencyEventEntityId(dependency),
587
+ ENTITY_OPERATIONS.dependency.added,
588
+ this.#dependencyEventFields({
589
+ dependencyId: dependency.id,
590
+ sourceId: dependency.sourceId,
591
+ sourceKind: dependency.sourceKind,
592
+ dependsOnId: dependency.dependsOnId,
593
+ dependsOnKind: dependency.dependsOnKind,
594
+ }),
595
+ );
525
596
  return dependency;
526
597
  });
527
598
  }
@@ -534,12 +605,18 @@ export class MutationService {
534
605
  }): AtomicIdempotentMutationResult {
535
606
  return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
536
607
  const dependency = this.#domain.addDependency(input.sourceId, input.dependsOnId);
537
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
538
- source_id: dependency.sourceId,
539
- source_kind: dependency.sourceKind,
540
- depends_on_id: dependency.dependsOnId,
541
- depends_on_kind: dependency.dependsOnKind,
542
- });
608
+ this.#appendEntityEvent(
609
+ "dependency",
610
+ this.#dependencyEventEntityId(dependency),
611
+ ENTITY_OPERATIONS.dependency.added,
612
+ this.#dependencyEventFields({
613
+ dependencyId: dependency.id,
614
+ sourceId: dependency.sourceId,
615
+ sourceKind: dependency.sourceKind,
616
+ dependsOnId: dependency.dependsOnId,
617
+ dependsOnKind: dependency.dependsOnKind,
618
+ }),
619
+ );
543
620
  return {
544
621
  state: "completed",
545
622
  status: 201,
@@ -552,12 +629,18 @@ export class MutationService {
552
629
  return this.#writeTransaction((): CompactDependencyBatchAddResult => {
553
630
  const created = this.#domain.addDependencyBatch(input);
554
631
  for (const dependency of created.dependencies) {
555
- this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
556
- source_id: dependency.sourceId,
557
- source_kind: dependency.sourceKind,
558
- depends_on_id: dependency.dependsOnId,
559
- depends_on_kind: dependency.dependsOnKind,
560
- });
632
+ this.#appendEntityEvent(
633
+ "dependency",
634
+ this.#dependencyEventEntityId(dependency),
635
+ ENTITY_OPERATIONS.dependency.added,
636
+ this.#dependencyEventFields({
637
+ dependencyId: dependency.id,
638
+ sourceId: dependency.sourceId,
639
+ sourceKind: dependency.sourceKind,
640
+ dependsOnId: dependency.dependsOnId,
641
+ dependsOnKind: dependency.dependsOnKind,
642
+ }),
643
+ );
561
644
  }
562
645
  return created;
563
646
  });
@@ -565,12 +648,22 @@ export class MutationService {
565
648
 
566
649
  removeDependency(sourceId: string, dependsOnId: string): number {
567
650
  return this.#writeTransaction((): number => {
651
+ const existingDependency = this.#domain.listDependencies(sourceId)
652
+ .find((dependency) => dependency.dependsOnId === dependsOnId);
568
653
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
569
654
  if (removed > 0) {
570
- this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
571
- source_id: sourceId,
572
- depends_on_id: dependsOnId,
573
- });
655
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
656
+ sourceId,
657
+ sourceKind: existingDependency?.sourceKind ?? "task",
658
+ dependsOnId,
659
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
660
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
661
+ dependencyId: existingDependency?.id,
662
+ sourceId,
663
+ sourceKind: existingDependency?.sourceKind,
664
+ dependsOnId,
665
+ dependsOnKind: existingDependency?.dependsOnKind,
666
+ }));
574
667
  }
575
668
  return removed;
576
669
  });
@@ -589,9 +682,10 @@ export class MutationService {
589
682
  }) => Record<string, unknown>;
590
683
  }): AtomicIdempotentMutationResult {
591
684
  return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
592
- const existingDependencyIds = this.#domain.listDependencies(input.sourceId)
593
- .filter((dependency) => dependency.dependsOnId === input.dependsOnId)
594
- .map((dependency) => dependency.id);
685
+ const existingDependencies = this.#domain.listDependencies(input.sourceId)
686
+ .filter((dependency) => dependency.dependsOnId === input.dependsOnId);
687
+ const existingDependencyIds = existingDependencies.map((dependency) => dependency.id);
688
+ const existingDependency = existingDependencies[0];
595
689
  const removed = this.#domain.removeDependency(input.sourceId, input.dependsOnId);
596
690
  if (removed === 0) {
597
691
  throw new DomainError({
@@ -603,10 +697,18 @@ export class MutationService {
603
697
  },
604
698
  });
605
699
  }
606
- this.#appendEntityEvent("dependency", `${input.sourceId}->${input.dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
607
- source_id: input.sourceId,
608
- depends_on_id: input.dependsOnId,
609
- });
700
+ this.#appendEntityEvent("dependency", this.#dependencyEventEntityId({
701
+ sourceId: input.sourceId,
702
+ sourceKind: existingDependency?.sourceKind ?? "task",
703
+ dependsOnId: input.dependsOnId,
704
+ dependsOnKind: existingDependency?.dependsOnKind ?? "task",
705
+ }), ENTITY_OPERATIONS.dependency.removed, this.#dependencyEventFields({
706
+ dependencyId: existingDependency?.id,
707
+ sourceId: input.sourceId,
708
+ sourceKind: existingDependency?.sourceKind,
709
+ dependsOnId: input.dependsOnId,
710
+ dependsOnKind: existingDependency?.dependsOnKind,
711
+ }));
610
712
  return {
611
713
  state: "completed",
612
714
  status: 200,
@@ -741,6 +843,7 @@ export class MutationService {
741
843
  mutate: () => AtomicIdempotencyCompletedResult,
742
844
  ): AtomicIdempotentMutationResult {
743
845
  return this.#writeTransaction((): AtomicIdempotentMutationResult => {
846
+ this.#pruneExpiredIdempotencyKeys();
744
847
  const inserted = this.#db.query(
745
848
  `
746
849
  INSERT INTO board_idempotency_keys (
@@ -816,6 +919,17 @@ export class MutationService {
816
919
  });
817
920
  }
818
921
 
922
+ #pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
923
+ const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
924
+ this.#db.query(
925
+ `
926
+ DELETE FROM board_idempotency_keys
927
+ WHERE state = 'completed'
928
+ AND created_at < ?;
929
+ `,
930
+ ).run(cutoff);
931
+ }
932
+
819
933
  #previewScopeReplacement(
820
934
  nodes: readonly SearchNode[],
821
935
  searchText: string,
@@ -165,6 +165,20 @@ function normalizeSubtaskDescription(value: string | undefined): string {
165
165
  return value.trim();
166
166
  }
167
167
 
168
+ function normalizeOwner(value: string | null | undefined): string | null | undefined {
169
+ if (value === undefined) {
170
+ return undefined;
171
+ }
172
+
173
+ if (value === null) {
174
+ return null;
175
+ }
176
+
177
+ const normalized = value.trim()
178
+ ;
179
+ return normalized.length > 0 ? normalized : null;
180
+ }
181
+
168
182
  function isValidStatus(status: string): status is ValidStatus {
169
183
  return (VALID_STATUSES as readonly string[]).includes(status);
170
184
  }
@@ -526,7 +540,7 @@ export class TrackerDomain {
526
540
  const nextDescription: string =
527
541
  input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
528
542
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
529
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
543
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
530
544
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
531
545
  const now: number = Date.now();
532
546
 
@@ -777,7 +791,7 @@ export class TrackerDomain {
777
791
  const nextDescription: string =
778
792
  input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
779
793
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
780
- const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
794
+ const nextOwner: string | null = input.owner !== undefined ? normalizeOwner(input.owner) ?? null : existing.owner;
781
795
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
782
796
  const now: number = Date.now();
783
797
 
@@ -0,0 +1,178 @@
1
+ import type { TrackerDomain } from "../domain/tracker-domain";
2
+ import type { DependencyRecord, SubtaskRecord, TaskRecord } from "../domain/types";
3
+
4
+ import {
5
+ EXPORT_SCHEMA_VERSION,
6
+ type ExportBundle,
7
+ type ExportDependencyEdge,
8
+ type ExportExternalNode,
9
+ type ExportStatusCounts,
10
+ type ExportSummary,
11
+ type ExportWarning,
12
+ } from "./types";
13
+
14
+ function countStatuses(records: readonly { readonly status: string }[]): ExportStatusCounts {
15
+ const counts = { total: records.length, todo: 0, inProgress: 0, done: 0, blocked: 0, other: 0 };
16
+ for (const record of records) {
17
+ if (record.status === "todo") counts.todo += 1;
18
+ else if (record.status === "in_progress") counts.inProgress += 1;
19
+ else if (record.status === "done") counts.done += 1;
20
+ else if (record.status === "blocked") counts.blocked += 1;
21
+ else counts.other += 1;
22
+ }
23
+ return counts;
24
+ }
25
+
26
+ export function buildEpicExportBundle(domain: TrackerDomain, epicId: string): ExportBundle {
27
+ const epic = domain.getEpicOrThrow(epicId);
28
+ const tasks: readonly TaskRecord[] = domain.listTasks(epicId);
29
+ const taskIds = new Set(tasks.map((t) => t.id));
30
+
31
+ const subtasksByTaskId = domain.listSubtasksByTaskIds(tasks.map((t) => t.id));
32
+ const allSubtasks: SubtaskRecord[] = [];
33
+ for (const task of tasks) {
34
+ for (const subtask of subtasksByTaskId.get(task.id) ?? []) {
35
+ allSubtasks.push(subtask);
36
+ }
37
+ }
38
+ const subtaskIds = new Set(allSubtasks.map((s) => s.id));
39
+
40
+ const inScopeIds = new Set([...taskIds, ...subtaskIds]);
41
+
42
+ // Gather all dependencies touching any in-scope node
43
+ const sourceIds = [...inScopeIds];
44
+ const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
45
+ const allRawDeps: DependencyRecord[] = [];
46
+ const seenDepIds = new Set<string>();
47
+ for (const deps of dependenciesBySourceId.values()) {
48
+ for (const dep of deps) {
49
+ if (!seenDepIds.has(dep.id)) {
50
+ seenDepIds.add(dep.id);
51
+ allRawDeps.push(dep);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Also find dependencies where in-scope nodes are the target (dependsOnId)
57
+ // by checking each in-scope node with listDependenciesTouchingNode
58
+ // We use a more efficient approach: query dependencies where dependsOnId is in scope
59
+ for (const nodeId of inScopeIds) {
60
+ const touching = domain.listDependenciesTouchingNode(nodeId);
61
+ for (const dep of touching) {
62
+ if (!seenDepIds.has(dep.id)) {
63
+ seenDepIds.add(dep.id);
64
+ allRawDeps.push(dep);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Sort for stable ordering
70
+ allRawDeps.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
71
+
72
+ // Classify edges and build dependency indexes
73
+ const blockedByMap = new Map<string, string[]>();
74
+ const blocksMap = new Map<string, string[]>();
75
+ const externalNodeMap = new Map<string, ExportExternalNode>();
76
+ const warnings: ExportWarning[] = [];
77
+
78
+ const edges: ExportDependencyEdge[] = allRawDeps.map((dep) => {
79
+ const sourceInternal = inScopeIds.has(dep.sourceId);
80
+ const targetInternal = inScopeIds.has(dep.dependsOnId);
81
+ const internal = sourceInternal && targetInternal;
82
+
83
+ // Build blockedBy: source is blocked by dependsOn
84
+ if (sourceInternal) {
85
+ const existing = blockedByMap.get(dep.sourceId) ?? [];
86
+ existing.push(dep.dependsOnId);
87
+ blockedByMap.set(dep.sourceId, existing);
88
+ }
89
+
90
+ // Build blocks: dependsOn blocks source
91
+ if (targetInternal) {
92
+ const existing = blocksMap.get(dep.dependsOnId) ?? [];
93
+ existing.push(dep.sourceId);
94
+ blocksMap.set(dep.dependsOnId, existing);
95
+ }
96
+
97
+ // Resolve external nodes
98
+ if (!sourceInternal && !externalNodeMap.has(dep.sourceId)) {
99
+ externalNodeMap.set(dep.sourceId, resolveExternalNode(domain, dep.sourceId, dep.sourceKind));
100
+ }
101
+ if (!targetInternal && !externalNodeMap.has(dep.dependsOnId)) {
102
+ externalNodeMap.set(dep.dependsOnId, resolveExternalNode(domain, dep.dependsOnId, dep.dependsOnKind));
103
+ }
104
+
105
+ return {
106
+ id: dep.id,
107
+ sourceId: dep.sourceId,
108
+ sourceKind: dep.sourceKind,
109
+ dependsOnId: dep.dependsOnId,
110
+ dependsOnKind: dep.dependsOnKind,
111
+ internal,
112
+ };
113
+ });
114
+
115
+ const externalNodes = [...externalNodeMap.values()].sort((a, b) => a.id.localeCompare(b.id));
116
+
117
+ // Check for orphaned dependency references
118
+ for (const node of externalNodes) {
119
+ if (node.title === null) {
120
+ warnings.push({
121
+ code: "orphaned_external_node",
122
+ message: `External ${node.kind} ${node.id} referenced by a dependency but not found in the database`,
123
+ entityId: node.id,
124
+ });
125
+ }
126
+ }
127
+
128
+ const summary: ExportSummary = {
129
+ taskCount: tasks.length,
130
+ subtaskCount: allSubtasks.length,
131
+ dependencyCount: edges.length,
132
+ externalNodeCount: externalNodes.length,
133
+ warningCount: warnings.length,
134
+ taskStatuses: countStatuses(tasks),
135
+ subtaskStatuses: countStatuses(allSubtasks),
136
+ };
137
+
138
+ return {
139
+ schemaVersion: EXPORT_SCHEMA_VERSION,
140
+ exportedAt: Date.now(),
141
+ epic,
142
+ tasks,
143
+ subtasks: allSubtasks,
144
+ dependencies: edges,
145
+ externalNodes,
146
+ blockedBy: blockedByMap,
147
+ blocks: blocksMap,
148
+ warnings,
149
+ summary,
150
+ };
151
+ }
152
+
153
+ function resolveExternalNode(
154
+ domain: TrackerDomain,
155
+ id: string,
156
+ kind: "task" | "subtask",
157
+ ): ExportExternalNode {
158
+ if (kind === "task") {
159
+ const task = domain.getTask(id);
160
+ if (task) {
161
+ return { id, kind: "task", title: task.title, status: task.status, epicId: task.epicId };
162
+ }
163
+ } else {
164
+ const subtask = domain.getSubtask(id);
165
+ if (subtask) {
166
+ const task = domain.getTask(subtask.taskId);
167
+ return {
168
+ id,
169
+ kind: "subtask",
170
+ title: subtask.title,
171
+ status: subtask.status,
172
+ epicId: task?.epicId ?? null,
173
+ };
174
+ }
175
+ }
176
+
177
+ return { id, kind, title: null, status: null, epicId: null };
178
+ }
@@ -0,0 +1,48 @@
1
+ import { extname, isAbsolute, resolve } from "node:path";
2
+
3
+ const PLANS_DIRNAME = "plans";
4
+
5
+ function slugify(text: string): string {
6
+ return text
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9\s-]/g, "")
9
+ .replace(/\s+/g, "-")
10
+ .replace(/-+/g, "-")
11
+ .replace(/^-|-$/g, "")
12
+ .slice(0, 80);
13
+ }
14
+
15
+ function defaultFilename(epicTitle: string, epicId: string): string {
16
+ const slug = slugify(epicTitle) || epicId;
17
+ return `${slug}.md`;
18
+ }
19
+
20
+ function looksLikeFilePath(path: string): boolean {
21
+ return extname(path) !== "";
22
+ }
23
+
24
+ export function resolveExportPath(options: {
25
+ readonly customPath: string | undefined;
26
+ readonly epicId: string;
27
+ readonly epicTitle: string;
28
+ readonly worktreeRoot: string;
29
+ readonly cwd: string;
30
+ }): string {
31
+ const filename = defaultFilename(options.epicTitle, options.epicId);
32
+
33
+ if (!options.customPath) {
34
+ return resolve(options.worktreeRoot, PLANS_DIRNAME, filename);
35
+ }
36
+
37
+ const resolved = isAbsolute(options.customPath)
38
+ ? options.customPath
39
+ : resolve(options.cwd, options.customPath);
40
+
41
+ // If the path has a file extension, treat it as a file path.
42
+ // Otherwise treat it as a directory and place the default-named file inside.
43
+ if (looksLikeFilePath(resolved)) {
44
+ return resolved;
45
+ }
46
+
47
+ return resolve(resolved, filename);
48
+ }