@critical-path/core 0.20.1 → 0.21.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 (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -0
  3. package/dist/domain/entities.d.ts +4 -0
  4. package/dist/domain/entities.d.ts.map +1 -1
  5. package/dist/domain/entities.js +56 -0
  6. package/dist/domain/entities.js.map +1 -1
  7. package/dist/domain/metrics.d.ts.map +1 -1
  8. package/dist/domain/metrics.js +2 -1
  9. package/dist/domain/metrics.js.map +1 -1
  10. package/dist/domain/task-duration.test.js +161 -0
  11. package/dist/domain/task-duration.test.js.map +1 -1
  12. package/dist/engine/index.d.ts.map +1 -1
  13. package/dist/engine/index.js +113 -21
  14. package/dist/engine/index.js.map +1 -1
  15. package/dist/status.test.js +97 -1
  16. package/dist/status.test.js.map +1 -1
  17. package/dist/store/sqlite.d.ts.map +1 -1
  18. package/dist/store/sqlite.js +22 -7
  19. package/dist/store/sqlite.js.map +1 -1
  20. package/dist/types/index.d.ts +7 -0
  21. package/dist/types/index.d.ts.map +1 -1
  22. package/dist/utils/fractional-index.d.ts +50 -0
  23. package/dist/utils/fractional-index.d.ts.map +1 -0
  24. package/dist/utils/fractional-index.js +230 -0
  25. package/dist/utils/fractional-index.js.map +1 -0
  26. package/dist/utils/fractional-index.test.d.ts +2 -0
  27. package/dist/utils/fractional-index.test.d.ts.map +1 -0
  28. package/dist/utils/fractional-index.test.js +151 -0
  29. package/dist/utils/fractional-index.test.js.map +1 -0
  30. package/dist/utils/index.d.ts +1 -0
  31. package/dist/utils/index.d.ts.map +1 -1
  32. package/dist/utils/index.js +1 -0
  33. package/dist/utils/index.js.map +1 -1
  34. package/dist/utils/key.d.ts +5 -0
  35. package/dist/utils/key.d.ts.map +1 -1
  36. package/dist/utils/key.js +10 -0
  37. package/dist/utils/key.js.map +1 -1
  38. package/dist/utils/key.test.js +18 -1
  39. package/dist/utils/key.test.js.map +1 -1
  40. package/dist/utils/mentions.d.ts +13 -5
  41. package/dist/utils/mentions.d.ts.map +1 -1
  42. package/dist/utils/mentions.js +52 -16
  43. package/dist/utils/mentions.js.map +1 -1
  44. package/dist/utils/mentions.test.js +32 -1
  45. package/dist/utils/mentions.test.js.map +1 -1
  46. package/dist/utils/status.d.ts +80 -1
  47. package/dist/utils/status.d.ts.map +1 -1
  48. package/dist/utils/status.js +194 -0
  49. package/dist/utils/status.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/domain/entities.ts +56 -0
  52. package/src/domain/metrics.ts +2 -1
  53. package/src/domain/task-duration.test.ts +184 -0
  54. package/src/engine/index.ts +121 -28
  55. package/src/status.test.ts +130 -2
  56. package/src/store/sqlite.ts +28 -7
  57. package/src/types/index.ts +7 -0
  58. package/src/utils/fractional-index.test.ts +191 -0
  59. package/src/utils/fractional-index.ts +260 -0
  60. package/src/utils/index.ts +2 -0
  61. package/src/utils/key.test.ts +22 -1
  62. package/src/utils/key.ts +11 -0
  63. package/src/utils/mentions.test.ts +37 -1
  64. package/src/utils/mentions.ts +58 -16
  65. package/src/utils/status.ts +247 -0
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -478,7 +478,8 @@ export function calculateTaskMetrics(
478
478
  isEndDateInferred: Boolean(task.actualEndDate && task.semanticStatus === 'completed'),
479
479
  activeWorkingHours,
480
480
  calendarDurationHours,
481
- actualDurationSeconds: task.actualDurationSeconds
481
+ actualDurationSeconds: task.actualDurationSeconds,
482
+ blockedDurationSeconds: task.blockedDurationSeconds
482
483
  };
483
484
 
484
485
  const progress = inferTaskProgress(task, options);
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
2
2
  import { TaskEntity } from './entities.js';
3
3
  import { CriticalPathEngine } from '../engine/index.js';
4
4
  import { InMemoryStore } from '../store/index.js';
5
+ import { SQLiteStore } from '../store/sqlite.js';
5
6
  import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
6
7
 
7
8
  describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
@@ -167,4 +168,187 @@ describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
167
168
  vi.useRealTimers();
168
169
  }
