trekoon 0.2.6 → 0.2.7

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.
@@ -20,19 +20,14 @@ import { unexpectedFailureResult } from "./error-utils";
20
20
  import {
21
21
  buildTaskReadiness,
22
22
  DEFAULT_OPEN_TASK_STATUSES,
23
- type DependencyBlocker,
24
- READY_REASON_BLOCKED,
25
- READY_REASON_READY,
26
- type ReadyReason,
27
23
  taskStatusPriority,
28
24
  type TaskReadinessResult,
29
- type TaskReadinessSummary,
30
25
  type TaskReadyCandidate,
31
26
  } from "./task-readiness";
32
27
 
33
28
  import { MutationService } from "../domain/mutation-service";
34
29
  import { TrackerDomain } from "../domain/tracker-domain";
35
- import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type TaskRecord } from "../domain/types";
30
+ import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type StatusCascadePlan, type TaskRecord } from "../domain/types";
36
31
  import { formatHumanTable } from "../io/human-table";
37
32
  import { failResult, okResult } from "../io/output";
38
33
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -45,9 +40,14 @@ function formatTask(task: TaskRecord): string {
45
40
  const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
46
41
  const LIST_VIEW_MODES = ["table", "compact"] as const;
47
42
  const DEFAULT_TASK_LIST_LIMIT = 10;
43
+ const CREATE_OPTIONS = ["epic", "e", "title", "t", "description", "d", "status", "s"] as const;
44
+ const LIST_OPTIONS = ["epic", "e", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
45
+ const SHOW_OPTIONS = ["view", "all"] as const;
48
46
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
49
47
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
50
48
  const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
49
+ const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
50
+ const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
51
51
 
52
52
  function parseIdsOption(rawIds: string | undefined): string[] {
53
53
  if (rawIds === undefined) {
@@ -185,6 +185,44 @@ function appendLine(existing: string, line: string): string {
185
185
  return existing.length > 0 ? `${existing}\n${line}` : line;
186
186
  }
187
187
 
188
+ function isStatusCascadeUpdateStatus(status: string | undefined): status is (typeof STATUS_CASCADE_UPDATE_STATUSES)[number] {
189
+ return status === "done" || status === "todo";
190
+ }
191
+
192
+ function buildStatusCascadeData(plan: StatusCascadePlan): Record<string, unknown> {
193
+ return {
194
+ mode: "descendants",
195
+ root: {
196
+ kind: plan.rootKind,
197
+ id: plan.rootId,
198
+ },
199
+ targetStatus: plan.targetStatus,
200
+ atomic: plan.atomic,
201
+ changedIds: plan.changedIds,
202
+ unchangedIds: plan.unchangedIds,
203
+ counts: plan.counts,
204
+ };
205
+ }
206
+
207
+ function formatStatusCascadeHuman(entityLabel: string, plan: StatusCascadePlan): string {
208
+ return `Cascade updated ${entityLabel} ${plan.rootId} to ${plan.targetStatus} (${plan.counts.changed} changed, ${plan.counts.unchanged} unchanged; tasks=${plan.counts.changedTasks}, subtasks=${plan.counts.changedSubtasks})`;
209
+ }
210
+
211
+ function failCascadeStatusUpdate(command: string, entityLabel: string, data: Record<string, unknown>): CliResult {
212
+ return failResult({
213
+ command,
214
+ human: `${entityLabel} descendant cascade requires --status done or --status todo and does not support --append, --description, or --title.`,
215
+ data: {
216
+ code: "invalid_input",
217
+ ...data,
218
+ },
219
+ error: {
220
+ code: "invalid_input",
221
+ message: `${entityLabel} descendant cascade requires status-only done/todo mode`,
222
+ },
223
+ });
224
+ }
225
+
188
226
  function formatTaskListTable(tasks: readonly TaskRecord[]): string {
189
227
  const rows = tasks.map((task) => [task.id, task.epicId, task.title, task.status]);
190
228
  return formatHumanTable(["ID", "EPIC", "TITLE", "STATUS"], rows, { wrapColumns: [2] });
@@ -426,6 +464,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
426
464
 
427
465
  switch (subcommand) {
428
466
  case "create": {
467
+ const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
468
+ if (createUnknownOption !== undefined) {
469
+ return unknownOption("task.create", createUnknownOption, CREATE_OPTIONS);
470
+ }
471
+
472
+ const unexpectedCreatePositionals = readUnexpectedPositionals(parsed, 1);
473
+ if (unexpectedCreatePositionals.length > 0) {
474
+ return failUnexpectedPositionals("task.create", unexpectedCreatePositionals);
475
+ }
476
+
429
477
  const missingCreateOption =
430
478
  readMissingOptionValue(parsed.missingOptionValues, "epic", "e") ??
431
479
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
@@ -502,6 +550,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
502
550
  });
503
551
  }
504
552
  case "list": {
553
+ const listUnknownOption = findUnknownOption(parsed, LIST_OPTIONS);
554
+ if (listUnknownOption !== undefined) {
555
+ return unknownOption("task.list", listUnknownOption, LIST_OPTIONS);
556
+ }
557
+
558
+ const unexpectedListPositionals = readUnexpectedPositionals(parsed, 1);
559
+ if (unexpectedListPositionals.length > 0) {
560
+ return failUnexpectedPositionals("task.list", unexpectedListPositionals);
561
+ }
562
+
505
563
  const missingListOption =
506
564
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
507
565
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
@@ -660,6 +718,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
660
718
  });
661
719
  }
662
720
  case "show": {
721
+ const showUnknownOption = findUnknownOption(parsed, SHOW_OPTIONS);
722
+ if (showUnknownOption !== undefined) {
723
+ return unknownOption("task.show", showUnknownOption, SHOW_OPTIONS);
724
+ }
725
+
726
+ const unexpectedShowPositionals = readUnexpectedPositionals(parsed, 2);
727
+ if (unexpectedShowPositionals.length > 0) {
728
+ return failUnexpectedPositionals("task.show", unexpectedShowPositionals);
729
+ }
730
+
663
731
  const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
664
732
  if (missingShowOption !== undefined) {
665
733
  return failMissingOptionValue("task.show", missingShowOption);
@@ -989,6 +1057,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
989
1057
  });
990
1058
  }
991
1059
  case "update": {
1060
+ const updateUnknownOption = findUnknownOption(parsed, UPDATE_OPTIONS);
1061
+ if (updateUnknownOption !== undefined) {
1062
+ return unknownOption("task.update", updateUnknownOption, UPDATE_OPTIONS);
1063
+ }
1064
+
1065
+ const unexpectedUpdatePositionals = readUnexpectedPositionals(parsed, 2);
1066
+ if (unexpectedUpdatePositionals.length > 0) {
1067
+ return failUnexpectedPositionals("task.update", unexpectedUpdatePositionals);
1068
+ }
1069
+
992
1070
  const missingUpdateOption =
993
1071
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
994
1072
  readMissingOptionValue(parsed.missingOptionValues, "append") ??
@@ -1031,7 +1109,35 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1031
1109
  });
