@critical-path/core 0.19.1 → 0.20.1

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 (46) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/domain/calendar.d.ts +13 -0
  3. package/dist/domain/calendar.d.ts.map +1 -1
  4. package/dist/domain/calendar.js +29 -0
  5. package/dist/domain/calendar.js.map +1 -1
  6. package/dist/domain/calendar.test.js +46 -1
  7. package/dist/domain/calendar.test.js.map +1 -1
  8. package/dist/domain/entities.d.ts +2 -0
  9. package/dist/domain/entities.d.ts.map +1 -1
  10. package/dist/domain/entities.js +55 -7
  11. package/dist/domain/entities.js.map +1 -1
  12. package/dist/domain/metrics.d.ts.map +1 -1
  13. package/dist/domain/metrics.js +14 -2
  14. package/dist/domain/metrics.js.map +1 -1
  15. package/dist/domain/task-duration.test.d.ts +2 -0
  16. package/dist/domain/task-duration.test.d.ts.map +1 -0
  17. package/dist/domain/task-duration.test.js +146 -0
  18. package/dist/domain/task-duration.test.js.map +1 -0
  19. package/dist/engine/index.d.ts.map +1 -1
  20. package/dist/engine/index.js +57 -19
  21. package/dist/engine/index.js.map +1 -1
  22. package/dist/index.test.js +4 -3
  23. package/dist/index.test.js.map +1 -1
  24. package/dist/store/sqlite.d.ts.map +1 -1
  25. package/dist/store/sqlite.js +9 -5
  26. package/dist/store/sqlite.js.map +1 -1
  27. package/dist/task-state.test.js +11 -11
  28. package/dist/task-state.test.js.map +1 -1
  29. package/dist/types/index.d.ts +8 -0
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/dist/utils/status.d.ts.map +1 -1
  32. package/dist/utils/status.js +1 -3
  33. package/dist/utils/status.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/domain/calendar.test.ts +53 -1
  36. package/src/domain/calendar.ts +45 -0
  37. package/src/domain/entities.ts +57 -7
  38. package/src/domain/metrics.ts +14 -2
  39. package/src/domain/task-duration.test.ts +170 -0
  40. package/src/engine/index.ts +60 -21
  41. package/src/index.test.ts +4 -3
  42. package/src/store/sqlite.ts +11 -3
  43. package/src/task-state.test.ts +11 -11
  44. package/src/types/index.ts +8 -0
  45. package/src/utils/status.ts +1 -5
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { TaskEntity } from './entities.js';
3
+ import { CriticalPathEngine } from '../engine/index.js';
4
+ import { InMemoryStore } from '../store/index.js';
5
+ import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
6
+
7
+ describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
8
+ it('stamps actualStartDate and inProgressSince when moving to in_progress', () => {
9
+ const task = TaskEntity.create({
10
+ projectId: 'proj_1',
11
+ title: 'Implement Task Timestamps',
12
+ status: 'todo'
13
+ });
14
+
15
+ expect(task.actualStartDate).toBeUndefined();
16
+ expect(task.actualEndDate).toBeUndefined();
17
+ expect(task.inProgressSince).toBeUndefined();
18
+ expect(task.actualDurationSeconds).toBeUndefined();
19
+
20
+ task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
21
+
22
+ expect(task.actualStartDate).toBeDefined();
23
+ expect(task.inProgressSince).toBeDefined();
24
+ expect(task.actualEndDate).toBeUndefined();
25
+ });
26
+
27
+ it('accumulates in-progress duration across pause/resume cycles and retains earliest actualStartDate', async () => {
28
+ vi.useFakeTimers();
29
+ try {
30
+ const now = new Date('2026-09-23T10:00:00Z');
31
+ vi.setSystemTime(now);
32
+
33
+ const task = TaskEntity.create({
34
+ projectId: 'proj_1',
35
+ title: 'Work Tracking',
36
+ status: 'todo'
37
+ });
38
+
39
+ // 1. Start working at 10:00
40
+ task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
41
+ const initialStartDate = task.actualStartDate;
42
+ expect(initialStartDate).toBe(now.toISOString());
43
+ expect(task.inProgressSince).toBe(now.toISOString());
44
+
45
+ // Fast-forward 30 minutes (1800s)
46
+ vi.setSystemTime(new Date('2026-09-23T10:30:00Z'));
47
+
48
+ // 2. Pause: move to todo
49
+ task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
50
+ expect(task.inProgressSince).toBeNull();
51
+ expect(task.actualDurationSeconds).toBe(1800);
52
+ expect(task.actualHours).toBe(0.5);
53
+ expect(task.actualDurationMinutes).toBe(30);
54
+ expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
55
+
56
+ // Paused for 1 hour
57
+ vi.setSystemTime(new Date('2026-09-23T11:30:00Z'));
58
+
59
+ // 3. Resume: move back to in_progress
60
+ task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
61
+ expect(task.actualStartDate).toBe(initialStartDate); // Still retains earliest
62
+ expect(task.inProgressSince).toBe(new Date('2026-09-23T11:30:00Z').toISOString());
63
+ expect(task.actualDurationSeconds).toBe(1800); // Unchanged until leaving
64
+
65
+ // Fast-forward another 45 minutes (2700s)
66
+ vi.setSystemTime(new Date('2026-09-23T12:15:00Z'));
67
+
68
+ // 4. Complete: move to done
69
+ task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
70
+ expect(task.inProgressSince).toBeNull();
71
+ // Total duration = 1800s + 2700s = 4500s = 1.25h
72
+ expect(task.actualDurationSeconds).toBe(4500);
73
+ expect(task.actualHours).toBe(1.25);
74
+ expect(task.actualDurationMinutes).toBe(75);
75
+ expect(task.actualStartDate).toBe(initialStartDate);
76
+ expect(task.actualEndDate).toBe(new Date('2026-09-23T12:15:00Z').toISOString());
77
+ expect(task.progress).toBe(100);
78
+
79
+ // 5. Reopen: move from done to todo, then to in_progress
80
+ vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
81
+ task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
82
+ expect(task.actualEndDate).toBeUndefined(); // Reset upon reopen
83
+ expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
84
+
85
+ vi.setSystemTime(new Date('2026-09-23T14:15:00Z'));
86
+ task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
87
+ expect(task.actualEndDate).toBeUndefined();
88
+ expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
89
+ expect(task.inProgressSince).toBe(new Date('2026-09-23T14:15:00Z').toISOString());
90
+ } finally {
91
+ vi.useRealTimers();
92
+ }
93
+ });
94
+
95
+ it('keeps actualDurationSeconds and actualHours synchronized on manual updates', () => {
96
+ const task = TaskEntity.create({
97
+ projectId: 'proj_1',
98
+ title: 'Manual Adjustments',
99
+ status: 'todo'
100
+ });
101
+
102
+ // Update actualDurationSeconds directly
103
+ task.update({ actualDurationSeconds: 7200 });
104
+ expect(task.actualHours).toBe(2);
105
+ expect(task.actualDurationMinutes).toBe(120);
106
+
107
+ // Update actualHours directly
108
+ task.update({ actualHours: 1.5 });
109
+ expect(task.actualDurationSeconds).toBe(5400);
110
+ expect(task.actualDurationMinutes).toBe(90);
111
+ });
112
+
113
+ it('integrates with CriticalPathEngine updateTask and metrics calculation', async () => {
114
+ vi.useFakeTimers();
115
+ try {
116
+ const store = new InMemoryStore();
117
+ const engine = new CriticalPathEngine({ store });
118
+
119
+ const project = await engine.createProject({ name: 'Engine Duration Test' });
120
+
121
+ vi.setSystemTime(new Date('2026-09-23T09:00:00Z'));
122
+ const task = await engine.createTask({
123
+ projectId: project.id,
124
+ title: 'Build Feature',
125
+ estimatedHours: 8
126
+ });
127
+
128
+ // 1. Move to in_progress
129
+ const started = await engine.updateTask(task.id, { status: 'in_progress' });
130
+ expect(started?.actualStartDate).toBe('2026-09-23T09:00:00.000Z');
131
+ expect(started?.inProgressSince).toBe('2026-09-23T09:00:00.000Z');
132
+ expect(started?.actualEndDate).toBeUndefined();
133
+
134
+ // 2. Work for 2 hours (7200s), then move to todo (pause)
135
+ vi.setSystemTime(new Date('2026-09-23T11:00:00Z'));
136
+ const paused = await engine.updateTask(task.id, { status: 'todo' });
137
+ expect(paused?.inProgressSince).toBeNull();
138
+ expect(paused?.actualDurationSeconds).toBe(7200);
139
+ expect(paused?.actualHours).toBe(2);
140
+
141
+ // 3. Complete task at 14:00 (move from todo to done)
142
+ vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
143
+ const completed = await engine.updateTask(task.id, { status: 'done' });
144
+ expect(completed?.actualEndDate).toBe('2026-09-23T14:00:00.000Z');
145
+ // in-progress duration should still be 7200s (since time in todo was paused)
146
+ expect(completed?.actualDurationSeconds).toBe(7200);
147
+
148
+ // 4. Verify metrics calculation
149
+ const metrics = await engine.getTaskMetrics(task.id);
150
+ expect(metrics?.inferredActuals.actualDurationSeconds).toBe(7200);
151
+ expect(metrics?.inferredActuals.activeWorkingHours).toBe(2);
152
+ // Calendar duration: 09:00 to 14:00 = 5.0 hours
153
+ expect(metrics?.inferredActuals.calendarDurationHours).toBe(5);
154
+
155
+ // 5. Reopen task (done -> todo -> in_progress): actualEndDate must be cleared (undefined)
156
+ vi.setSystemTime(new Date('2026-09-23T15:00:00Z'));
157
+ const reopenedTodo = await engine.updateTask(task.id, { status: 'todo' });
158
+ expect(reopenedTodo?.actualEndDate).toBeUndefined();
159
+
160
+ vi.setSystemTime(new Date('2026-09-23T15:30:00Z'));
161
+ const reopenedProgress = await engine.updateTask(task.id, { status: 'in_progress' });
162
+ expect(reopenedProgress?.actualEndDate).toBeUndefined();
163
+ expect(reopenedProgress?.actualStartDate).toBe('2026-09-23T09:00:00.000Z'); // Earliest preserved
164
+ expect(reopenedProgress?.inProgressSince).toBe('2026-09-23T15:30:00.000Z');
165
+ expect(reopenedProgress?.actualDurationSeconds).toBe(7200);
166
+ } finally {
167
+ vi.useRealTimers();
168
+ }
169
+ });
170
+ });
@@ -371,6 +371,7 @@ export class CriticalPathEngine {
371
371
  const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
372
372
  const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
373
373
  const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
374
+ const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
374
375
  const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
375
376
 
376
377
  const created = await this.store.createTask({
@@ -400,7 +401,11 @@ export class CriticalPathEngine {
400
401
  estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
401
402
  actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
402
403
  billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
404
+ actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
405
+ inProgressSince,
403
406
  progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
407
+ isBlocked: processedInput.isBlocked ?? taskInput.isBlocked ?? false,
408
+ blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
404
409
  tags: processedInput.tags ?? taskInput.tags ?? [],
405
410
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
406
411
  parentId: processedInput.parentId ?? taskInput.parentId
@@ -515,17 +520,51 @@ export class CriticalPathEngine {
515
520
  existing.status,
516
521
  project?.statusDefinitions || workflow?.statuses
517
522
  );
518
- if (
523
+
524
+ // 1. Leaving in_progress: finalize active session duration
525
+ if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
526
+ const inProgressSince = existing.inProgressSince;
527
+ if (inProgressSince) {
528
+ const startMs = new Date(inProgressSince).getTime();
529
+ const endMs = new Date(now).getTime();
530
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
531
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
532
+ processedUpdates.actualDurationSeconds =
533
+ (processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
534
+ if (processedUpdates.actualHours === undefined) {
535
+ processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
536
+ }
537
+ if (processedUpdates.actualDurationMinutes === undefined) {
538
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
539
+ }
540
+ }
541
+ processedUpdates.inProgressSince = null;
542
+ }
543
+ }
544
+
545
+ // 2. Entering in_progress: retain earliest start date & start session timer
546
+ if (statusDef.category === 'in_progress') {
547
+ if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
548
+ processedUpdates.actualStartDate = now;
549
+ }
550
+ if (!processedUpdates.inProgressSince) {
551
+ processedUpdates.inProgressSince = now;
552
+ }
553
+ if (
554
+ (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
555
+ processedUpdates.actualEndDate === undefined
556
+ ) {
557
+ processedUpdates.actualEndDate = undefined;
558
+ }
559
+ } else if (
519
560
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
520
- (statusDef.category === 'in_progress' || statusDef.category === 'not_started') &&
561
+ statusDef.category === 'not_started' &&
521
562
  processedUpdates.actualEndDate === undefined
522
563
  ) {
523
564
  processedUpdates.actualEndDate = undefined;
524
565
  }
525
566
 
526
- if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
527
- processedUpdates.actualStartDate = now;
528
- }
567
+ // 3. Entering completed or canceled
529
568
  if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
530
569
  processedUpdates.actualEndDate = now;
531
570
  if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
@@ -534,6 +573,19 @@ export class CriticalPathEngine {
534
573
  }
535
574
  }
536
575
 
576
+ // Mutual sync for manual duration/effort edits
577
+ if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
578
+ processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
579
+ if (processedUpdates.actualDurationMinutes === undefined) {
580
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
581
+ }
582
+ } else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
583
+ processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
584
+ if (processedUpdates.actualDurationMinutes === undefined) {
585
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
586
+ }
587
+ }
588
+
537
589
  const updated = await this.store.updateTask(id, processedUpdates);
