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.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +40 -14
- package/docs/ai-agents.md +1 -0
- package/docs/commands.md +18 -0
- package/docs/quickstart.md +35 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/epic.ts +84 -1
- package/src/commands/help.ts +19 -1
- package/src/commands/quickstart.ts +13 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/export/build-epic-export-bundle.ts +178 -0
- package/src/export/path.ts +48 -0
- package/src/export/render-markdown.ts +256 -0
- package/src/export/types.ts +61 -0
- package/src/export/write.ts +97 -0
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
|
@@ -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
|
-
|
|
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(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
516
|
+
this.#dependencyEventEntityId(dependency),
|
|
458
517
|
ENTITY_OPERATIONS.dependency.removed,
|
|
459
|
-
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
554
|
+
this.#dependencyEventEntityId(dependency),
|
|
493
555
|
ENTITY_OPERATIONS.dependency.removed,
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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",
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
593
|
-
.filter((dependency) => dependency.dependsOnId === input.dependsOnId)
|
|
594
|
-
|
|
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",
|
|
607
|
-
|
|
608
|
-
|
|
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
|
+
}
|