169
170
  });
171
+
172
+ it('tracks blocked duration while task is in_progress', async () => {
173
+ vi.useFakeTimers();
174
+ try {
175
+ const store = new InMemoryStore();
176
+ const engine = new CriticalPathEngine({ store });
177
+
178
+ const project = await engine.createProject({
179
+ name: 'Blocked Project'
180
+ });
181
+
182
+ vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
183
+ const task = await engine.createTask({
184
+ projectId: project.id,
185
+ title: 'Blocked Tracking Feature'
186
+ });
187
+
188
+ // 1. Being blocked in 'todo' does NOT accumulate blocked duration or stamp blockedSince
189
+ await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Waiting for spec' });
190
+ const todoBlocked = await engine.getTask(task.id);
191
+ expect(todoBlocked?.isBlocked).toBe(true);
192
+ expect(todoBlocked?.blockedSince).toBeUndefined();
193
+ expect(todoBlocked?.blockedDurationSeconds).toBeUndefined();
194
+
195
+ // 2. Unblock while still in todo
196
+ await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
197
+
198
+ // 3. Start task at 10:00:00 (in_progress)
199
+ const started = await engine.updateTask(task.id, { status: 'in_progress' });
200
+ expect(started?.inProgressSince).toBe('2026-09-24T10:00:00.000Z');
201
+ expect(started?.blockedSince).toBeUndefined();
202
+
203
+ // 4. Block task at 10:00:30 (30 seconds in)
204
+ vi.setSystemTime(new Date('2026-09-24T10:00:30Z'));
205
+ const blocked = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'API down' });
206
+ expect(blocked?.isBlocked).toBe(true);
207
+ expect(blocked?.blockedSince).toBe('2026-09-24T10:00:30.000Z');
208
+
209
+ // 5. Unblock task at 10:01:00 (30 seconds blocked)
210
+ vi.setSystemTime(new Date('2026-09-24T10:01:00Z'));
211
+ const unblocked = await engine.updateTask(task.id, { isBlocked: false, blockedReason: null });
212
+ expect(unblocked?.isBlocked).toBe(false);
213
+ expect(unblocked?.blockedSince).toBeNull();
214
+ expect(unblocked?.blockedDurationSeconds).toBe(30);
215
+
216
+ // 6. Block again at 10:01:10
217
+ vi.setSystemTime(new Date('2026-09-24T10:01:10Z'));
218
+ const blockedAgain = await engine.updateTask(task.id, { isBlocked: true, blockedReason: 'Review pending' });
219
+ expect(blockedAgain?.blockedSince).toBe('2026-09-24T10:01:10.000Z');
220
+
221
+ // 7. Complete task at 10:01:24 while still blocked (14 seconds blocked)
222
+ // Total gross in-progress: 84s (1m 24s)
223
+ // Total blocked duration: 30s + 14s = 44s
224
+ vi.setSystemTime(new Date('2026-09-24T10:01:24Z'));
225
+ const completed = await engine.updateTask(task.id, { status: 'done' });
226
+ expect(completed?.status).toBe('done');
227
+ expect(completed?.actualDurationSeconds).toBe(84);
228
+ expect(completed?.blockedDurationSeconds).toBe(44);
229
+ expect(completed?.blockedSince).toBeNull();
230
+
231
+ // 8. Verify metrics
232
+ const metrics = await engine.getTaskMetrics(task.id);
233
+ expect(metrics?.inferredActuals.actualDurationSeconds).toBe(84);
234
+ expect(metrics?.inferredActuals.blockedDurationSeconds).toBe(44);
235
+ } finally {
236
+ vi.useRealTimers();
237
+ }
238
+ });
239
+
240
+ it('persists and retrieves blocked duration fields in SQLiteStore', async () => {
241
+ const store = new SQLiteStore({ filename: ':memory:' });
242
+ const project = await store.createProject({ key: 'SQL', name: 'SQLite Project' });
243
+
244
+ const task = await store.createTask({
245
+ projectId: project.id,
246
+ title: 'SQLite Blocked Task',
247
+ status: 'in_progress',
248
+ priority: 'medium',
249
+ isBlocked: true,
250
+ blockedReason: 'Third party dependency',
251
+ blockedSince: '2026-09-24T12:00:00.000Z',
252
+ blockedDurationSeconds: 120
253
+ });
254
+
255
+ const retrieved = await store.getTask(task.id);
256
+ expect(retrieved?.isBlocked).toBe(true);
257
+ expect(retrieved?.blockedReason).toBe('Third party dependency');
258
+ expect(retrieved?.blockedSince).toBe('2026-09-24T12:00:00.000Z');
259
+ expect(retrieved?.blockedDurationSeconds).toBe(120);
260
+
261
+ const updated = await store.updateTask(task.id, {
262
+ isBlocked: false,
263
+ blockedReason: null,
264
+ blockedSince: null,
265
+ blockedDurationSeconds: 180
266
+ });
267
+ expect(updated?.isBlocked).toBe(false);
268
+ expect(updated?.blockedReason).toBeFalsy();
269
+ expect(updated?.blockedSince).toBeFalsy();
270
+ expect(updated?.blockedDurationSeconds).toBe(180);
271
+
272
+ const reloaded = await store.getTask(task.id);
273
+ expect(reloaded?.isBlocked).toBe(false);
274
+ expect(reloaded?.blockedReason).toBeUndefined();
275
+ expect(reloaded?.blockedSince).toBeUndefined();
276
+ expect(reloaded?.blockedDurationSeconds).toBe(180);
277
+ });
278
+
279
+ it('manages blocked state directly on TaskEntity transitions and updates', () => {
280
+ vi.useFakeTimers();
281
+ try {
282
+ vi.setSystemTime(new Date('2026-09-24T12:00:00Z'));
283
+ const task = TaskEntity.create({
284
+ projectId: 'proj_blk',
285
+ title: 'Entity Blocked Test',
286
+ status: 'in_progress'
287
+ });
288
+
289
+ // Block while in progress
290
+ vi.setSystemTime(new Date('2026-09-24T12:00:30Z'));
291
+ task.update({ isBlocked: true, blockedReason: 'Database migration' });
292
+ expect(task.isBlocked).toBe(true);
293
+ expect(task.blockedSince).toBe('2026-09-24T12:00:30.000Z');
294
+
295
+ // Unblock while in progress
296
+ vi.setSystemTime(new Date('2026-09-24T12:01:00Z'));
297
+ task.update({ isBlocked: false, blockedReason: null });
298
+ expect(task.isBlocked).toBe(false);
299
+ expect(task.blockedSince).toBeNull();
300
+ expect(task.blockedDurationSeconds).toBe(30);
301
+
302
+ // Block again
303
+ vi.setSystemTime(new Date('2026-09-24T12:01:10Z'));
304
+ task.update({ isBlocked: true, blockedReason: 'Code review' });
305
+ expect(task.blockedSince).toBe('2026-09-24T12:01:10.000Z');
306
+
307
+ // Transition to completed (done) while blocked
308
+ vi.setSystemTime(new Date('2026-09-24T12:01:30Z'));
309
+ task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
310
+ expect(task.status).toBe('done');
311
+ expect(task.actualDurationSeconds).toBe(90);
312
+ expect(task.blockedDurationSeconds).toBe(50); // 30s + 20s
313
+ expect(task.blockedSince).toBeNull();
314
+ } finally {
315
+ vi.useRealTimers();
316
+ }
317
+ });
318
+
319
+ it('sets completedAt and actualEndDate on completion and resets progress when reopening', async () => {
320
+ vi.useFakeTimers();
321
+ try {
322
+ const store = new InMemoryStore();
323
+ const engine = new CriticalPathEngine({ store });
324
+
325
+ const project = await engine.createProject({ name: 'Progress Transition Proj' });
326
+ vi.setSystemTime(new Date('2026-10-01T10:00:00Z'));
327
+ const task = await engine.createTask({
328
+ projectId: project.id,
329
+ title: 'Task for completion test',
330
+ status: 'in_progress',
331
+ progress: 50
332
+ });
333
+
334
+ // Complete the task
335
+ vi.setSystemTime(new Date('2026-10-01T12:00:00Z'));
336
+ const completed = await engine.updateTask(task.id, { status: 'done' });
337
+ expect(completed?.status).toBe('done');
338
+ expect(completed?.actualEndDate).toBe('2026-10-01T12:00:00.000Z');
339
+ expect(completed?.completedAt).toBe('2026-10-01T12:00:00.000Z');
340
+ expect(completed?.progress).toBe(100);
341
+
342
+ // Reopen to todo -> actualEndDate & completedAt cleared, progress reset from 100 to 0
343
+ vi.setSystemTime(new Date('2026-10-01T13:00:00Z'));
344
+ const reopened = await engine.updateTask(task.id, { status: 'todo' });
345
+ expect(reopened?.status).toBe('todo');
346
+ expect(reopened?.actualEndDate).toBeUndefined();
347
+ expect(reopened?.completedAt).toBeUndefined();
348
+ expect(reopened?.progress).toBe(0);
349
+ } finally {
350
+ vi.useRealTimers();
351
+ }
352
+ });
170
353
  });
