trekoon 0.1.6 → 0.1.8
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 +39 -15
- package/README.md +124 -10
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +13 -0
- package/src/commands/dep.ts +20 -1
- package/src/commands/epic.ts +72 -7
- package/src/commands/help.ts +255 -17
- package/src/commands/quickstart.ts +88 -24
- package/src/commands/skills.ts +177 -14
- package/src/commands/subtask.ts +76 -6
- package/src/commands/sync.ts +4 -0
- package/src/commands/task.ts +299 -7
- package/src/domain/tracker-domain.ts +113 -7
- package/src/domain/types.ts +7 -0
- package/src/runtime/cli-shell.ts +1 -2
- package/src/runtime/version.ts +20 -0
- package/src/storage/migrations.ts +48 -1
- package/src/sync/service.ts +67 -9
- package/src/sync/types.ts +9 -0
package/src/commands/task.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
hasFlag,
|
|
3
|
+
parseArgs,
|
|
4
|
+
parseStrictNonNegativeInt,
|
|
5
|
+
parseStrictPositiveInt,
|
|
6
|
+
readEnumOption,
|
|
7
|
+
readMissingOptionValue,
|
|
8
|
+
readOption,
|
|
9
|
+
} from "./arg-parser";
|
|
2
10
|
|
|
3
11
|
import { MutationService } from "../domain/mutation-service";
|
|
4
12
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
@@ -16,6 +24,51 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
|
16
24
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
17
25
|
const DEFAULT_TASK_LIST_LIMIT = 10;
|
|
18
26
|
const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
27
|
+
const READY_REASON_READY = "all_dependencies_done";
|
|
28
|
+
const READY_REASON_BLOCKED = "blocked_by_dependencies";
|
|
29
|
+
|
|
30
|
+
interface DependencyBlocker {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly kind: "task" | "subtask";
|
|
33
|
+
readonly status: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TaskReadyCandidate {
|
|
37
|
+
readonly task: TaskRecord;
|
|
38
|
+
readonly readiness: {
|
|
39
|
+
readonly isReady: boolean;
|
|
40
|
+
readonly reason: typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
41
|
+
};
|
|
42
|
+
readonly blockerSummary: {
|
|
43
|
+
readonly totalDependencies: number;
|
|
44
|
+
readonly blockedByCount: number;
|
|
45
|
+
readonly blockedBy: ReadonlyArray<DependencyBlocker>;
|
|
46
|
+
};
|
|
47
|
+
readonly ranking: {
|
|
48
|
+
readonly statusPriority: number;
|
|
49
|
+
readonly blockerCount: number;
|
|
50
|
+
readonly createdAt: number;
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly rank: number;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ReadyReason = typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
57
|
+
|
|
58
|
+
interface TaskReadinessSummary {
|
|
59
|
+
readonly totalOpenTasks: number;
|
|
60
|
+
readonly readyCount: number;
|
|
61
|
+
readonly returnedCount: number;
|
|
62
|
+
readonly appliedLimit: number | null;
|
|
63
|
+
readonly blockedCount: number;
|
|
64
|
+
readonly unresolvedDependencyCount: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TaskReadinessResult {
|
|
68
|
+
readonly candidates: readonly TaskReadyCandidate[];
|
|
69
|
+
readonly blocked: readonly TaskReadyCandidate[];
|
|
70
|
+
readonly summary: TaskReadinessSummary;
|
|
71
|
+
}
|
|
19
72
|
|
|
20
73
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
21
74
|
if (rawIds === undefined) {
|
|
@@ -51,20 +104,149 @@ function taskStatusPriority(status: string): number {
|
|
|
51
104
|
return 2;
|
|
52
105
|
}
|
|
53
106
|
|
|
107
|
+
function buildTaskReadiness(domain: TrackerDomain, epicId: string | undefined): TaskReadinessResult {
|
|
108
|
+
const openStatuses = new Set<string>(DEFAULT_OPEN_TASK_STATUSES);
|
|
109
|
+
const openTasks = domain.listTasks(epicId).filter((task) => openStatuses.has(task.status));
|
|
110
|
+
const assessed = openTasks
|
|
111
|
+
.map((task) => {
|
|
112
|
+
const blockers: DependencyBlocker[] = [];
|
|
113
|
+
const dependencies = domain.listDependencies(task.id);
|
|
114
|
+
for (const dependency of dependencies) {
|
|
115
|
+
const dependencyStatus =
|
|
116
|
+
dependency.dependsOnKind === "task"
|
|
117
|
+
? domain.getTaskOrThrow(dependency.dependsOnId).status
|
|
118
|
+
: domain.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
119
|
+
|
|
120
|
+
if (dependencyStatus !== "done") {
|
|
121
|
+
blockers.push({
|
|
122
|
+
id: dependency.dependsOnId,
|
|
123
|
+
kind: dependency.dependsOnKind,
|
|
124
|
+
status: dependencyStatus,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const blockerCount = blockers.length;
|
|
130
|
+
const readinessReason: ReadyReason = blockerCount === 0 ? READY_REASON_READY : READY_REASON_BLOCKED;
|
|
131
|
+
return {
|
|
132
|
+
task,
|
|
133
|
+
readiness: {
|
|
134
|
+
isReady: blockerCount === 0,
|
|
135
|
+
reason: readinessReason,
|
|
136
|
+
},
|
|
137
|
+
blockerSummary: {
|
|
138
|
+
totalDependencies: dependencies.length,
|
|
139
|
+
blockedByCount: blockerCount,
|
|
140
|
+
blockedBy: blockers,
|
|
141
|
+
},
|
|
142
|
+
ranking: {
|
|
143
|
+
statusPriority: taskStatusPriority(task.status),
|
|
144
|
+
blockerCount,
|
|
145
|
+
createdAt: task.createdAt,
|
|
146
|
+
id: task.id,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
})
|
|
150
|
+
.sort((left, right) => {
|
|
151
|
+
const byStatus = left.ranking.statusPriority - right.ranking.statusPriority;
|
|
152
|
+
if (byStatus !== 0) {
|
|
153
|
+
return byStatus;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const byBlockers = left.ranking.blockerCount - right.ranking.blockerCount;
|
|
157
|
+
if (byBlockers !== 0) {
|
|
158
|
+
return byBlockers;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const byCreatedAt = left.ranking.createdAt - right.ranking.createdAt;
|
|
162
|
+
if (byCreatedAt !== 0) {
|
|
163
|
+
return byCreatedAt;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return left.ranking.id.localeCompare(right.ranking.id);
|
|
167
|
+
})
|
|
168
|
+
.map((item, index) => ({
|
|
169
|
+
...item,
|
|
170
|
+
ranking: {
|
|
171
|
+
...item.ranking,
|
|
172
|
+
rank: index + 1,
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const candidates = assessed.filter((item) => item.readiness.isReady);
|
|
177
|
+
const blocked = assessed.filter((item) => !item.readiness.isReady);
|
|
178
|
+
return {
|
|
179
|
+
candidates,
|
|
180
|
+
blocked,
|
|
181
|
+
summary: {
|
|
182
|
+
totalOpenTasks: assessed.length,
|
|
183
|
+
readyCount: candidates.length,
|
|
184
|
+
returnedCount: candidates.length,
|
|
185
|
+
appliedLimit: null,
|
|
186
|
+
blockedCount: blocked.length,
|
|
187
|
+
unresolvedDependencyCount: blocked.reduce((total, item) => total + item.blockerSummary.blockedByCount, 0),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatTaskReadyCandidateLine(candidate: TaskReadyCandidate): string {
|
|
193
|
+
return `${candidate.ranking.rank}. ${formatTask(candidate.task)} | reason=${candidate.readiness.reason} | blockers=${candidate.blockerSummary.blockedByCount}/${candidate.blockerSummary.totalDependencies}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatTaskReadyHumanOutput(result: TaskReadinessResult): string {
|
|
197
|
+
if (result.candidates.length === 0) {
|
|
198
|
+
return `No ready tasks found. Open=${result.summary.totalOpenTasks}, ready=${result.summary.readyCount}, returned=${result.summary.returnedCount}, blocked=${result.summary.blockedCount}, unresolvedDependencies=${result.summary.unresolvedDependencyCount}.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lines = result.candidates.map(formatTaskReadyCandidateLine);
|
|
202
|
+
lines.push(
|
|
203
|
+
`Summary: ready=${result.summary.readyCount}, returned=${result.summary.returnedCount}, blocked=${result.summary.blockedCount}, unresolvedDependencies=${result.summary.unresolvedDependencyCount}.`,
|
|
204
|
+
);
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
54
208
|
function filterSortAndLimitTasks(
|
|
55
209
|
tasks: readonly TaskRecord[],
|
|
56
210
|
statuses: readonly string[] | undefined,
|
|
57
211
|
limit: number | undefined,
|
|
58
|
-
|
|
212
|
+
cursor: number,
|
|
213
|
+
): { tasks: TaskRecord[]; pagination: { hasMore: boolean; nextCursor: string | null } } {
|
|
59
214
|
const allowedStatuses = statuses === undefined ? undefined : new Set(statuses);
|
|
60
215
|
const filtered = allowedStatuses === undefined ? [...tasks] : tasks.filter((task) => allowedStatuses.has(task.status));
|
|
61
|
-
const sorted = [...filtered].sort((left, right) =>
|
|
216
|
+
const sorted = [...filtered].sort((left, right) => {
|
|
217
|
+
const byStatus = taskStatusPriority(left.status) - taskStatusPriority(right.status);
|
|
218
|
+
if (byStatus !== 0) {
|
|
219
|
+
return byStatus;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const byCreatedAt = left.createdAt - right.createdAt;
|
|
223
|
+
if (byCreatedAt !== 0) {
|
|
224
|
+
return byCreatedAt;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return left.id.localeCompare(right.id);
|
|
228
|
+
});
|
|
62
229
|
|
|
63
230
|
if (limit === undefined) {
|
|
64
|
-
return
|
|
231
|
+
return {
|
|
232
|
+
tasks: sorted,
|
|
233
|
+
pagination: {
|
|
234
|
+
hasMore: false,
|
|
235
|
+
nextCursor: null,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
65
238
|
}
|
|
66
239
|
|
|
67
|
-
|
|
240
|
+
const pagedTasks = sorted.slice(cursor, cursor + limit);
|
|
241
|
+
const nextIndex = cursor + pagedTasks.length;
|
|
242
|
+
const hasMore = nextIndex < sorted.length;
|
|
243
|
+
return {
|
|
244
|
+
tasks: pagedTasks,
|
|
245
|
+
pagination: {
|
|
246
|
+
hasMore,
|
|
247
|
+
nextCursor: hasMore ? `${nextIndex}` : null,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
68
250
|
}
|
|
69
251
|
|
|
70
252
|
function appendLine(existing: string, line: string): string {
|
|
@@ -227,6 +409,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
227
409
|
readMissingOptionValue(parsed.missingOptionValues, "view") ??
|
|
228
410
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
229
411
|
readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
|
|
412
|
+
readMissingOptionValue(parsed.missingOptionValues, "cursor") ??
|
|
230
413
|
readMissingOptionValue(parsed.missingOptionValues, "epic", "e");
|
|
231
414
|
if (missingListOption !== undefined) {
|
|
232
415
|
return failMissingOptionValue("task.list", missingListOption);
|
|
@@ -237,6 +420,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
237
420
|
const includeAll = hasFlag(parsed.flags, "all");
|
|
238
421
|
const rawStatuses = readOption(parsed.options, "status", "s");
|
|
239
422
|
const rawLimit = readOption(parsed.options, "limit", "l");
|
|
423
|
+
const rawCursor = readOption(parsed.options, "cursor");
|
|
240
424
|
|
|
241
425
|
if (rawView !== undefined && view === undefined) {
|
|
242
426
|
return failResult({
|
|
@@ -286,6 +470,18 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
286
470
|
});
|
|
287
471
|
}
|
|
288
472
|
|
|
473
|
+
if (includeAll && rawCursor !== undefined) {
|
|
474
|
+
return failResult({
|
|
475
|
+
command: "task.list",
|
|
476
|
+
human: "Use either --all or --cursor, not both.",
|
|
477
|
+
data: { code: "invalid_input", flags: ["all", "cursor"] },
|
|
478
|
+
error: {
|
|
479
|
+
code: "invalid_input",
|
|
480
|
+
message: "--all and --cursor are mutually exclusive",
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
289
485
|
const statuses = parseStatusCsv(rawStatuses);
|
|
290
486
|
if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
|
|
291
487
|
return failResult({
|
|
@@ -312,6 +508,19 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
312
508
|
});
|
|
313
509
|
}
|
|
314
510
|
|
|
511
|
+
const parsedCursor = parseStrictNonNegativeInt(rawCursor);
|
|
512
|
+
if (Number.isNaN(parsedCursor)) {
|
|
513
|
+
return failResult({
|
|
514
|
+
command: "task.list",
|
|
515
|
+
human: "Invalid --cursor value. Use an integer >= 0.",
|
|
516
|
+
data: { code: "invalid_input", cursor: rawCursor },
|
|
517
|
+
error: {
|
|
518
|
+
code: "invalid_input",
|
|
519
|
+
message: "Invalid --cursor value",
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
315
524
|
const epicId: string | undefined = readOption(parsed.options, "epic", "e");
|
|
316
525
|
const selectedStatuses = includeAll
|
|
317
526
|
? undefined
|
|
@@ -319,7 +528,8 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
319
528
|
const selectedLimit = includeAll
|
|
320
529
|
? undefined
|
|
321
530
|
: parsedLimit ?? DEFAULT_TASK_LIST_LIMIT;
|
|
322
|
-
const
|
|
531
|
+
const listed = filterSortAndLimitTasks(domain.listTasks(epicId), selectedStatuses, selectedLimit, parsedCursor ?? 0);
|
|
532
|
+
const tasks = listed.tasks;
|
|
323
533
|
const listView = view ?? "table";
|
|
324
534
|
const human = tasks.length === 0 ? "No tasks found." : listView === "compact" ? tasks.map(formatTask).join("\n") : formatTaskListTable(tasks);
|
|
325
535
|
|
|
@@ -327,6 +537,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
327
537
|
command: "task.list",
|
|
328
538
|
human,
|
|
329
539
|
data: { tasks },
|
|
540
|
+
...(context.mode === "human" ? {} : { meta: { pagination: listed.pagination } }),
|
|
330
541
|
});
|
|
331
542
|
}
|
|
332
543
|
case "show": {
|
|
@@ -401,6 +612,87 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
401
612
|
data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
|
|
402
613
|
});
|
|
403
614
|
}
|
|
615
|
+
case "ready": {
|
|
616
|
+
const missingReadyOption =
|
|
617
|
+
readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
|
|
618
|
+
readMissingOptionValue(parsed.missingOptionValues, "epic", "e");
|
|
619
|
+
if (missingReadyOption !== undefined) {
|
|
620
|
+
return failMissingOptionValue("task.ready", missingReadyOption);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const rawLimit = readOption(parsed.options, "limit", "l");
|
|
624
|
+
const parsedLimit = parseStrictPositiveInt(rawLimit);
|
|
625
|
+
if (Number.isNaN(parsedLimit)) {
|
|
626
|
+
return failResult({
|
|
627
|
+
command: "task.ready",
|
|
628
|
+
human: "Invalid --limit value. Use an integer >= 1.",
|
|
629
|
+
data: { code: "invalid_input", limit: rawLimit },
|
|
630
|
+
error: {
|
|
631
|
+
code: "invalid_input",
|
|
632
|
+
message: "Invalid --limit value",
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const epicId = readOption(parsed.options, "epic", "e");
|
|
638
|
+
const readiness = buildTaskReadiness(domain, epicId);
|
|
639
|
+
const limit = parsedLimit ?? readiness.candidates.length;
|
|
640
|
+
const candidates = readiness.candidates.slice(0, limit);
|
|
641
|
+
const summary = {
|
|
642
|
+
...readiness.summary,
|
|
643
|
+
returnedCount: candidates.length,
|
|
644
|
+
appliedLimit: parsedLimit ?? null,
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
return okResult({
|
|
648
|
+
command: "task.ready",
|
|
649
|
+
human: formatTaskReadyHumanOutput({
|
|
650
|
+
...readiness,
|
|
651
|
+
candidates,
|
|
652
|
+
summary,
|
|
653
|
+
}),
|
|
654
|
+
data: {
|
|
655
|
+
candidates,
|
|
656
|
+
blocked: readiness.blocked.map((item) => ({
|
|
657
|
+
task: item.task,
|
|
658
|
+
readiness: item.readiness,
|
|
659
|
+
blockerSummary: item.blockerSummary,
|
|
660
|
+
ranking: item.ranking,
|
|
661
|
+
})),
|
|
662
|
+
summary: {
|
|
663
|
+
...summary,
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
case "next": {
|
|
669
|
+
const missingNextOption = readMissingOptionValue(parsed.missingOptionValues, "epic", "e");
|
|
670
|
+
if (missingNextOption !== undefined) {
|
|
671
|
+
return failMissingOptionValue("task.next", missingNextOption);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const epicId = readOption(parsed.options, "epic", "e");
|
|
675
|
+
const readiness = buildTaskReadiness(domain, epicId);
|
|
676
|
+
const candidate = readiness.candidates[0] ?? null;
|
|
677
|
+
|
|
678
|
+
return okResult({
|
|
679
|
+
command: "task.next",
|
|
680
|
+
human:
|
|
681
|
+
candidate === null
|
|
682
|
+
? formatTaskReadyHumanOutput(readiness)
|
|
683
|
+
: `${formatTaskReadyCandidateLine(candidate)}\nSummary: ready=${readiness.summary.readyCount}, blocked=${readiness.summary.blockedCount}, unresolvedDependencies=${readiness.summary.unresolvedDependencyCount}.`,
|
|
684
|
+
data: {
|
|
685
|
+
candidate,
|
|
686
|
+
summary: readiness.summary,
|
|
687
|
+
blocked: readiness.blocked.map((item) => ({
|
|
688
|
+
task: item.task,
|
|
689
|
+
readiness: item.readiness,
|
|
690
|
+
blockerSummary: item.blockerSummary,
|
|
691
|
+
ranking: item.ranking,
|
|
692
|
+
})),
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
}
|
|
404
696
|
case "update": {
|
|
405
697
|
const missingUpdateOption =
|
|
406
698
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
@@ -538,7 +830,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
538
830
|
default:
|
|
539
831
|
return failResult({
|
|
540
832
|
command: "task",
|
|
541
|
-
human: "Usage: trekoon task <create|list|show|update|delete>",
|
|
833
|
+
human: "Usage: trekoon task <create|list|show|ready|next|update|delete>",
|
|
542
834
|
data: {
|
|
543
835
|
args: context.args,
|
|
544
836
|
},
|
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
type EpicRecord,
|
|
10
10
|
type EpicTree,
|
|
11
11
|
type NodeKind,
|
|
12
|
+
type ReverseDependencyNode,
|
|
12
13
|
type SubtaskRecord,
|
|
13
14
|
type TaskTreeDetailed,
|
|
14
15
|
type TaskRecord,
|
|
15
16
|
} from "./types";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_STATUS = "todo";
|
|
19
|
+
const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "in-progress", "done"]);
|
|
18
20
|
|
|
19
21
|
interface EpicRow {
|
|
20
22
|
id: string;
|
|
@@ -43,6 +45,18 @@ interface DependencyRow {
|
|
|
43
45
|
updated_at: number;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
interface ReverseDependencyRow {
|
|
49
|
+
node_id: string;
|
|
50
|
+
node_kind: "task" | "subtask";
|
|
51
|
+
min_distance: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface UnresolvedDependencyBlocker {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly kind: "task" | "subtask";
|
|
57
|
+
readonly status: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
function assertNonEmpty(field: string, value: string | undefined | null): string {
|
|
47
61
|
const normalized: string = (value ?? "").trim();
|
|
48
62
|
if (!normalized) {
|
|
@@ -136,7 +150,7 @@ export class TrackerDomain {
|
|
|
136
150
|
|
|
137
151
|
listEpics(): readonly EpicRecord[] {
|
|
138
152
|
const rows = this.#db
|
|
139
|
-
.query("SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC;")
|
|
153
|
+
.query("SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC;")
|
|
140
154
|
.all() as EpicRow[];
|
|
141
155
|
return rows.map(mapEpic);
|
|
142
156
|
}
|
|
@@ -208,14 +222,14 @@ export class TrackerDomain {
|
|
|
208
222
|
this.getEpicOrThrow(epicId);
|
|
209
223
|
const rows = this.#db
|
|
210
224
|
.query(
|
|
211
|
-
"SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC;",
|
|
225
|
+
"SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
|
|
212
226
|
)
|
|
213
227
|
.all(epicId) as TaskRow[];
|
|
214
228
|
return rows.map(mapTask);
|
|
215
229
|
}
|
|
216
230
|
|
|
217
231
|
const rows = this.#db
|
|
218
|
-
.query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC;")
|
|
232
|
+
.query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
|
|
219
233
|
.all() as TaskRow[];
|
|
220
234
|
return rows.map(mapTask);
|
|
221
235
|
}
|
|
@@ -249,6 +263,7 @@ export class TrackerDomain {
|
|
|
249
263
|
const nextDescription: string =
|
|
250
264
|
input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
|
|
251
265
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
266
|
+
this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
|
|
252
267
|
const now: number = Date.now();
|
|
253
268
|
|
|
254
269
|
this.#db
|
|
@@ -289,7 +304,7 @@ export class TrackerDomain {
|
|
|
289
304
|
this.getTaskOrThrow(taskId);
|
|
290
305
|
const rows = this.#db
|
|
291
306
|
.query(
|
|
292
|
-
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC;",
|
|
307
|
+
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
|
|
293
308
|
)
|
|
294
309
|
.all(taskId) as SubtaskRow[];
|
|
295
310
|
return rows.map(mapSubtask);
|
|
@@ -297,7 +312,7 @@ export class TrackerDomain {
|
|
|
297
312
|
|
|
298
313
|
const rows = this.#db
|
|
299
314
|
.query(
|
|
300
|
-
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC;",
|
|
315
|
+
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
|
|
301
316
|
)
|
|
302
317
|
.all() as SubtaskRow[];
|
|
303
318
|
return rows.map(mapSubtask);
|
|
@@ -332,6 +347,7 @@ export class TrackerDomain {
|
|
|
332
347
|
const nextDescription: string =
|
|
333
348
|
input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
|
|
334
349
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
350
|
+
this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
335
351
|
const now: number = Date.now();
|
|
336
352
|
|
|
337
353
|
this.#db
|
|
@@ -352,7 +368,7 @@ export class TrackerDomain {
|
|
|
352
368
|
const taskIds = new Set(tasks.map((task) => task.id));
|
|
353
369
|
const subtasks = this.#db
|
|
354
370
|
.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;",
|
|
371
|
+
"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, id ASC;",
|
|
356
372
|
)
|
|
357
373
|
.all(epicId) as SubtaskRow[];
|
|
358
374
|
|
|
@@ -496,13 +512,46 @@ export class TrackerDomain {
|
|
|
496
512
|
|
|
497
513
|
const rows = this.#db
|
|
498
514
|
.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;",
|
|
515
|
+
"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, id ASC;",
|
|
500
516
|
)
|
|
501
517
|
.all(normalizedSourceId) as DependencyRow[];
|
|
502
518
|
|
|
503
519
|
return rows.map(mapDependency);
|
|
504
520
|
}
|
|
505
521
|
|
|
522
|
+
listReverseDependencies(nodeId: string): readonly ReverseDependencyNode[] {
|
|
523
|
+
const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
|
|
524
|
+
this.resolveNodeKind(normalizedNodeId);
|
|
525
|
+
|
|
526
|
+
const rows = this.#db
|
|
527
|
+
.query(
|
|
528
|
+
`
|
|
529
|
+
WITH RECURSIVE reverse_paths(node_id, node_kind, distance, visited) AS (
|
|
530
|
+
SELECT d.source_id, d.source_kind, 1, ',' || d.source_id || ','
|
|
531
|
+
FROM dependencies d
|
|
532
|
+
WHERE d.depends_on_id = ?
|
|
533
|
+
UNION ALL
|
|
534
|
+
SELECT d.source_id, d.source_kind, rp.distance + 1, rp.visited || d.source_id || ','
|
|
535
|
+
FROM dependencies d
|
|
536
|
+
INNER JOIN reverse_paths rp ON d.depends_on_id = rp.node_id
|
|
537
|
+
WHERE instr(rp.visited, ',' || d.source_id || ',') = 0
|
|
538
|
+
)
|
|
539
|
+
SELECT node_id, node_kind, MIN(distance) AS min_distance
|
|
540
|
+
FROM reverse_paths
|
|
541
|
+
GROUP BY node_id, node_kind
|
|
542
|
+
ORDER BY min_distance ASC, node_kind ASC, node_id ASC;
|
|
543
|
+
`,
|
|
544
|
+
)
|
|
545
|
+
.all(normalizedNodeId) as ReverseDependencyRow[];
|
|
546
|
+
|
|
547
|
+
return rows.map((row) => ({
|
|
548
|
+
id: row.node_id,
|
|
549
|
+
kind: row.node_kind,
|
|
550
|
+
distance: row.min_distance,
|
|
551
|
+
isDirect: row.min_distance === 1,
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
|
|
506
555
|
private getDependencyOrThrow(id: string): DependencyRecord {
|
|
507
556
|
const row = this.#db
|
|
508
557
|
.query(
|
|
@@ -542,6 +591,63 @@ export class TrackerDomain {
|
|
|
542
591
|
|
|
543
592
|
return row !== null;
|
|
544
593
|
}
|
|
594
|
+
|
|
595
|
+
private assertNoUnresolvedDependenciesForStatusTransition(
|
|
596
|
+
id: string,
|
|
597
|
+
kind: "task" | "subtask",
|
|
598
|
+
existingStatus: string,
|
|
599
|
+
nextStatus: string,
|
|
600
|
+
): void {
|
|
601
|
+
if (existingStatus === nextStatus) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!DEPENDENCY_GATED_STATUSES.has(nextStatus)) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const unresolvedDependencies = this.listUnresolvedDependencyBlockers(id);
|
|
610
|
+
if (unresolvedDependencies.length === 0) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
throw new DomainError({
|
|
615
|
+
code: "dependency_blocked",
|
|
616
|
+
message: `${kind} cannot transition to ${nextStatus} while dependencies are unresolved`,
|
|
617
|
+
details: {
|
|
618
|
+
entity: kind,
|
|
619
|
+
id,
|
|
620
|
+
status: nextStatus,
|
|
621
|
+
unresolvedDependencyCount: unresolvedDependencies.length,
|
|
622
|
+
unresolvedDependencyIds: unresolvedDependencies.map((dependency) => dependency.id),
|
|
623
|
+
unresolvedDependencies,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private listUnresolvedDependencyBlockers(sourceId: string): readonly UnresolvedDependencyBlocker[] {
|
|
629
|
+
const dependencies = this.listDependencies(sourceId);
|
|
630
|
+
const unresolved: UnresolvedDependencyBlocker[] = [];
|
|
631
|
+
|
|
632
|
+
for (const dependency of dependencies) {
|
|
633
|
+
const dependencyStatus =
|
|
634
|
+
dependency.dependsOnKind === "task"
|
|
635
|
+
? this.getTaskOrThrow(dependency.dependsOnId).status
|
|
636
|
+
: this.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
637
|
+
|
|
638
|
+
if (dependencyStatus === "done") {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
unresolved.push({
|
|
643
|
+
id: dependency.dependsOnId,
|
|
644
|
+
kind: dependency.dependsOnKind,
|
|
645
|
+
status: dependencyStatus,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return unresolved;
|
|
650
|
+
}
|
|
545
651
|
}
|
|
546
652
|
|
|
547
653
|
export function parseNodeKind(kind: string): NodeKind {
|
package/src/domain/types.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface DependencyRecord {
|
|
|
39
39
|
readonly updatedAt: number;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export interface ReverseDependencyNode {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly kind: Extract<NodeKind, "task" | "subtask">;
|
|
45
|
+
readonly distance: number;
|
|
46
|
+
readonly isDirect: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
export interface EpicTree {
|
|
43
50
|
readonly id: string;
|
|
44
51
|
readonly title: string;
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -12,8 +12,7 @@ import { runTask } from "../commands/task";
|
|
|
12
12
|
import { runWipe } from "../commands/wipe";
|
|
13
13
|
import { failResult, okResult, renderResult } from "../io/output";
|
|
14
14
|
import { type CliContext, type CliResult, type OutputMode } from "./command-types";
|
|
15
|
-
|
|
16
|
-
const CLI_VERSION = "0.1.0";
|
|
15
|
+
import { CLI_VERSION } from "./version";
|
|
17
16
|
|
|
18
17
|
const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
|
|
19
18
|
"help",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
interface PackageManifest {
|
|
4
|
+
readonly version?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function readCliVersion(): string {
|
|
8
|
+
const packageJsonPath = new URL("../../package.json", import.meta.url);
|
|
9
|
+
const packageJsonContent: string = readFileSync(packageJsonPath, "utf8");
|
|
10
|
+
const packageManifest: PackageManifest = JSON.parse(packageJsonContent) as PackageManifest;
|
|
11
|
+
const version: string | undefined = packageManifest.version;
|
|
12
|
+
|
|
13
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
14
|
+
throw new Error("package.json is missing a valid version field.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return version;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CLI_VERSION: string = readCliVersion();
|