@critical-path/core 0.14.0 → 0.15.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 (55) hide show
  1. package/CHANGELOG.md +24 -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/domain/events.d.ts +8 -1
  10. package/dist/domain/events.d.ts.map +1 -1
  11. package/dist/domain/events.js.map +1 -1
  12. package/dist/engine/index.d.ts +7 -3
  13. package/dist/engine/index.d.ts.map +1 -1
  14. package/dist/engine/index.js +102 -13
  15. package/dist/engine/index.js.map +1 -1
  16. package/dist/index.test.js +64 -9
  17. package/dist/index.test.js.map +1 -1
  18. package/dist/status.test.d.ts +2 -0
  19. package/dist/status.test.d.ts.map +1 -0
  20. package/dist/status.test.js +199 -0
  21. package/dist/status.test.js.map +1 -0
  22. package/dist/store/firebase.d.ts.map +1 -1
  23. package/dist/store/firebase.js +12 -2
  24. package/dist/store/firebase.js.map +1 -1
  25. package/dist/store/firebase.test.js +14 -1
  26. package/dist/store/firebase.test.js.map +1 -1
  27. package/dist/store/sqlite.test.js +1 -1
  28. package/dist/store/sqlite.test.js.map +1 -1
  29. package/dist/types/index.d.ts +21 -6
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/dist/use-case-validation.test.js +1 -1
  32. package/dist/use-case-validation.test.js.map +1 -1
  33. package/dist/utils/status.d.ts +7 -10
  34. package/dist/utils/status.d.ts.map +1 -1
  35. package/dist/utils/status.js +104 -21
  36. package/dist/utils/status.js.map +1 -1
  37. package/dist/utils/workflow.d.ts +1 -0
  38. package/dist/utils/workflow.d.ts.map +1 -1
  39. package/dist/utils/workflow.js +65 -18
  40. package/dist/utils/workflow.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/deliverables.test.ts +2 -2
  43. package/src/domain/entities.ts +15 -5
  44. package/src/domain/events.ts +9 -0
  45. package/src/engine/index.ts +123 -14
  46. package/src/index.test.ts +77 -9
  47. package/src/status.test.ts +226 -0
  48. package/src/store/firebase.test.ts +17 -1
  49. package/src/store/firebase.ts +12 -2
  50. package/src/store/sqlite.test.ts +1 -1
  51. package/src/types/index.ts +23 -5
  52. package/src/use-case-validation.test.ts +1 -1
  53. package/src/utils/status.ts +119 -33
  54. package/src/utils/workflow.ts +75 -18
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -20,15 +20,17 @@ 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,
31
32
  getAllowedPreviousStatuses,
33
+ getAllowedTransitions,
32
34
  WorkflowValidationError,
33
35
  DEFAULT_SOFTWARE_WORKFLOW
34
36
  } from '../utils/workflow.js';
@@ -38,6 +40,7 @@ import {
38
40
  TaskCreatedEvent,
39
41
  TaskUpdatedEvent,
40
42
  TaskStatusChangedEvent,
43
+ TaskUnblockedEvent,
41
44
  TaskDeletedEvent,
42
45
  ProjectCreatedEvent,
43
46
  ProjectUpdatedEvent,
@@ -234,6 +237,13 @@ export class CriticalPathEngine {
234
237
  }
235
238
 
236
239
  async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
240
+ const task = await this.store.getTask(taskId);
241
+ if (!task) return [];
242
+ const workflow = await this.resolveProjectWorkflow(task.projectId);
243
+ return getAllowedTransitions(workflow || undefined, task.status);
244
+ }
245
+
246
+ async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
237
247
  const task = await this.store.getTask(taskId);
238
248
  if (!task) return [];
239
249
  const workflow = await this.resolveProjectWorkflow(task.projectId);
@@ -335,16 +345,18 @@ export class CriticalPathEngine {
335
345
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
336
346
  const now = new Date().toISOString();
337
347
 
338
- // Derive initial lifecycle timestamps
348
+ // Derive initial lifecycle timestamps and semantic status
339
349
  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);
350
+ const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
351
+ const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
352
+ const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
342
353
 
