trekoon 0.1.7 → 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.
@@ -1,4 +1,12 @@
1
- import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
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
- ): TaskRecord[] {
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) => taskStatusPriority(left.status) - taskStatusPriority(right.status));
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 sorted;
231
+ return {
232
+ tasks: sorted,
233
+ pagination: {
234
+ hasMore: false,
235
+ nextCursor: null,
236
+ },
237
+ };
65
238
  }
66
239
 
67
- return sorted.slice(0, limit);
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 tasks = filterSortAndLimitTasks(domain.listTasks(epicId), selectedStatuses, selectedLimit);
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 {
@@ -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;
@@ -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();