354
+
@@ -373,6 +373,11 @@ export class CriticalPathEngine {
373
373
  const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
374
374
  const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
375
375
  const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
376
+ const completedAt = processedInput.completedAt ?? taskInput.completedAt ?? (statusDef.category === 'completed' ? now : undefined);
377
+
378
+ const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
379
+ const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
380
+ const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
376
381
 
377
382
  const created = await this.store.createTask({
378
383
  projectId,
@@ -393,6 +398,7 @@ export class CriticalPathEngine {
393
398
  plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
394
399
  actualStartDate,
395
400
  actualEndDate,
401
+ completedAt,
396
402
  dueDate: processedInput.dueDate ?? taskInput.dueDate,
397
403
  estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
398
404
  loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
@@ -403,8 +409,10 @@ export class CriticalPathEngine {
403
409
  billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
404
410
  actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
405
411
  inProgressSince,
412
+ blockedDurationSeconds,
413
+ blockedSince,
406
414
  progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
407
- isBlocked: processedInput.isBlocked ?? taskInput.isBlocked ?? false,
415
+ isBlocked: isInitialBlocked,
408
416
  blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
409
417
  tags: processedInput.tags ?? taskInput.tags ?? [],
410
418
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
@@ -507,22 +515,25 @@ export class CriticalPathEngine {
507
515
 
508
516
  const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
509
517
 
510
- // Auto-update execution/completion timestamps if status changes
511
- if (processedUpdates.status && processedUpdates.status !== existing.status) {
512
- const statusDef = resolveStatusDefinition(
513
- processedUpdates.status,
514
- project?.statusDefinitions || workflow?.statuses
515
- );
516
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
517
- const now = new Date().toISOString();
518
+ const now = new Date().toISOString();
519
+ const existingStatusDef = resolveStatusDefinition(
520
+ existing.status,
521
+ project?.statusDefinitions || workflow?.statuses
522
+ );
523
+ const newStatusDef = processedUpdates.status
524
+ ? resolveStatusDefinition(
525
+ processedUpdates.status,
526
+ project?.statusDefinitions || workflow?.statuses
527
+ )
528
+ : undefined;
529
+ const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
518
530
 
519
- const existingStatusDef = resolveStatusDefinition(
520
- existing.status,
521
- project?.statusDefinitions || workflow?.statuses
522
- );
531
+ // Auto-update execution/completion timestamps if status changes
532
+ if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
533
+ processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
523
534
 
524
- // 1. Leaving in_progress: finalize active session duration
525
- if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
535
+ // 1. Leaving in_progress: finalize active session duration and blocked duration
536
+ if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
526
537
  const inProgressSince = existing.inProgressSince;
527
538
  if (inProgressSince) {
528
539
  const startMs = new Date(inProgressSince).getTime();
@@ -540,39 +551,113 @@ export class CriticalPathEngine {
540
551
  }
541
552
  processedUpdates.inProgressSince = null;
542
553
  }
554
+
555
+ const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
556
+ if (blockedSince) {
557
+ const startMs = new Date(blockedSince).getTime();
558
+ const endMs = new Date(now).getTime();
559
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
560
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
561
+ processedUpdates.blockedDurationSeconds =
562
+ (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
563
+ }
564
+ processedUpdates.blockedSince = null;
565
+ }
543
566
  }
544
567
 
545
- // 2. Entering in_progress: retain earliest start date & start session timer
546
- if (statusDef.category === 'in_progress') {
568
+ // 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
569
+ if (newStatusDef.category === 'in_progress') {
547
570
  if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
548
571
  processedUpdates.actualStartDate = now;
549
572
  }
550
573
  if (!processedUpdates.inProgressSince) {
551
574
  processedUpdates.inProgressSince = now;
552
575
  }
576
+ const isTaskBlocked = processedUpdates.isBlocked !== undefined
577
+ ? Boolean(processedUpdates.isBlocked)
578
+ : Boolean(existing.isBlocked);
579
+ if (isTaskBlocked && !processedUpdates.blockedSince) {
580
+ processedUpdates.blockedSince = now;
581
+ }
582
+ // Moving from completed/canceled to in_progress resets completion timestamp and progress
553
583
  if (
554
- (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
555
- processedUpdates.actualEndDate === undefined
584
+ existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
556
585
  ) {
557
- processedUpdates.actualEndDate = undefined;
586
+ if (processedUpdates.actualEndDate === undefined) {
587
+ processedUpdates.actualEndDate = undefined;
588
+ }
589
+ if (processedUpdates.completedAt === undefined) {
590
+ processedUpdates.completedAt = undefined;
591
+ }
592
+ if (existing.progress === 100 && processedUpdates.progress === undefined) {
593
+ processedUpdates.progress = 0;
594
+ }
558
595
  }
559
596
  } else if (
560
597
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
561
- statusDef.category === 'not_started' &&
562
- processedUpdates.actualEndDate === undefined
598
+ newStatusDef.category === 'not_started'
563
599
  ) {
564
- processedUpdates.actualEndDate = undefined;
600
+ // Moving from completed/canceled to not_started resets completion timestamp and progress
601
+ if (processedUpdates.actualEndDate === undefined) {
602
+ processedUpdates.actualEndDate = undefined;
603
+ }
604
+ if (processedUpdates.completedAt === undefined) {
605
+ processedUpdates.completedAt = undefined;
606
+ }
607
+ if (existing.progress === 100 && processedUpdates.progress === undefined) {
608
+ processedUpdates.progress = 0;
609
+ }
565
610
  }
566
611
 
567
612
  // 3. Entering completed or canceled
568
- if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
569
- processedUpdates.actualEndDate = now;
570
- if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
613
+ if (newStatusDef.category === 'completed') {
614
+ if (!processedUpdates.actualEndDate) {
615
+ processedUpdates.actualEndDate = now;
616
+ }
617
+ if (!processedUpdates.completedAt) {
618
+ processedUpdates.completedAt = now;
619
+ }
620
+ if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
571
621
  processedUpdates.progress = 100;
572
622
  }
623
+ } else if (newStatusDef.category === 'canceled') {
624
+ if (!processedUpdates.actualEndDate) {
625
+ processedUpdates.actualEndDate = now;
626
+ }
627
+ if (processedUpdates.completedAt === undefined) {
628
+ processedUpdates.completedAt = undefined;
629
+ }
573
630
  }
574
631
  }
575
632
 
633
+ // Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
634
+ if (effectiveCategory === 'in_progress') {
635
+ const wasBlocked = Boolean(existing.isBlocked);
636
+ const isNowBlocked = processedUpdates.isBlocked !== undefined
637
+ ? Boolean(processedUpdates.isBlocked)
638
+ : wasBlocked;
639
+
640
+ if (!wasBlocked && isNowBlocked) {
641
+ if (!processedUpdates.blockedSince) {
642
+ processedUpdates.blockedSince = now;
643
+ }
644
+ } else if (wasBlocked && !isNowBlocked) {
645
+ const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
646
+ if (blockedSince) {
647
+ const startMs = new Date(blockedSince).getTime();
648
+ const endMs = new Date(now).getTime();
649
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
650
+ const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
651
+ processedUpdates.blockedDurationSeconds =
652
+ (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
653
+ }
654
+ processedUpdates.blockedSince = null;
655
+ }
656
+ }
657
+ } else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
658
+ processedUpdates.blockedSince = null;
659
+ }
660
+
576
661
  // Mutual sync for manual duration/effort edits
577
662
  if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
578
663
  processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
@@ -591,7 +676,6 @@ export class CriticalPathEngine {
591
676
 
592
677
  await this.plugins.runAfterTaskUpdate(updated, existing);
593
678
 
594
- const now = new Date().toISOString();
595
679
  const isStatusChange = existing.status !== updated.status;
596
680
 
597
681
  // Publish typed Domain Events
@@ -741,9 +825,18 @@ export class CriticalPathEngine {
741
825
  let updatedDownstream = downstream;
742
826
  const updates: Partial<Task> = {};
743
827
 
744
- if (downstream.isBlocked || downstream.blockedReason) {
828
+ if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
745
829
  updates.isBlocked = false;
746
830
  updates.blockedReason = null;
831
+ if (downstream.blockedSince) {
832
+ const startMs = new Date(downstream.blockedSince).getTime();
833
+ const endMs = new Date().getTime();
834
+ if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
835
+ const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
836
+ updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
837
+ }
838
+ updates.blockedSince = null;
839
+ }
747
840
  }
748
841
 
749
842
  if (Object.keys(updates).length > 0) {
@@ -1,7 +1,22 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { CriticalPathEngine } from './engine/index.js';
3
- import { resolveStatusDefinition, deriveTaskLifecycleState } from './utils/status.js';
4
- import type { Task, StatusDefinition } from './types/index.js';
3
+ import {
4
+ resolveStatusDefinition,
5
+ deriveTaskLifecycleState,
6
+ isDraftTask,
7
+ isArchivedTask,
8
+ isTrashedTask,
9
+ isTrashedOrArchivedTask,
10
+ isWorkflowTask,
11
+ getTaskSemanticStatus,
12
+ isTaskCompleted,
13
+ isTaskInProgress,
14
+ isTaskNotStarted,
15
+ isTaskCanceled,
16
+ isTaskActive,
17
+ isTaskUnassigned
18
+ } from './utils/status.js';
19
+ import type { Task, StatusDefinition, Workflow } from './types/index.js';
5
20
 
6
21
  describe('Universal Semantic Statuses & Implied Statuses', () => {
7
22
  describe('resolveStatusDefinition', () => {
@@ -223,4 +238,117 @@ describe('Universal Semantic Statuses & Implied Statuses', () => {
223
238
  expect(stateB?.blockingTaskIds).toEqual([]);
224
239
  });
225
240
  });
241
+
242
+ describe('Canonical Task Lifecycle & Semantic Status Predicates', () => {
243
+ it('identifies draft, archived, and trashed tasks properly', () => {
244
+ expect(isDraftTask(null)).toBe(false);
245
+ expect(isDraftTask({ isDraft: true })).toBe(true);
246
+ expect(isDraftTask({ isDraft: false })).toBe(false);
247
+
248
+ expect(isArchivedTask(null)).toBe(false);
249
+ expect(isArchivedTask({ archived: true })).toBe(true);
250
+ expect(isArchivedTask({ archivedAt: '2026-09-01T00:00:00Z' })).toBe(true);
251
+ expect(isArchivedTask({ status: 'archived' })).toBe(true);
252
+ expect(isArchivedTask({ customFields: { isArchived: true } })).toBe(true);
253
+ expect(isArchivedTask({ status: 'todo' })).toBe(false);
254
+
255
+ expect(isTrashedTask(null)).toBe(false);
256
+ expect(isTrashedTask({ trashed: true })).toBe(true);
257
+ expect(isTrashedTask({ trashedAt: '2026-09-01T00:00:00Z' })).toBe(true);
258
+ expect(isTrashedTask({ status: 'trashed' })).toBe(true);
259
+ expect(isTrashedTask({ customFields: { isTrashed: true } })).toBe(true);
260
+ expect(isTrashedTask({ status: 'todo' })).toBe(false);
261
+
262
+ expect(isTrashedOrArchivedTask({ trashed: true })).toBe(true);
263
+ expect(isTrashedOrArchivedTask({ archived: true })).toBe(true);
264
+ expect(isTrashedOrArchivedTask({ status: 'todo' })).toBe(false);
265
+ });
266
+
267
+ it('identifies workflow tasks excluding draft, trashed, and archived tasks (UCH-137)', () => {
268
+ expect(isWorkflowTask(null)).toBe(false);
269
+ expect(isWorkflowTask({ status: 'todo' })).toBe(true);
270
+ expect(isWorkflowTask({ status: 'in_progress' })).toBe(true);
271
+ expect(isWorkflowTask({ status: 'done' })).toBe(true);
272
+
273
+ // Draft tasks are excluded
274
+ expect(isWorkflowTask({ status: 'todo', isDraft: true })).toBe(false);
275
+ // Trashed tasks are excluded
276
+ expect(isWorkflowTask({ status: 'todo', trashed: true })).toBe(false);
277
+ // Archived tasks are excluded
278
+ expect(isWorkflowTask({ status: 'done', archived: true })).toBe(false);
279
+ });
280
+
281
+ it('resolves semantic status using workflow, defaults, and custom definitions', () => {
282
+ const mockWorkflow: Workflow = {
283
+ id: 'wf_custom',
284
+ name: 'Custom Flow',
285
+ isDefault: true,
286
+ statuses: [
287
+ { key: 'discovery', label: 'Discovery', category: 'not_started' },
288
+ { key: 'developing', label: 'Developing', category: 'in_progress' },
289
+ { key: 'delivered', label: 'Delivered', category: 'completed' },
290
+ { key: 'dropped', label: 'Dropped', category: 'canceled' }
291
+ ],
292
+ transitions: [],
293
+ createdAt: '2026-09-01T00:00:00Z',
294
+ updatedAt: '2026-09-01T00:00:00Z'
295
+ };
296
+
297
+ expect(getTaskSemanticStatus({ status: 'developing' }, mockWorkflow)).toBe('in_progress');
298
+ expect(getTaskSemanticStatus({ status: 'delivered' }, mockWorkflow)).toBe('completed');
299
+ expect(getTaskSemanticStatus({ status: 'dropped' }, mockWorkflow)).toBe('canceled');
300
+ expect(getTaskSemanticStatus({ status: 'discovery' }, mockWorkflow)).toBe('not_started');
301
+
302
+ // Falls back to standard defaults when not in custom workflow
303
+ expect(getTaskSemanticStatus({ status: 'in_progress' }, mockWorkflow)).toBe('in_progress');
304
+ expect(getTaskSemanticStatus({ status: 'done' })).toBe('completed');
305
+ expect(getTaskSemanticStatus({ status: 'todo' })).toBe('not_started');
306
+ expect(getTaskSemanticStatus({ status: 'canceled' })).toBe('canceled');
307
+ });
308
+
309
+ it('verifies lifecycle predicates (completed, in_progress, not_started, canceled, active)', () => {
310
+ const taskDone = { status: 'done' };
311
+ const taskActive = { status: 'in_progress' };
312
+ const taskTodo = { status: 'todo' };
313
+ const taskCanceled = { status: 'canceled' };
314
+
315
+ expect(isTaskCompleted(taskDone)).toBe(true);
316
+ expect(isTaskCompleted(taskActive)).toBe(false);
317
+
318
+ expect(isTaskInProgress(taskActive)).toBe(true);
319
+ expect(isTaskInProgress(taskDone)).toBe(false);
320
+
321
+ expect(isTaskNotStarted(taskTodo)).toBe(true);
322
+ expect(isTaskNotStarted(taskActive)).toBe(false);
323
+
324
+ expect(isTaskCanceled(taskCanceled)).toBe(true);
325
+ expect(isTaskCanceled(taskDone)).toBe(false);
326
+
327
+ // Active tasks
328
+ expect(isTaskActive(taskTodo)).toBe(true);
329
+ expect(isTaskActive(taskActive)).toBe(true);
330
+ expect(isTaskActive(taskDone)).toBe(false);
331
+ expect(isTaskActive(taskCanceled)).toBe(false);
332
+ expect(isTaskActive({ ...taskActive, isDraft: true })).toBe(false);
333
+ expect(isTaskActive({ ...taskActive, archived: true })).toBe(false);
334
+ expect(isTaskActive({ ...taskActive, trashed: true })).toBe(false);
335
+ });
336
+
337
+ it('identifies unassigned tasks properly', () => {
338
+ expect(isTaskUnassigned(null)).toBe(true);
339
+ expect(isTaskUnassigned({})).toBe(true);
340
+ expect(isTaskUnassigned({ assigneeId: undefined })).toBe(true);
341
+ expect(isTaskUnassigned({ assigneeId: '' })).toBe(true);
342
+ expect(isTaskUnassigned({ assigneeId: 'unassigned' })).toBe(true);
343
+ expect(isTaskUnassigned({ assigneeId: ' UNASSIGNED ' })).toBe(true);
344
+ expect(isTaskUnassigned({ assigneeId: 'user_123' })).toBe(false);
345
+
346
+ // Assignees array checks
347
+ expect(isTaskUnassigned({ assignees: [] })).toBe(true);
348
+ expect(isTaskUnassigned({ assignees: ['unassigned'] })).toBe(true);
349
+ expect(isTaskUnassigned({ assignees: [{ id: 'unassigned', name: 'unassigned' }] })).toBe(true);
350
+ expect(isTaskUnassigned({ assignees: ['user_456'] })).toBe(false);
351
+ expect(isTaskUnassigned({ assignees: [{ id: 'user_456', name: 'Alice' }] })).toBe(false);
352
+ });
353
+ });
226
354
  });