@critical-path/core 0.14.0 → 0.15.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +22 -0
  3. package/dist/deliverables.test.js +2 -2
  4. package/dist/deliverables.test.js.map +1 -1
  5. package/dist/domain/entities.d.ts +2 -1
  6. package/dist/domain/entities.d.ts.map +1 -1
  7. package/dist/domain/entities.js +11 -4
  8. package/dist/domain/entities.js.map +1 -1
  9. package/dist/engine/index.d.ts +5 -3
  10. package/dist/engine/index.d.ts.map +1 -1
  11. package/dist/engine/index.js +22 -12
  12. package/dist/engine/index.js.map +1 -1
  13. package/dist/index.test.js +8 -8
  14. package/dist/index.test.js.map +1 -1
  15. package/dist/status.test.d.ts +2 -0
  16. package/dist/status.test.d.ts.map +1 -0
  17. package/dist/status.test.js +199 -0
  18. package/dist/status.test.js.map +1 -0
  19. package/dist/store/firebase.test.js +1 -1
  20. package/dist/store/firebase.test.js.map +1 -1
  21. package/dist/store/sqlite.test.js +1 -1
  22. package/dist/store/sqlite.test.js.map +1 -1
  23. package/dist/types/index.d.ts +20 -5
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/use-case-validation.test.js +1 -1
  26. package/dist/use-case-validation.test.js.map +1 -1
  27. package/dist/utils/status.d.ts +7 -10
  28. package/dist/utils/status.d.ts.map +1 -1
  29. package/dist/utils/status.js +104 -21
  30. package/dist/utils/status.js.map +1 -1
  31. package/dist/utils/workflow.js +15 -15
  32. package/dist/utils/workflow.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/deliverables.test.ts +2 -2
  35. package/src/domain/entities.ts +15 -5
  36. package/src/engine/index.ts +30 -14
  37. package/src/index.test.ts +8 -8
  38. package/src/status.test.ts +226 -0
  39. package/src/store/firebase.test.ts +1 -1
  40. package/src/store/sqlite.test.ts +1 -1
  41. package/src/types/index.ts +22 -5
  42. package/src/use-case-validation.test.ts +1 -1
  43. package/src/utils/status.ts +119 -33
  44. package/src/utils/workflow.ts +15 -15
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -20,11 +20,12 @@ import type {
20
20
  CreateProjectInput,
21
21
  Deliverable,
22
22
  DeliverableSummary,
23
- CreateDeliverableInput
23
+ CreateDeliverableInput,
24
+ TaskLifecycleState
24
25
  } from '../types/index.js';
25
26
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
26
27
  import { PluginRegistry } from '../plugins/index.js';