1032
1110
  }
1033
1111
 
1034
- const hasBulkTarget = updateAll || ids.length > 0;
1112
+ const cascadeMode = updateAll && taskId.length > 0;
1113
+ if (cascadeMode) {
1114
+ if (title !== undefined || description !== undefined || append !== undefined || !isStatusCascadeUpdateStatus(status)) {
1115
+ return failCascadeStatusUpdate("task.update", "Task", {
1116
+ id: taskId,
1117
+ status,
1118
+ allowedStatuses: [...STATUS_CASCADE_UPDATE_STATUSES],
1119
+ fields: {
1120
+ title: title !== undefined,
1121
+ description: description !== undefined,
1122
+ append: append !== undefined,
1123
+ },
1124
+ });
1125
+ }
1126
+
1127
+ const cascade = mutations.updateTaskStatusCascade(taskId, status);
1128
+ const task = domain.getTaskOrThrow(taskId);
1129
+
1130
+ return okResult({
1131
+ command: "task.update",
1132
+ human: formatStatusCascadeHuman("task", cascade),
1133
+ data: {
1134
+ task,
1135
+ cascade: buildStatusCascadeData(cascade),
1136
+ },
1137
+ });
1138
+ }
1139
+
1140
+ const hasBulkTarget = (updateAll && taskId.length === 0) || ids.length > 0;
1035
1141
  if (hasBulkTarget) {
1036
1142
  if (taskId.length > 0) {
1037
1143
  return failResult({
@@ -18,8 +18,10 @@ import {
18
18
  type SearchField,
19
19
  type SearchNode,
20
20
  type SearchSummary,
21
+ type StatusCascadePlan,
21
22
  type SubtaskRecord,
22
23
  type TaskRecord,
24
+ DomainError,
23
25
  } from "./types";
24
26
 
25
27
  function countMatches(value: string, searchText: string): number {
@@ -187,6 +189,15 @@ export class MutationService {
187
189
  })();
188
190
  }
189
191
 
192
+ updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
193
+ return this.#db.transaction((): StatusCascadePlan => {
194
+ const plan = this.#domain.planStatusCascade("epic", id, status);
195
+ this.#assertCascadeNotBlocked(plan);
196
+ this.#applyStatusCascadePlan(plan);
197
+ return plan;
198
+ })();
199
+ }
200
+
190
201
  deleteEpic(id: string): void {
191
202
  this.#db.transaction((): void => {
192
203
  this.#domain.deleteEpic(id);
@@ -277,6 +288,15 @@ export class MutationService {
277
288
  })();