343
354
  const created = await this.store.createTask({
344
355
  projectId,
345
356
  title: processedInput.title || taskInput.title,
346
357
  description: processedInput.description ?? taskInput.description,
347
358
  status: initialStatus,
359
+ semanticStatus,
348
360
  priority: processedInput.priority || taskInput.priority || 'medium',
349
361
  taskType: processedInput.taskType || taskInput.taskType || 'task',
350
362
  assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
@@ -366,7 +378,7 @@ export class CriticalPathEngine {
366
378
  estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
367
379
  actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
368
380
  billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
369
- progress: processedInput.progress ?? taskInput.progress ?? (statusDef.completionState === 'done' ? 100 : 0),
381
+ progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
370
382
  tags: processedInput.tags ?? taskInput.tags ?? [],
371
383
  customFields: processedInput.customFields ?? taskInput.customFields ?? {},
372
384
  parentId: processedInput.parentId ?? taskInput.parentId
@@ -428,13 +440,14 @@ export class CriticalPathEngine {
428
440
  processedUpdates.status,
429
441
  project?.statusDefinitions || workflow?.statuses
430
442
  );
443
+ processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
431
444
  const now = new Date().toISOString();
432
- if (statusDef.executionState === 'active' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
445
+ if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
433
446
  processedUpdates.actualStartDate = now;
434
447
  }
435
- if (statusDef.completionState === 'done' && !processedUpdates.actualEndDate) {
448
+ if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
436
449
  processedUpdates.actualEndDate = now;
437
- if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
450
+ if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
438
451
  processedUpdates.progress = 100;
439
452
  }
440
453
  }
@@ -463,6 +476,14 @@ export class CriticalPathEngine {
463
476
  }
464
477
  };
465
478
  await this.events.publish(statusEvent);
479
+
480
+ const statusDef = resolveStatusDefinition(
481
+ updated.status,
482
+ project?.statusDefinitions || workflow?.statuses
483
+ );
484
+ if (statusDef.category === 'completed') {
485
+ await this.checkAndUnblockDownstreamTasks(updated);
486
+ }
466
487
  } else {
467
488
  const updateEvent: TaskUpdatedEvent = {
468
489
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -494,6 +515,82 @@ export class CriticalPathEngine {
494
515
  return updated;
495
516
  }
496
517
 
518
+ private async checkAndUnblockDownstreamTasks(completedTask: Task): Promise<void> {
519
+ try {
520
+ const graph = await this.getTaskDependencyGraph(completedTask.id);
521
+ if (!graph.downstreamTasks || graph.downstreamTasks.length === 0) return;
522
+
523
+ for (const downstream of graph.downstreamTasks) {
524
+ const downstreamProject = await this.store.getProject(downstream.projectId);
525
+ const downstreamWorkflow = await this.resolveProjectWorkflow(downstream.projectId);
526
+ const downstreamStatusDefs = downstreamProject?.statusDefinitions || downstreamWorkflow?.statuses;
527
+ const downstreamStatusDef = resolveStatusDefinition(downstream.status, downstreamStatusDefs);
528
+
529
+ if (downstreamStatusDef.category === 'completed' || downstreamStatusDef.category === 'canceled') {
530
+ continue;
531
+ }
532
+
533
+ const downstreamGraph = await this.getTaskDependencyGraph(downstream.id);
534
+ const allUpstreamsCompleted = downstreamGraph.upstreamTasks.every((up) => {
535
+ const upDef = resolveStatusDefinition(up.status, downstreamStatusDefs);
536
+ return upDef.category === 'completed';
537
+ });
538
+
539
+ if (allUpstreamsCompleted) {
540
+ let updatedDownstream = downstream;
541
+ const updates: Partial<Task> = {};
542
+
543
+ if (downstream.status === 'blocked') {
544
+ const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
545
+ updates.status = defaultStatus;
546
+ }
547
+
548
+ if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
549
+ updates.customFields = {
550
+ ...downstream.customFields,
551
+ isBlocked: false,
552
+ blockedReason: null
553
+ };
554
+ }
555
+
556
+ if (Object.keys(updates).length > 0) {
557
+ const res = await this.store.updateTask(downstream.id, updates);
558
+ if (res) updatedDownstream = res;
559
+ }
560
+
561
+ const now = new Date().toISOString();
562
+ const unblockedEvent: TaskUnblockedEvent = {
563
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
564
+ name: 'task.unblocked',
565
+ aggregateId: downstream.id,
566
+ aggregateType: 'Task',
567
+ occurredAt: now,
568
+ payload: {
569
+ task: updatedDownstream,
570
+ upstreamTaskId: completedTask.id
571
+ }
572
+ };
573
+ await this.events.publish(unblockedEvent);
574
+
575
+ await this.store.logActivity({
576
+ projectId: downstream.projectId,
577
+ taskId: downstream.id,
578
+ actorId: 'system',
579
+ action: 'task.unblocked',
580
+ details: { unblockedByTaskId: completedTask.id }
581
+ });
582
+
583
+ this.dispatchWebhook('task.unblocked', {
584
+ task: updatedDownstream,
585
+ upstreamTaskId: completedTask.id
586
+ });
587
+ }
588
+ }
589
+ } catch (err) {
590
+ console.error(`[CriticalPathEngine] Error during auto-unblocking for task ${completedTask.id}:`, err);
591
+ }
592
+ }
593
+
497
594
  async deleteTask(id: string): Promise<boolean> {
498
595
  const existing = await this.store.getTask(id);
499
596
  if (!existing) return false;
@@ -590,13 +687,25 @@ export class CriticalPathEngine {
590
687
  };
591
688
  }
592
689
 
593
- async getTaskLifecycleState(taskId: string): Promise<TaskLifecycleState | null> {
690
+ async getTaskLifecycleState(
691
+ taskId: string,
692
+ options?: { referenceDate?: Date; stalledThresholdDays?: number }
693
+ ): Promise<TaskLifecycleState | null> {
594
694
  const task = await this.getTask(taskId);
595
695
  if (!task) return null;
596
696
  const project = await this.getProject(task.projectId);
597
697
  const workflow = await this.resolveProjectWorkflow(task.projectId);
598
698
  const statusDefs = project?.statusDefinitions || workflow?.statuses;
599
- return deriveTaskLifecycleState(task, statusDefs);
699
+
700
+ const graph = await this.getTaskDependencyGraph(taskId);
701
+ const upstreamTasks = graph.upstreamTasks;
702
+
703
+ return deriveTaskLifecycleState(task, {
704
+ customDefinitions: statusDefs,
705
+ referenceDate: options?.referenceDate,
706
+ stalledThresholdDays: options?.stalledThresholdDays,
707
+ upstreamTasks
708
+ });
600
709
  }
601
710
 
602
711
  // --- Time Tracking ---
@@ -1142,9 +1251,9 @@ export class CriticalPathEngine {
1142
1251
 
1143
1252
  for (const task of tasks) {
1144
1253
  const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1145
- if (statusDef.completionState === 'done') {
1254
+ if (statusDef.category === 'completed') {
1146
1255
  completedTasks++;
1147
- } else if (statusDef.executionState === 'active') {
1256
+ } else if (statusDef.category === 'in_progress') {
1148
1257
  activeTasks++;
1149
1258
  }
1150
1259
 
@@ -1154,7 +1263,7 @@ export class CriticalPathEngine {
1154
1263
  if (typeof task.progress === 'number') {
1155
1264
  totalProgress += task.progress;
1156
1265
  } else {
1157
- totalProgress += statusDef.completionState === 'done' ? 100 : 0;
1266
+ totalProgress += statusDef.category === 'completed' ? 100 : 0;
1158
1267
  }
1159
1268
  }
1160
1269
 
package/src/index.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { CriticalPathEngine, deriveTaskLifecycleState } from './index.js';
2
+ import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } from './index.js';
3
3
  import type { CriticalPathPlugin } from './types/index.js';
4
4
 
5
5
  describe('CriticalPathEngine Core Tests', () => {
@@ -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' },
@@ -372,6 +372,74 @@ describe('CriticalPathEngine Core Tests', () => {
372
372
  const backlogPrev = await engine.getAllowedPreviousTaskTransitions(backlogTask.id);
373
373
  expect(backlogPrev).toEqual([]);
374
374
  });
375
+
376
+ it('automatically unblocks downstream tasks and publishes task.unblocked event when upstream task completes', async () => {
377
+ const engine = new CriticalPathEngine();
378
+ const proj = await engine.createProject({ key: 'UNBLK', name: 'Auto-Unblock Project' });
379
+
380
+ const upstream = await engine.createTask({
381
+ projectId: proj.id,
382
+ title: 'Upstream Task',
383
+ status: 'in_progress'
384
+ });
385
+
386
+ const downstream = await engine.createTask({
387
+ projectId: proj.id,
388
+ title: 'Downstream Task',
389
+ status: 'blocked',
390
+ customFields: { isBlocked: true, blockedReason: 'Waiting on upstream' }
391
+ });
392
+
393
+ await engine.addDependency({
394
+ taskId: downstream.id,
395
+ dependsOnTaskId: upstream.id,
396
+ type: 'blocking'
397
+ });
398
+
399
+ const unblockedEvents: any[] = [];
400
+ engine.events.subscribe('task.unblocked', async (evt) => {
401
+ unblockedEvents.push(evt);
402
+ });
403
+
404
+ // Complete upstream task
405
+ const completedUpstream = await engine.updateTask(upstream.id, { status: 'done' });
406
+ expect(completedUpstream?.status).toBe('done');
407
+
408
+ // Downstream task should now be unblocked
409
+ expect(unblockedEvents).toHaveLength(1);
410
+ expect(unblockedEvents[0].payload.task.id).toBe(downstream.id);
411
+ expect(unblockedEvents[0].payload.upstreamTaskId).toBe(upstream.id);
412
+
413
+ const refreshedDownstream = await engine.getTask(downstream.id);
414
+ expect(refreshedDownstream?.status).toBe('todo');
415
+ expect(refreshedDownstream?.customFields?.isBlocked).toBe(false);
416
+ });
417
+
418
+ it('allows VFX workflow transitions from revision_requested to in_production', async () => {
419
+ const engine = new CriticalPathEngine();
420
+ const vfxWorkflow = await engine.createWorkflow(DEFAULT_VFX_WORKFLOW);
421
+ const proj = await engine.createProject({
422
+ key: 'VFX',
423
+ name: 'VFX Film Project',
424
+ workflowId: vfxWorkflow.id
425
+ });
426
+
427
+ const task = await engine.createTask({
428
+ projectId: proj.id,
429
+ title: 'Compositing Shot 010',
430
+ status: 'revision_requested'
431
+ });
432
+
433
+ const allowed = await engine.getAllowedTaskTransitions(task.id);
434
+ expect(allowed).toContain('in_production');
435
+ expect(allowed).toContain('internal_review');
436
+
437
+ const allowedPrevious = await engine.getAllowedPreviousTaskTransitions(task.id);
438
+ expect(allowedPrevious).toContain('in_production');
439
+
440
+ const updated = await engine.updateTask(task.id, { status: 'in_production' });
441
+ expect(updated?.status).toBe('in_production');
442
+ });
375
443
  });
376
444
 
377
445
 
@@ -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
 
@@ -91,5 +91,21 @@ describe('FirebaseStore', () => {
91
91
  expect(doc?.name).toBe('Undefined Test');
92
92
  expect('description' in (doc as any)).toBe(false);
93
93
  });
94
+
95
+ it('should retrieve dependencies when queried as taskId or dependsOnTaskId', async () => {
96
+ const dep = await store.addDependency({
97
+ taskId: 'task-downstream',
98
+ dependsOnTaskId: 'task-upstream',
99
+ type: 'blocking'
100
+ });
101
+
102
+ const byDownstream = await store.getDependencies('task-downstream');
103
+ expect(byDownstream).toHaveLength(1);
104
+ expect(byDownstream[0].id).toBe(dep.id);
105
+
106
+ const byUpstream = await store.getDependencies('task-upstream');
107
+ expect(byUpstream).toHaveLength(1);
108
+ expect(byUpstream[0].id).toBe(dep.id);
109
+ });
94
110
  });
95
111
 
@@ -546,8 +546,18 @@ export class FirebaseStore implements StorageAdapter {
546
546
 
547
547
  // --- Dependencies ---
548
548
  async getDependencies(taskId: string): Promise<TaskDependency[]> {
549
- const snap = await this.db.collection('dependencies').where('taskId', '==', taskId).get();
550
- return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
549
+ const [snap1, snap2] = await Promise.all([
550
+ this.db.collection('dependencies').where('taskId', '==', taskId).get(),
551
+ this.db.collection('dependencies').where('dependsOnTaskId', '==', taskId).get()
552
+ ]);
553
+ const depMap = new Map<string, TaskDependency>();
554
+ for (const doc of snap1.docs) {
555
+ depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
556
+ }
557
+ for (const doc of snap2.docs) {
558
+ depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
559
+ }
560
+ return Array.from(depMap.values());
551
561
  }
552
562
 
553
563
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
@@ -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