538
590
  if (!updated) return null;
539
591
 
@@ -586,11 +638,11 @@ export class CriticalPathEngine {
586
638
  await this.events.publish(updateEvent);
587
639
  }
588
640
 
589
- const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
590
- const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
641
+ const wasBlocked = Boolean(existing.isBlocked);
642
+ const isNowBlocked = Boolean(updated.isBlocked);
591
643
 
592
644
  if (!wasBlocked && isNowBlocked) {
593
- const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
645
+ const blockedReason = updated.blockedReason ?? null;
594
646
  const blockedEvent: TaskBlockedEvent = {
595
647
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
596
648
  name: 'task.blocked',
@@ -689,24 +741,11 @@ export class CriticalPathEngine {
689
741
  let updatedDownstream = downstream;
690
742
  const updates: Partial<Task> = {};
691
743
 
692
- if (downstream.status === 'blocked') {
693
- const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
694
- updates.status = defaultStatus;
695
- }
696
-
697
744
  if (downstream.isBlocked || downstream.blockedReason) {
698
745
  updates.isBlocked = false;
699
746
  updates.blockedReason = null;
700
747
  }
701
748
 
702
- if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
703
- updates.customFields = {
704
- ...downstream.customFields,
705
- isBlocked: false,
706
- blockedReason: null
707
- };
708
- }
709
-
710
749
  if (Object.keys(updates).length > 0) {
711
750
  const res = await this.store.updateTask(downstream.id, updates);
712
751
  if (res) updatedDownstream = res;
package/src/index.test.ts CHANGED
@@ -386,8 +386,9 @@ describe('CriticalPathEngine Core Tests', () => {
386
386
  const downstream = await engine.createTask({
387
387
  projectId: proj.id,
388
388
  title: 'Downstream Task',
389
- status: 'blocked',
390
- customFields: { isBlocked: true, blockedReason: 'Waiting on upstream' }
389
+ status: 'todo',
390
+ isBlocked: true,
391
+ blockedReason: 'Waiting on upstream'
391
392
  });
392
393
 
393
394
  await engine.addDependency({
@@ -412,7 +413,7 @@ describe('CriticalPathEngine Core Tests', () => {
412
413
 
413
414
  const refreshedDownstream = await engine.getTask(downstream.id);
414
415
  expect(refreshedDownstream?.status).toBe('todo');
415
- expect(refreshedDownstream?.customFields?.isBlocked).toBe(false);
416
+ expect(refreshedDownstream?.isBlocked).toBe(false);
416
417
  });
417
418
 
418
419
  it('allows VFX workflow transitions from revision_requested to in_production', async () => {
@@ -131,6 +131,8 @@ export class SQLiteStore implements StorageAdapter {
131
131
  estimatedDurationMinutes REAL,
132
132
  actualDurationMinutes REAL,
133
133
  billableDurationMinutes REAL,
134
+ actualDurationSeconds REAL,
135
+ inProgressSince TEXT,
134
136
  progress REAL,
135
137
  isBlocked INTEGER,
136
138
  blockedReason TEXT,
@@ -660,10 +662,11 @@ export class SQLiteStore implements StorageAdapter {
660
662
  id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
661
663
  reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
662
664
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
663
- estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
665
+ estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
666
+ actualDurationSeconds, inProgressSince, progress,
664
667
  isBlocked, blockedReason,
665
668
  tags, customFields, parentId, createdAt, updatedAt
666
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
669
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
667
670
  `);
668
671
  stmt.run(
669
672
  newTask.id,
@@ -692,6 +695,8 @@ export class SQLiteStore implements StorageAdapter {
692
695
  newTask.estimatedDurationMinutes ?? null,
693
696
  newTask.actualDurationMinutes ?? null,
694
697
  newTask.billableDurationMinutes ?? null,
698
+ newTask.actualDurationSeconds ?? null,
699
+ newTask.inProgressSince ?? null,
695
700
  newTask.progress ?? null,
696
701
  newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
697
702
  newTask.blockedReason ?? null,
@@ -720,7 +725,8 @@ export class SQLiteStore implements StorageAdapter {
720
725
  assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
721
726
  plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
722
727
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
723
- estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?, progress = ?,
728
+ estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
729
+ actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
724
730
  isBlocked = ?, blockedReason = ?,
725
731
  tags = ?, customFields = ?, parentId = ?, updatedAt = ?
726
732
  WHERE id = ?
@@ -751,6 +757,8 @@ export class SQLiteStore implements StorageAdapter {
751
757
  updated.estimatedDurationMinutes ?? null,
752
758
  updated.actualDurationMinutes ?? null,
753
759
  updated.billableDurationMinutes ?? null,
760
+ updated.actualDurationSeconds ?? null,
761
+ updated.inProgressSince ?? null,
754
762
  updated.progress ?? null,
755
763
  updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
756
764
  updated.blockedReason ?? null,
@@ -353,12 +353,13 @@ describe('Task State Behaviour & Dependency Validation', () => {
353
353
  status: 'todo'
354
354
  });
355
355
 
356
- // Create downstream task with status: 'blocked' and customFields.isBlocked = true
356
+ // Create downstream task with isBlocked = true
357
357
  const taskDownstream = await engine.createTask({
358
358
  projectId: project.id,
359
359
  title: 'Run Integration Tests',
360
- status: 'blocked',
361
- customFields: { isBlocked: true, blockedReason: 'Waiting on compilation' }
360
+ status: 'todo',
361
+ isBlocked: true,
362
+ blockedReason: 'Waiting on compilation'
362
363
  });
363
364
 
364
365
  await engine.addDependency({
@@ -382,11 +383,11 @@ describe('Task State Behaviour & Dependency Validation', () => {
382
383
  expect(unblockedEvents[0].aggregateId).toBe(taskDownstream.id);
383
384
  expect(unblockedEvents[0].payload.upstreamTaskId).toBe(taskUpstream.id);
384
385
 
385
- // Verify downstream task was automatically transitioned from 'blocked' to 'todo'
386
+ // Verify downstream task is unblocked
386
387
  const updatedDownstream = await engine.getTask(taskDownstream.id);
387
388
  expect(updatedDownstream?.status).toBe('todo');
388
- expect(updatedDownstream?.customFields?.isBlocked).toBe(false);
389
- expect(updatedDownstream?.customFields?.blockedReason).toBeNull();
389
+ expect(updatedDownstream?.isBlocked).toBe(false);
390
+ expect(updatedDownstream?.blockedReason).toBeNull();
390
391
 
391
392
  // Verify lifecycle state is ready and unblocked
392
393
  const lifecycle = await engine.getTaskLifecycleState(taskDownstream.id);
@@ -404,8 +405,8 @@ describe('Task State Behaviour & Dependency Validation', () => {
404
405
  const downstream = await engine.createTask({
405
406
  projectId: project.id,
406
407
  title: 'Final Assembly',
407
- status: 'blocked',
408
- customFields: { isBlocked: true }
408
+ status: 'todo',
409
+ isBlocked: true
409
410
  });
410
411
 
411
412
  await engine.addDependency({ taskId: downstream.id, dependsOnTaskId: up1.id, type: 'blocking' });
@@ -423,8 +424,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
423
424
  expect(unblockedEvents).toHaveLength(0);
424
425
 
425
426
  let taskState = await engine.getTask(downstream.id);
426
- expect(taskState?.status).toBe('blocked');
427
- expect(taskState?.customFields?.isBlocked).toBe(true);
427
+ expect(taskState?.isBlocked).toBe(true);
428
428
 
429
429
  let lifecycle = await engine.getTaskLifecycleState(downstream.id);
430
430
  expect(lifecycle?.isBlocked).toBe(true);
@@ -437,7 +437,7 @@ describe('Task State Behaviour & Dependency Validation', () => {
437
437
  expect(unblockedEvents).toHaveLength(1);
438
438
  taskState = await engine.getTask(downstream.id);
439
439
  expect(taskState?.status).toBe('todo');
440
- expect(taskState?.customFields?.isBlocked).toBe(false);
440
+ expect(taskState?.isBlocked).toBe(false);
441
441
 
442
442
  lifecycle = await engine.getTaskLifecycleState(downstream.id);
443
443
  expect(lifecycle?.isBlocked).toBe(false);
@@ -214,6 +214,10 @@ export interface Task {
214
214
  estimatedDurationMinutes?: number;
215
215
  actualDurationMinutes?: number;
216
216
  billableDurationMinutes?: number;
217
+ /** Cumulative active execution time spent in 'in_progress' status (in seconds) */
218
+ actualDurationSeconds?: number;
219
+ /** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
220
+ inProgressSince?: string | null;
217
221
  // Progress (0 to 100 percentage)
218
222
  progress?: number;
219
223
  isBlocked?: boolean;
@@ -626,6 +630,10 @@ export interface TaskInferredActuals {
626
630
  isStartDateInferred: boolean;
627
631
  isEndDateInferred: boolean;
628
632
  activeWorkingHours?: number;
633
+ /** Calendar elapsed hours between actualStartDate and actualEndDate */
634
+ calendarDurationHours?: number;
635
+ /** Cumulative active execution duration in seconds */
636
+ actualDurationSeconds?: number;
629
637
  }
630
638
 
631
639
  export interface TaskMetrics {
@@ -88,11 +88,7 @@ export function deriveTaskLifecycleState(
88
88
  return upstreamDef.category !== 'completed';
89
89
  });
90
90
  const blockingTaskIds = blockingTasks.map((t) => t.id);
91
- const isExplicitlyBlocked = Boolean(
92
- task.isBlocked === true ||
93
- task.customFields?.isBlocked === true ||
94
- task.status === 'blocked'
95
- );
91
+ const isExplicitlyBlocked = Boolean(task.isBlocked === true);
96
92
  const isBlocked = !isDone && (blockingTaskIds.length > 0 || isExplicitlyBlocked);
97
93
  const isReady = semanticStatus === 'not_started' && !isBlocked;
98
94
 
@@ -1 +1 @@
1
- {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
1
+ {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}