278
289
  }
279
290
 
291
+ updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
292
+ return this.#db.transaction((): StatusCascadePlan => {
293
+ const plan = this.#domain.planStatusCascade("task", id, status);
294
+ this.#assertCascadeNotBlocked(plan);
295
+ this.#applyStatusCascadePlan(plan);
296
+ return plan;
297
+ })();
298
+ }
299
+
280
300
  deleteTask(id: string): void {
281
301
  this.#db.transaction((): void => {
282
302
  this.#domain.deleteTask(id);
@@ -458,6 +478,62 @@ export class MutationService {
458
478
  return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
459
479
  }
460
480
 
481
+ #assertCascadeNotBlocked(plan: StatusCascadePlan): void {
482
+ if (plan.blockers.length === 0) {
483
+ return;
484
+ }
485
+
486
+ throw new DomainError({
487
+ code: "dependency_blocked",
488
+ message: `${plan.rootKind} cascade cannot transition to ${plan.targetStatus} while dependencies are unresolved`,
489
+ details: {
490
+ entity: plan.rootKind,
491
+ id: plan.rootId,
492
+ status: plan.targetStatus,
493
+ atomic: plan.atomic,
494
+ changedIds: plan.changedIds,
495
+ unchangedIds: plan.unchangedIds,
496
+ blockerCount: plan.blockers.length,
497
+ blockers: plan.blockers,
498
+ blockedNodeIds: [...new Set(plan.blockers.map((blocker) => blocker.sourceId))],
499
+ unresolvedDependencyIds: [...new Set(plan.blockers.map((blocker) => blocker.dependsOnId))],
500
+ },
501
+ });
502
+ }
503
+
504
+ #applyStatusCascadePlan(plan: StatusCascadePlan): void {
505
+ for (const change of plan.orderedChanges) {
506
+ if (change.kind === "epic") {
507
+ const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
508
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
509
+ title: epic.title,
510
+ description: epic.description,
511
+ status: epic.status,
512
+ });
513
+ continue;
514
+ }
515
+
516
+ if (change.kind === "task") {
517
+ const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
518
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
519
+ epic_id: task.epicId,
520
+ title: task.title,
521
+ description: task.description,
522
+ status: task.status,
523
+ });
524
+ continue;
525
+ }
526
+
527
+ const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
528
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
529
+ task_id: subtask.taskId,
530
+ title: subtask.title,
531
+ description: subtask.description,
532
+ status: subtask.status,
533
+ });
534
+ }
535
+ }
536
+
461
537
  #applyScopeReplacement(
462
538
  nodes: readonly SearchNode[],
463
539
  searchText: string,