27
- import { deriveTaskLifecycleState, type TaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
28
+ import { deriveTaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
28
29
  import {
29
30
  validateTransition,
30
31
  getAllowedNextStatuses,
@@ -335,16 +336,18 @@ export class CriticalPathEngine {
335
336
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
336
337
  const now = new Date().toISOString();
337
338
 
338
- // Derive initial lifecycle timestamps
339
+ // Derive initial lifecycle timestamps and semantic status
339
340
  const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
340
- const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.executionState === 'active' ? now : undefined);
341
- const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? (statusDef.completionState === 'done' ? now : undefined);
341
+ const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
342
+ const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
343
+ const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
342
344
 
343
345
  const created = await this.store.createTask({
344
346
  projectId,
345
347
  title: processedInput.title || taskInput.title,
346
348
  description: processedInput.description ?? taskInput.description,
347
349
  status: initialStatus,
350
+ semanticStatus,
348
351
  priority: processedInput.priority || taskInput.priority || 'medium',
349
352
  taskType: processedInput.taskType || taskInput.taskType || 'task',
350
353
  assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
@@ -366,7 +369,7 @@ export class CriticalPathEngine {
366
369
  estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
367
370
  actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
368
371
  billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
369
- progress: processedInput.progress ?? taskInput.progress ?? (statusDef.completionState === 'done' ? 100 : 0),
372
+ progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
370
373
  tags: processedInput.tags ?? taskInput.tags ?? [],
371
374
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
372
375
  parentId: processedInput.parentId ?? taskInput.parentId
@@ -428,13 +431,14 @@ export class CriticalPathEngine {
428
431
  processedUpdates.status,
429
432
  project?.statusDefinitions || workflow?.statuses
430
433
  );
434
+ processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
431
435
  const now = new Date().toISOString();
432
- if (statusDef.executionState === 'active' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
436
+ if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
433
437
  processedUpdates.actualStartDate = now;
434
438
  }
435
- if (statusDef.completionState === 'done' && !processedUpdates.actualEndDate) {
439
+ if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
436
440
  processedUpdates.actualEndDate = now;
437
- if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
441
+ if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
438
442
  processedUpdates.progress = 100;
439
443
  }
440
444
  }
@@ -590,13 +594,25 @@ export class CriticalPathEngine {
590
594
  };
591
595
  }
592
596
 
593
- async getTaskLifecycleState(taskId: string): Promise<TaskLifecycleState | null> {
597
+ async getTaskLifecycleState(
598
+ taskId: string,
599
+ options?: { referenceDate?: Date; stalledThresholdDays?: number }
600
+ ): Promise<TaskLifecycleState | null> {
594
601
  const task = await this.getTask(taskId);
595
602
  if (!task) return null;
596
603
  const project = await this.getProject(task.projectId);
597
604
  const workflow = await this.resolveProjectWorkflow(task.projectId);
598
605
  const statusDefs = project?.statusDefinitions || workflow?.statuses;
599
- return deriveTaskLifecycleState(task, statusDefs);
606
+
607
+ const graph = await this.getTaskDependencyGraph(taskId);
608
+ const upstreamTasks = graph.upstreamTasks;
609
+
610
+ return deriveTaskLifecycleState(task, {
611
+ customDefinitions: statusDefs,
612
+ referenceDate: options?.referenceDate,
613
+ stalledThresholdDays: options?.stalledThresholdDays,
614
+ upstreamTasks
615
+ });
600
616
  }
601
617
 
602
618
  // --- Time Tracking ---
@@ -1142,9 +1158,9 @@ export class CriticalPathEngine {
1142
1158
 
1143
1159
  for (const task of tasks) {
1144
1160
  const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1145
- if (statusDef.completionState === 'done') {
1161
+ if (statusDef.category === 'completed') {
1146
1162
  completedTasks++;
1147
- } else if (statusDef.executionState === 'active') {
1163
+ } else if (statusDef.category === 'in_progress') {
1148
1164
  activeTasks++;
1149
1165
  }
1150
1166
 
@@ -1154,7 +1170,7 @@ export class CriticalPathEngine {
1154
1170
  if (typeof task.progress === 'number') {
1155
1171
  totalProgress += task.progress;
1156
1172
  } else {
1157
- totalProgress += statusDef.completionState === 'done' ? 100 : 0;
1173
+ totalProgress += statusDef.category === 'completed' ? 100 : 0;
1158
1174
  }
1159
1175
  }
1160
1176
 
package/src/index.test.ts CHANGED
@@ -128,9 +128,9 @@ describe('CriticalPathEngine Core Tests', () => {
128
128
  key: 'CUST',
129
129
  name: 'Custom Workflow Project',
130
130
  statusDefinitions: [
131
- { key: 'draft', label: 'Draft', completionState: 'not_done', executionState: 'inactive' },
132
- { key: 'active_work', label: 'Active Work', completionState: 'not_done', executionState: 'active' },
133
- { key: 'finished', label: 'Finished', completionState: 'done', executionState: 'inactive' }
131
+ { key: 'draft', label: 'Draft', category: 'not_started' },
132
+ { key: 'active_work', label: 'Active Work', category: 'in_progress' },
133
+ { key: 'finished', label: 'Finished', category: 'completed' }
134
134
  ]
135
135
  });
136
136
 
@@ -145,10 +145,10 @@ describe('CriticalPathEngine Core Tests', () => {
145
145
 
146
146
  const state = await engine.getTaskLifecycleState(task.id);
147
147
  expect(state).not.toBeNull();
148
- expect(state?.completionState).toBe('not_done');
149
- expect(state?.executionState).toBe('active');
148
+ expect(state?.semanticStatus).toBe('in_progress');
150
149
  expect(state?.isActive).toBe(true);
151
150
  expect(state?.isDone).toBe(false);
151
+ expect(state?.isBlocked).toBe(false);
152
152
  });
153
153
 
154
154
  it('builds task dependency graph with upstream and downstream tasks', async () => {
@@ -181,9 +181,9 @@ describe('CriticalPathEngine Core Tests', () => {
181
181
  name: 'Strict Software Workflow',
182
182
  defaultStatusKey: 'backlog',
183
183
  statuses: [
184
- { key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' },
185
- { key: 'in_progress', label: 'In Progress', completionState: 'not_done', executionState: 'active' },
186
- { key: 'done', label: 'Done', completionState: 'done', executionState: 'inactive' }
184
+ { key: 'backlog', label: 'Backlog', category: 'not_started' },
185
+ { key: 'in_progress', label: 'In Progress', category: 'in_progress' },
186
+ { key: 'done', label: 'Done', category: 'completed' }
187
187
  ],
188
188
  transitions: [
189
189
  { id: 't1', fromStatusKey: 'backlog', toStatusKey: 'in_progress', name: 'Start Work' },
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CriticalPathEngine } from './engine/index.js';
3
+ import { resolveStatusDefinition, deriveTaskLifecycleState } from './utils/status.js';
4
+ import type { Task, StatusDefinition } from './types/index.js';
5
+
6
+ describe('Universal Semantic Statuses & Implied Statuses', () => {
7
+ describe('resolveStatusDefinition', () => {
8
+ it('resolves core standard statuses to their semantic category', () => {
9
+ expect(resolveStatusDefinition('todo').category).toBe('not_started');
10
+ expect(resolveStatusDefinition('backlog').category).toBe('not_started');
11
+ expect(resolveStatusDefinition('in_progress').category).toBe('in_progress');
12
+ expect(resolveStatusDefinition('in_review').category).toBe('in_progress');
13
+ expect(resolveStatusDefinition('done').category).toBe('completed');
14
+ expect(resolveStatusDefinition('canceled').category).toBe('canceled');
15
+ });
16
+
17
+ it('resolves custom status definitions when provided', () => {
18
+ const customDefs: StatusDefinition[] = [
19
+ { key: 'concept', label: 'Concept & Brainstorming', category: 'not_started' },
20
+ { key: 'client_review', label: 'Client Feedback Session', category: 'in_progress' },
21
+ { key: 'shipped', label: 'Shipped to Customer', category: 'completed' },
22
+ { key: 'abandoned', label: 'Abandoned Initiative', category: 'canceled' }
23
+ ];
24
+
25
+ expect(resolveStatusDefinition('concept', customDefs).category).toBe('not_started');
26
+ expect(resolveStatusDefinition('client_review', customDefs).category).toBe('in_progress');
27
+ expect(resolveStatusDefinition('shipped', customDefs).category).toBe('completed');
28
+ expect(resolveStatusDefinition('abandoned', customDefs).category).toBe('canceled');
29
+ });
30
+
31
+ it('infers category heuristically when unknown status key is provided', () => {
32
+ expect(resolveStatusDefinition('draft_v2').category).toBe('not_started');
33
+ expect(resolveStatusDefinition('final_approved').category).toBe('completed');
34
+ expect(resolveStatusDefinition('active_development').category).toBe('in_progress');
35
+ expect(resolveStatusDefinition('rejected_proposal').category).toBe('canceled');
36
+ expect(resolveStatusDefinition('custom_arbitrary_key').category).toBe('not_started');
37
+ });
38
+ });
39
+
40
+ describe('deriveTaskLifecycleState', () => {
41
+ const baseTask: Task = {
42
+ id: 'task_1',
43
+ projectId: 'proj_1',
44
+ title: 'Design Wireframes',
45
+ status: 'todo',
46
+ priority: 'high',
47
+ createdAt: '2026-09-01T00:00:00.000Z',
48
+ updatedAt: '2026-09-01T00:00:00.000Z'
49
+ };
50
+
51
+ it('derives basic state for not_started task', () => {
52
+ const derived = deriveTaskLifecycleState(baseTask);
53
+ expect(derived.semanticStatus).toBe('not_started');
54
+ expect(derived.isActive).toBe(false);
55
+ expect(derived.isDone).toBe(false);
56
+ expect(derived.isCancelled).toBe(false);
57
+ expect(derived.isReady).toBe(true);
58
+ expect(derived.isBlocked).toBe(false);
59
+ expect(derived.isUnassigned).toBe(true);
60
+ expect(derived.isUnplanned).toBe(true);
61
+ });
62
+
63
+ it('derives blocked and ready states based on upstream tasks', () => {
64
+ const upstream1: Task = {
65
+ ...baseTask,
66
+ id: 'task_up1',
67
+ status: 'in_progress',
68
+ semanticStatus: 'in_progress'
69
+ };
70
+ const upstream2: Task = {
71
+ ...baseTask,
72
+ id: 'task_up2',
73
+ status: 'done',
74
+ semanticStatus: 'completed'
75
+ };
76
+
77
+ // When upstream1 is in progress, task_1 is blocked
78
+ const blockedState = deriveTaskLifecycleState(baseTask, {
79
+ upstreamTasks: [upstream1, upstream2]
80
+ });
81
+ expect(blockedState.isBlocked).toBe(true);
82
+ expect(blockedState.isReady).toBe(false);
83
+ expect(blockedState.blockingTaskIds).toEqual(['task_up1']);
84
+
85
+ // When all upstream tasks are completed, task_1 becomes ready
86
+ const allDoneState = deriveTaskLifecycleState(baseTask, {
87
+ upstreamTasks: [
88
+ { ...upstream1, status: 'done', semanticStatus: 'completed' },
89
+ upstream2
90
+ ]
91
+ });
92
+ expect(allDoneState.isBlocked).toBe(false);
93
+ expect(allDoneState.isReady).toBe(true);
94
+ expect(allDoneState.blockingTaskIds).toHaveLength(0);
95
+ });
96
+
97
+ it('derives schedule indicators: overdue, upcoming, unplanned', () => {
98
+ const refDate = new Date('2026-09-10T12:00:00.000Z');
99
+
100
+ // Overdue task
101
+ const overdueTask: Task = {
102
+ ...baseTask,
103
+ dueDate: '2026-09-08T00:00:00.000Z'
104
+ };
105
+ const overdueDerived = deriveTaskLifecycleState(overdueTask, { referenceDate: refDate });
106
+ expect(overdueDerived.isOverdue).toBe(true);
107
+ expect(overdueDerived.isUnplanned).toBe(false);
108
+
109
+ // Completed task is not overdue even if dueDate is past
110
+ const completedTask: Task = {
111
+ ...overdueTask,
112
+ status: 'done'
113
+ };
114
+ const completedDerived = deriveTaskLifecycleState(completedTask, { referenceDate: refDate });
115
+ expect(completedDerived.isOverdue).toBe(false);
116
+ expect(completedDerived.isDone).toBe(true);
117
+
118
+ // Upcoming task
119
+ const upcomingTask: Task = {
120
+ ...baseTask,
121
+ plannedStartDate: '2026-09-12T00:00:00.000Z' // within 3 days of Sept 10
122
+ };
123
+ const upcomingDerived = deriveTaskLifecycleState(upcomingTask, { referenceDate: refDate });
124
+ expect(upcomingDerived.isUpcoming).toBe(true);
125
+ });
126
+
127
+ it('derives resource indicators: unassigned and stalled', () => {
128
+ const refDate = new Date('2026-09-15T12:00:00.000Z');
129
+
130
+ // Assigned via assigneeId
131
+ const assignedTask1: Task = {
132
+ ...baseTask,
133
+ assigneeId: 'user_alice'
134
+ };
135
+ expect(deriveTaskLifecycleState(assignedTask1).isUnassigned).toBe(false);
136
+
137
+ // Assigned via assignees array
138
+ const assignedTask2: Task = {
139
+ ...baseTask,
140
+ assignees: [{ id: 'user_bob' }]
141
+ };
142
+ expect(deriveTaskLifecycleState(assignedTask2).isUnassigned).toBe(false);
143
+
144
+ // Stalled task: in_progress with no update in > 7 days
145
+ const stalledTask: Task = {
146
+ ...baseTask,
147
+ status: 'in_progress',
148
+ updatedAt: '2026-09-01T00:00:00.000Z'
149
+ };
150
+ const stalledDerived = deriveTaskLifecycleState(stalledTask, { referenceDate: refDate, stalledThresholdDays: 7 });
151
+ expect(stalledDerived.isStalled).toBe(true);
152
+
153
+ // Not stalled if updated recently
154
+ const activeTask: Task = {
155
+ ...stalledTask,
156
+ updatedAt: '2026-09-14T00:00:00.000Z'
157
+ };
158
+ expect(deriveTaskLifecycleState(activeTask, { referenceDate: refDate, stalledThresholdDays: 7 }).isStalled).toBe(false);
159
+ });
160
+
161
+ it('derives estimate indicators: isOverEstimate and isPaceWarning', () => {
162
+ // Over estimate by logged hours
163
+ const overEstimateTask: Task = {
164
+ ...baseTask,
165
+ status: 'in_progress',
166
+ estimatedHours: 10,
167
+ loggedHours: 12
168
+ };
169
+ const overEstDerived = deriveTaskLifecycleState(overEstimateTask);
170
+ expect(overEstDerived.isOverEstimate).toBe(true);
171
+
172
+ // Pace warning: in_progress task that has been active longer than estimated duration
173
+ const refDate = new Date('2026-09-10T00:00:00.000Z');
174
+ const paceWarningTask: Task = {
175
+ ...baseTask,
176
+ status: 'in_progress',
177
+ actualStartDate: '2026-09-01T00:00:00.000Z', // 9 days = 216 hours elapsed
178
+ estimatedHours: 40 // only 40 hours estimated
179
+ };
180
+ const paceDerived = deriveTaskLifecycleState(paceWarningTask, { referenceDate: refDate });
181
+ expect(paceDerived.isPaceWarning).toBe(true);
182
+ });
183
+ });
184
+
185
+ describe('Engine Integration', () => {
186
+ it('computes full lifecycle state with dependency graph integration', async () => {
187
+ const engine = new CriticalPathEngine();
188
+ const proj = await engine.createProject({ key: 'LIFECYCLE', name: 'Lifecycle Test' });
189
+
190
+ const taskA = await engine.createTask({
191
+ projectId: proj.id,
192
+ title: 'Task A (Dependency)',
193
+ status: 'todo',
194
+ priority: 'high'
195
+ });
196
+
197
+ const taskB = await engine.createTask({
198
+ projectId: proj.id,
199
+ title: 'Task B (Dependent)',
200
+ status: 'todo',
201
+ priority: 'medium'
202
+ });
203
+
204
+ await engine.store.addDependency({
205
+ taskId: taskB.id,
206
+ dependsOnTaskId: taskA.id,
207
+ type: 'blocking'
208
+ });
209
+
210
+ // Task B should be blocked by Task A
211
+ let stateB = await engine.getTaskLifecycleState(taskB.id);
212
+ expect(stateB?.isBlocked).toBe(true);
213
+ expect(stateB?.isReady).toBe(false);
214
+ expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
215
+
216
+ // Complete Task A
217
+ await engine.updateTask(taskA.id, { status: 'done' });
218
+
219
+ // Task B should now be ready
220
+ stateB = await engine.getTaskLifecycleState(taskB.id);
221
+ expect(stateB?.isBlocked).toBe(false);
222
+ expect(stateB?.isReady).toBe(true);
223
+ expect(stateB?.blockingTaskIds).toEqual([]);
224
+ });
225
+ });
226
+ });
@@ -62,7 +62,7 @@ describe('FirebaseStore', () => {
62
62
  name: 'Firebase Workflow',
63
63
  isDefault: false,
64
64
  defaultStatusKey: 'backlog',
65
- statuses: [{ key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' }],
65
+ statuses: [{ key: 'backlog', label: 'Backlog', category: 'not_started' }],
66
66
  transitions: []
67
67
  });
68
68
 
@@ -155,7 +155,7 @@ describe('SQLiteStore', () => {
155
155
  name: 'SQLite Workflow',
156
156
  isDefault: true,
157
157
  defaultStatusKey: 'todo',
158
- statuses: [{ key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' }],
158
+ statuses: [{ key: 'todo', label: 'To Do', category: 'not_started' }],
159
159
  transitions: []
160
160
  });
161
161
 
@@ -4,17 +4,33 @@ export type Priority = DefaultPriority | (string & {});
4
4
  export type DefaultTaskStatus = 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'canceled';
5
5
  export type TaskStatus = DefaultTaskStatus | (string & {});
6
6
 
7
- export type CompletionState = 'done' | 'not_done';
8
- export type ExecutionState = 'active' | 'inactive';
7
+ export type SemanticStatus = 'not_started' | 'in_progress' | 'completed' | 'canceled';
9
8
 
10
9
  export interface StatusDefinition {
11
10
  key: string;
12
11
  label: string;
13
- completionState: CompletionState;
14
- executionState: ExecutionState;
15
- isCancelled?: boolean;
12
+ category: SemanticStatus;
16
13
  }
17
14
 
15
+ export interface TaskDerivedStatus {
16
+ semanticStatus: SemanticStatus;
17
+ isReady: boolean;
18
+ isBlocked: boolean;
19
+ blockingTaskIds: string[];
20
+ isOverdue: boolean;
21
+ isUpcoming: boolean;
22
+ isUnplanned: boolean;
23
+ isUnassigned: boolean;
24
+ isStalled: boolean;
25
+ isOverEstimate: boolean;
26
+ isPaceWarning: boolean;
27
+ isDone: boolean;
28
+ isActive: boolean;
29
+ isCancelled: boolean;
30
+ }
31
+
32
+ export type TaskLifecycleState = TaskDerivedStatus;
33
+
18
34
  export type Role = 'admin' | 'project_manager' | 'contributor' | 'viewer';
19
35
 
20
36
  export interface User {
@@ -142,6 +158,7 @@ export interface Task {
142
158
  title: string;
143
159
  description?: string;
144
160
  status: TaskStatus;
161
+ semanticStatus?: SemanticStatus;
145
162
  priority: Priority;
146
163
  taskType?: string; // e.g. "bug", "feature", "task", "epic"
147
164
  assigneeId?: string;
@@ -138,7 +138,7 @@ describe('Critical Path Use Case Validation Suite', () => {
138
138
  // Check Lifecycle State
139
139
  const lifecycle = await engine.getTaskLifecycleState(featureTask.id);
140
140
  expect(lifecycle?.isDone).toBe(true);
141
- expect(lifecycle?.completionState).toBe('done');
141
+ expect(lifecycle?.semanticStatus).toBe('completed');
142
142
  });
143
143
  });
144
144
 
@@ -1,27 +1,24 @@
1
1
  import type {
2
2
  Task,
3
3
  StatusDefinition,
4
- CompletionState,
5
- ExecutionState
4
+ SemanticStatus,
5
+ TaskDerivedStatus,
6
6
  } from '../types/index.js';
7
7
 
8
8
  export const DEFAULT_STATUS_DEFINITIONS: Record<string, StatusDefinition> = {
9
- backlog: { key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' },
10
- todo: { key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' },
11
- in_progress: { key: 'in_progress', label: 'In Progress', completionState: 'not_done', executionState: 'active' },
12
- in_review: { key: 'in_review', label: 'In Review', completionState: 'not_done', executionState: 'active' },
13
- done: { key: 'done', label: 'Done', completionState: 'done', executionState: 'inactive' },
14
- canceled: { key: 'canceled', label: 'Canceled', completionState: 'done', executionState: 'inactive', isCancelled: true }
9
+ backlog: { key: 'backlog', label: 'Backlog', category: 'not_started' },
10
+ todo: { key: 'todo', label: 'To Do', category: 'not_started' },
11
+ in_progress: { key: 'in_progress', label: 'In Progress', category: 'in_progress' },
12
+ in_review: { key: 'in_review', label: 'In Review', category: 'in_progress' },
13
+ done: { key: 'done', label: 'Done', category: 'completed' },
14
+ canceled: { key: 'canceled', label: 'Canceled', category: 'canceled' }
15
15
  };
16
16
 
17
- export interface TaskLifecycleState {
18
- completionState: CompletionState;
19
- executionState: ExecutionState;
20
- isDone: boolean;
21
- isActive: boolean;
22
- isCancelled: boolean;
23
- isOverdue: boolean;
24
- isUpcoming: boolean;
17
+ export interface DeriveTaskLifecycleOptions {
18
+ customDefinitions?: StatusDefinition[];
19
+ referenceDate?: Date;
20
+ upstreamTasks?: Task[];
21
+ stalledThresholdDays?: number;
25
22
  }
26
23
 
27
24
  export function resolveStatusDefinition(
@@ -35,28 +32,66 @@ export function resolveStatusDefinition(
35
32
  if (DEFAULT_STATUS_DEFINITIONS[statusKey]) {
36
33
  return DEFAULT_STATUS_DEFINITIONS[statusKey];
37
34
  }
35
+
38
36
  // Fallback heuristic for unknown custom statuses:
39
- const isDoneKey = ['done', 'completed', 'finished', 'closed', 'canceled', 'cancelled', 'approved', 'final'].includes(statusKey.toLowerCase());
40
- const isActiveKey = ['in_progress', 'active', 'working', 'in_review', 'review'].includes(statusKey.toLowerCase());
37
+ const lower = statusKey.toLowerCase();
38
+ let category: SemanticStatus = 'not_started';
39
+ if (['canceled', 'cancelled', 'rejected', 'abandoned', 'void', 'dropped'].some((k) => lower.includes(k))) {
40
+ category = 'canceled';
41
+ } else if (['done', 'completed', 'finished', 'closed', 'approved', 'final', 'delivered', 'shipped'].some((k) => lower.includes(k))) {
42
+ category = 'completed';
43
+ } else if (['in_progress', 'active', 'working', 'in_review', 'review', 'testing', 'qa', 'building', 'editing'].some((k) => lower.includes(k))) {
44
+ category = 'in_progress';
45
+ }
46
+
41
47
  return {
42
48
  key: statusKey,
43
49
  label: statusKey,
44
- completionState: isDoneKey ? 'done' : 'not_done',
45
- executionState: isActiveKey ? 'active' : 'inactive',
46
- isCancelled: statusKey.toLowerCase().includes('cancel')
50
+ category
47
51
  };
48
52
  }
49
53
 
50
54
  export function deriveTaskLifecycleState(
51
55
  task: Task,
52
- customDefinitions?: StatusDefinition[],
53
- referenceDate: Date = new Date()
54
- ): TaskLifecycleState {
56
+ customDefinitionsOrOptions?: StatusDefinition[] | DeriveTaskLifecycleOptions,
57
+ legacyReferenceDate?: Date
58
+ ): TaskDerivedStatus {
59
+ let customDefinitions: StatusDefinition[] | undefined;
60
+ let referenceDate = legacyReferenceDate ?? new Date();
61
+ let upstreamTasks: Task[] = [];
62
+ let stalledThresholdDays = 5;
63
+
64
+ if (Array.isArray(customDefinitionsOrOptions)) {
65
+ customDefinitions = customDefinitionsOrOptions;
66
+ } else if (customDefinitionsOrOptions) {
67
+ customDefinitions = customDefinitionsOrOptions.customDefinitions;
68
+ if (customDefinitionsOrOptions.referenceDate) {
69
+ referenceDate = customDefinitionsOrOptions.referenceDate;
70
+ }
71
+ if (customDefinitionsOrOptions.upstreamTasks) {
72
+ upstreamTasks = customDefinitionsOrOptions.upstreamTasks;
73
+ }
74
+ if (customDefinitionsOrOptions.stalledThresholdDays !== undefined) {
75
+ stalledThresholdDays = customDefinitionsOrOptions.stalledThresholdDays;
76
+ }
77
+ }
78
+
55
79
  const def = resolveStatusDefinition(task.status, customDefinitions);
56
- const isDone = def.completionState === 'done';
57
- const isActive = def.executionState === 'active';
58
- const isCancelled = Boolean(def.isCancelled);
80
+ const semanticStatus: SemanticStatus = def.category;
81
+ const isDone = semanticStatus === 'completed' || semanticStatus === 'canceled';
82
+ const isActive = semanticStatus === 'in_progress';
83
+ const isCancelled = semanticStatus === 'canceled';
59
84
 
85
+ // Dependency & Flow Indicators
86
+ const blockingTasks = upstreamTasks.filter((t) => {
87
+ const upstreamDef = resolveStatusDefinition(t.status, customDefinitions);
88
+ return upstreamDef.category !== 'completed';
89
+ });
90
+ const blockingTaskIds = blockingTasks.map((t) => t.id);
91
+ const isBlocked = !isDone && blockingTaskIds.length > 0;
92
+ const isReady = semanticStatus === 'not_started' && blockingTaskIds.length === 0;
93
+
94
+ // Time & Schedule Indicators
60
95
  let isOverdue = false;
61
96
  if (!isDone && task.dueDate) {
62
97
  const due = new Date(task.dueDate);
@@ -66,20 +101,71 @@ export function deriveTaskLifecycleState(
66
101
  }
67
102
 
68
103
  let isUpcoming = false;
69
- if (!isDone && !isActive && task.plannedStartDate) {
104
+ if (semanticStatus === 'not_started' && task.plannedStartDate) {
70
105
  const plannedStart = new Date(task.plannedStartDate);
71
106
  if (!isNaN(plannedStart.getTime()) && plannedStart > referenceDate) {
72
107
  isUpcoming = true;
73
108
  }
74
109
  }
75
110
 
111
+ const isUnplanned = semanticStatus === 'not_started' && !task.iterationId && !task.plannedStartDate && !task.dueDate;
112
+
113
+ // Resource & Activity Indicators
114
+ const hasAssignee = Boolean(task.assigneeId || (task.assignees && task.assignees.length > 0));
115
+ const isUnassigned = !isDone && !hasAssignee;
116
+
117
+ let isStalled = false;
118
+ if (semanticStatus === 'in_progress') {
119
+ const lastActivity = task.updatedAt ? new Date(task.updatedAt) : (task.createdAt ? new Date(task.createdAt) : null);
120
+ if (lastActivity && !isNaN(lastActivity.getTime())) {
121
+ const thresholdMs = stalledThresholdDays * 24 * 60 * 60 * 1000;
122
+ if (referenceDate.getTime() - lastActivity.getTime() > thresholdMs) {
123
+ isStalled = true;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Effort & Estimate Indicators
129
+ let isOverEstimate = false;
130
+ const loggedH = task.loggedHours ?? task.actualHours;
131
+ const estimatedH = task.estimatedHours;
132
+ if (estimatedH !== undefined && estimatedH > 0 && loggedH !== undefined && loggedH > estimatedH) {
133
+ isOverEstimate = true;
134
+ }
135
+ const actualM = task.actualDurationMinutes;
136
+ const estimatedM = task.estimatedDurationMinutes;
137
+ if (estimatedM !== undefined && estimatedM > 0 && actualM !== undefined && actualM > estimatedM) {
138
+ isOverEstimate = true;
139
+ }
140
+
141
+ let isPaceWarning = false;
142
+ if (semanticStatus === 'in_progress' && task.actualStartDate) {
143
+ const start = new Date(task.actualStartDate);
144
+ if (!isNaN(start.getTime())) {
145
+ const elapsedMs = referenceDate.getTime() - start.getTime();
146
+ const elapsedHours = elapsedMs / (1000 * 60 * 60);
147
+ if (estimatedH !== undefined && estimatedH > 0 && elapsedHours > estimatedH) {
148
+ isPaceWarning = true;
149
+ } else if (estimatedM !== undefined && estimatedM > 0 && (elapsedMs / (1000 * 60)) > estimatedM) {
150
+ isPaceWarning = true;
151
+ }
152
+ }
153
+ }
154
+
76
155
  return {
77
- completionState: def.completionState,
78
- executionState: def.executionState,
156
+ semanticStatus,
157
+ isReady,
158
+ isBlocked,
159
+ blockingTaskIds,
160
+ isOverdue,
161
+ isUpcoming,
162
+ isUnplanned,
163
+ isUnassigned,
164
+ isStalled,
165
+ isOverEstimate,
166
+ isPaceWarning,
79
167
  isDone,
80
168
  isActive,
81
- isCancelled,
82
- isOverdue,
83
- isUpcoming
169
+ isCancelled
84
170
  };
85
171
  }