@@ -12,6 +12,7 @@ import {
12
12
  type CompactTaskBatchCreateResult,
13
13
  type CompactTaskSpec,
14
14
  type DependencyRecord,
15
+ type DependencyNodeKind,
15
16
  DomainError,
16
17
  type EpicTreeDetailed,
17
18
  type EpicRecord,
@@ -23,6 +24,11 @@ import {
23
24
  type SearchFieldMatch,
24
25
  type SearchNode,
25
26
  type SearchSummary,
27
+ type StatusCascadeBlocker,
28
+ type StatusCascadeChange,
29
+ type StatusCascadePlan,
30
+ type StatusCascadeRootKind,
31
+ type StatusCascadeScopeNode,
26
32
  type SubtaskRecord,
27
33
  type TaskTreeDetailed,
28
34
  type TaskRecord,
@@ -66,7 +72,7 @@ interface ReverseDependencyRow {
66
72
 
67
73
  interface UnresolvedDependencyBlocker {
68
74
  readonly id: string;
69
- readonly kind: "task" | "subtask";
75
+ readonly kind: DependencyNodeKind;
70
76
  readonly status: string;
71
77
  }
72
78
 
@@ -656,6 +662,40 @@ export class TrackerDomain {
656
662
  };
657
663
  }
658
664
 
665
+ planStatusCascade(rootKind: StatusCascadeRootKind, rootId: string, targetStatus: string): StatusCascadePlan {
666
+ const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
667
+ const scope = this.#collectStatusCascadeScope(rootKind, rootId);
668
+ const scopeIdSet = new Set(scope.map((node) => node.id));
669
+ const orderedChanges = this.#orderStatusCascadeChanges(scope, normalizedTargetStatus);
670
+ const changedIds = orderedChanges.map((change) => change.id);
671
+ const changedIdSet = new Set(changedIds);
672
+ const unchangedIds = scope
673
+ .filter((node) => !changedIdSet.has(node.id))
674
+ .map((node) => node.id);
675
+ const blockers = this.#collectStatusCascadeBlockers(orderedChanges, scopeIdSet, changedIdSet, normalizedTargetStatus);
676
+
677
+ return {
678
+ rootKind,
679
+ rootId,
680
+ targetStatus: normalizedTargetStatus,
681
+ atomic: true,
682
+ scope,
683
+ orderedChanges,
684
+ changedIds,
685
+ unchangedIds,
686
+ blockers,
687
+ counts: {
688
+ scope: scope.length,
689
+ changed: orderedChanges.length,
690
+ unchanged: unchangedIds.length,
691
+ blockers: blockers.length,
692
+ changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
693
+ changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
694
+ changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
695
+ },
696
+ };
697
+ }
698
+
659
699
  collectEpicSearchScope(epicId: string): readonly SearchNode[] {
660
700
  const tree = this.buildEpicTreeDetailed(epicId);
661
701
 
@@ -1266,9 +1306,217 @@ export class TrackerDomain {
1266
1306
  return row !== null;
1267
1307
  }
1268
1308
 
1309
+ #collectStatusCascadeScope(rootKind: StatusCascadeRootKind, rootId: string): StatusCascadeScopeNode[] {
1310
+ if (rootKind === "task") {
1311
+ const tree = this.buildTaskTreeDetailed(rootId);
1312
+ return [
1313
+ {
1314
+ kind: "task",
1315
+ id: tree.id,
1316
+ parentId: tree.epicId,
1317
+ status: tree.status,
1318
+ },
1319
+ ...tree.subtasks.map((subtask) => ({
1320
+ kind: "subtask" as const,
1321
+ id: subtask.id,
1322
+ parentId: subtask.taskId,
1323
+ status: subtask.status,
1324
+ })),
1325
+ ];
1326
+ }
1327
+
1328
+ const tree = this.buildEpicTreeDetailed(rootId);
1329
+ return [
1330
+ {
1331
+ kind: "epic",
1332
+ id: tree.id,
1333
+ status: tree.status,
1334
+ },
1335
+ ...tree.tasks.flatMap((task) => [
1336
+ {
1337
+ kind: "task" as const,
1338
+ id: task.id,
1339
+ parentId: task.epicId,
1340
+ status: task.status,
1341
+ },
1342
+ ...task.subtasks.map((subtask) => ({
1343
+ kind: "subtask" as const,
1344
+ id: subtask.id,
1345
+ parentId: subtask.taskId,
1346
+ status: subtask.status,
1347
+ })),
1348
+ ]),
1349
+ ];
1350
+ }
1351
+
1352
+ #orderStatusCascadeChanges(scope: readonly StatusCascadeScopeNode[], targetStatus: string): StatusCascadeChange[] {
1353
+ const changes = scope
1354
+ .filter((node) => node.status !== targetStatus)
1355
+ .map((node) => {
1356
+ const change: StatusCascadeChange = {
1357
+ kind: node.kind,
1358
+ id: node.id,
1359
+ previousStatus: node.status,
1360
+ nextStatus: targetStatus,
1361
+ ...(node.parentId === undefined ? {} : { parentId: node.parentId }),
1362
+ };
1363
+ return change;
1364
+ });
1365
+
1366
+ if (targetStatus !== "done") {
1367
+ return changes;
1368
+ }
1369
+
1370
+ return this.#topologicallyOrderDoneCascadeChanges(changes);
1371
+ }
1372
+
1373
+ #topologicallyOrderDoneCascadeChanges(changes: readonly StatusCascadeChange[]): StatusCascadeChange[] {
1374
+ const indexById = new Map<string, number>();
1375
+ const changeById = new Map<string, StatusCascadeChange>();
1376
+ const dependencyTargetsBySource = new Map<string, Set<string>>();
1377
+ const dependents = new Map<string, Set<string>>();
1378
+ const indegree = new Map<string, number>();
1379
+
1380
+ changes.forEach((change, index) => {
1381
+ indexById.set(change.id, index);
1382
+ changeById.set(change.id, change);
1383
+ indegree.set(change.id, 0);
1384
+
1385
+ if (change.kind !== "task" && change.kind !== "subtask") {
1386
+ return;
1387
+ }
1388
+
1389
+ const dependencyTargets = new Set(this.listDependencies(change.id).map((dependency) => dependency.dependsOnId));
1390
+ dependencyTargetsBySource.set(change.id, dependencyTargets);
1391
+ });
1392
+
1393
+ const addEdge = (fromId: string, toId: string): void => {
1394
+ if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
1395
+ return;
1396
+ }
1397
+
1398
+ const neighbors = dependents.get(fromId) ?? new Set<string>();
1399
+ if (neighbors.has(toId)) {
1400
+ return;
1401
+ }
1402
+
1403
+ neighbors.add(toId);
1404
+ dependents.set(fromId, neighbors);
1405
+ indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
1406
+ };
1407
+
1408
+ for (const change of changes) {
1409
+ const dependencyTargets = dependencyTargetsBySource.get(change.id);
1410
+
1411
+ if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1412
+ addEdge(change.id, change.parentId);
1413
+ }
1414
+
1415
+ if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1416
+ addEdge(change.id, change.parentId);
1417
+ }
1418
+
1419
+ if (change.kind !== "task" && change.kind !== "subtask") {
1420
+ continue;
1421
+ }
1422
+
1423
+ for (const dependencyTargetId of dependencyTargets ?? []) {
1424
+ addEdge(dependencyTargetId, change.id);
1425
+ }
1426
+ }
1427
+
1428
+ const ordered: StatusCascadeChange[] = [];
1429
+ const ready = changes
1430
+ .filter((change) => (indegree.get(change.id) ?? 0) === 0)
1431
+ .sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1432
+
1433
+ while (ready.length > 0) {
1434
+ const next = ready.shift();
1435
+ if (next === undefined) {
1436
+ continue;
1437
+ }
1438
+
1439
+ ordered.push(next);
1440
+ for (const dependentId of dependents.get(next.id) ?? []) {
1441
+ const remaining = (indegree.get(dependentId) ?? 0) - 1;
1442
+ indegree.set(dependentId, remaining);
1443
+ if (remaining !== 0) {
1444
+ continue;
1445
+ }
1446
+
1447
+ const dependent = changeById.get(dependentId);
1448
+ if (dependent === undefined) {
1449
+ continue;
1450
+ }
1451
+
1452
+ ready.push(dependent);
1453
+ ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1454
+ }
1455
+ }
1456
+
1457
+ if (ordered.length !== changes.length) {
1458
+ throw new DomainError({
1459
+ code: "invalid_dependency",
1460
+ message: "unable to determine dependency-safe cascade order",
1461
+ details: {
1462
+ changedIds: changes.map((change) => change.id),
1463
+ },
1464
+ });
1465
+ }
1466
+
1467
+ return ordered;
1468
+ }
1469
+
1470
+ #collectStatusCascadeBlockers(
1471
+ changes: readonly StatusCascadeChange[],
1472
+ scopeIdSet: ReadonlySet<string>,
1473
+ changedIdSet: ReadonlySet<string>,
1474
+ targetStatus: string,
1475
+ ): StatusCascadeBlocker[] {
1476
+ if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
1477
+ return [];
1478
+ }
1479
+
1480
+ const blockers: StatusCascadeBlocker[] = [];
1481
+ for (const change of changes) {
1482
+ if (change.kind !== "task" && change.kind !== "subtask") {
1483
+ continue;
1484
+ }
1485
+
1486
+ for (const dependency of this.listDependencies(change.id)) {
1487
+ const dependencyStatus =
1488
+ dependency.dependsOnKind === "task"
1489
+ ? this.getTaskOrThrow(dependency.dependsOnId).status
1490
+ : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1491
+ const inScope = scopeIdSet.has(dependency.dependsOnId);
1492
+ const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
1493
+ if (dependencyStatus === "done" || willCascade) {
1494
+ continue;
1495
+ }
1496
+
1497
+ blockers.push({
1498
+ sourceId: dependency.sourceId,
1499
+ sourceKind: dependency.sourceKind,
1500
+ dependsOnId: dependency.dependsOnId,
1501
+ dependsOnKind: dependency.dependsOnKind,
1502
+ dependsOnStatus: dependencyStatus,
1503
+ inScope,
1504
+ willCascade,
1505
+ });
1506
+ }
1507
+ }
1508
+
1509
+ return blockers.sort(
1510
+ (left, right) =>
1511
+ left.sourceId.localeCompare(right.sourceId) ||
1512
+ left.dependsOnId.localeCompare(right.dependsOnId) ||
1513
+ left.dependsOnKind.localeCompare(right.dependsOnKind),
1514
+ );
1515
+ }
1516
+
1269
1517
  private assertNoUnresolvedDependenciesForStatusTransition(
1270
1518
  id: string,
1271
- kind: "task" | "subtask",
1519
+ kind: DependencyNodeKind,
1272
1520
  existingStatus: string,
1273
1521
  nextStatus: string,
1274
1522
  ): void {
@@ -192,6 +192,57 @@ export interface SearchNode {
192
192
  readonly description: string;
193
193
  }
194
194
 
195
+ export type StatusCascadeRootKind = Extract<NodeKind, "epic" | "task">;
196
+ export type DependencyNodeKind = Extract<NodeKind, "task" | "subtask">;
197
+
198
+ export interface StatusCascadeScopeNode {
199
+ readonly kind: NodeKind;
200
+ readonly id: string;
201
+ readonly parentId?: string;
202
+ readonly status: string;
203
+ }
204
+
205
+ export interface StatusCascadeChange {
206
+ readonly kind: NodeKind;
207
+ readonly id: string;
208
+ readonly parentId?: string;
209
+ readonly previousStatus: string;
210
+ readonly nextStatus: string;
211
+ }
212
+
213
+ export interface StatusCascadeBlocker {
214
+ readonly sourceId: string;
215
+ readonly sourceKind: DependencyNodeKind;
216
+ readonly dependsOnId: string;
217
+ readonly dependsOnKind: DependencyNodeKind;
218
+ readonly dependsOnStatus: string;
219
+ readonly inScope: boolean;
220
+ readonly willCascade: boolean;
221
+ }
222
+
223
+ export interface StatusCascadeCounts {
224
+ readonly scope: number;
225
+ readonly changed: number;
226
+ readonly unchanged: number;
227
+ readonly blockers: number;
228
+ readonly changedEpics: number;
229
+ readonly changedTasks: number;
230
+ readonly changedSubtasks: number;
231
+ }
232
+
233
+ export interface StatusCascadePlan {
234
+ readonly rootKind: StatusCascadeRootKind;
235
+ readonly rootId: string;
236
+ readonly targetStatus: string;
237
+ readonly atomic: true;
238
+ readonly scope: ReadonlyArray<StatusCascadeScopeNode>;
239
+ readonly orderedChanges: ReadonlyArray<StatusCascadeChange>;
240
+ readonly changedIds: ReadonlyArray<string>;
241
+ readonly unchangedIds: ReadonlyArray<string>;
242
+ readonly blockers: ReadonlyArray<StatusCascadeBlocker>;
243
+ readonly counts: StatusCascadeCounts;
244
+ }
245
+
195
246
  export interface DomainErrorShape {
196
247
  readonly code: string;
197
248
  readonly message: string;