@critical-path/core 0.18.0 → 0.19.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 (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +43 -0
  3. package/dist/domain/calendar.d.ts +53 -0
  4. package/dist/domain/calendar.d.ts.map +1 -0
  5. package/dist/domain/calendar.js +333 -0
  6. package/dist/domain/calendar.js.map +1 -0
  7. package/dist/domain/calendar.test.d.ts +2 -0
  8. package/dist/domain/calendar.test.d.ts.map +1 -0
  9. package/dist/domain/calendar.test.js +136 -0
  10. package/dist/domain/calendar.test.js.map +1 -0
  11. package/dist/domain/cpm.d.ts +8 -3
  12. package/dist/domain/cpm.d.ts.map +1 -1
  13. package/dist/domain/cpm.js +36 -3
  14. package/dist/domain/cpm.js.map +1 -1
  15. package/dist/domain/cpm.test.js +53 -0
  16. package/dist/domain/cpm.test.js.map +1 -1
  17. package/dist/domain/events.d.ts +38 -2
  18. package/dist/domain/events.d.ts.map +1 -1
  19. package/dist/domain/events.js.map +1 -1
  20. package/dist/domain/index.d.ts +1 -0
  21. package/dist/domain/index.d.ts.map +1 -1
  22. package/dist/domain/index.js +1 -0
  23. package/dist/domain/index.js.map +1 -1
  24. package/dist/domain/ladder.d.ts +3 -2
  25. package/dist/domain/ladder.d.ts.map +1 -1
  26. package/dist/domain/ladder.js +21 -3
  27. package/dist/domain/ladder.js.map +1 -1
  28. package/dist/domain/metrics.test.js +5 -4
  29. package/dist/domain/metrics.test.js.map +1 -1
  30. package/dist/domain/workload.d.ts.map +1 -1
  31. package/dist/domain/workload.js +60 -17
  32. package/dist/domain/workload.js.map +1 -1
  33. package/dist/domain/workload.test.js +60 -0
  34. package/dist/domain/workload.test.js.map +1 -1
  35. package/dist/engine/index.d.ts +22 -2
  36. package/dist/engine/index.d.ts.map +1 -1
  37. package/dist/engine/index.js +119 -15
  38. package/dist/engine/index.js.map +1 -1
  39. package/dist/index.test.js +41 -0
  40. package/dist/index.test.js.map +1 -1
  41. package/dist/store/sqlite.d.ts.map +1 -1
  42. package/dist/store/sqlite.js +9 -3
  43. package/dist/store/sqlite.js.map +1 -1
  44. package/dist/store/sqlite.test.js +15 -1
  45. package/dist/store/sqlite.test.js.map +1 -1
  46. package/dist/task-state.test.js +49 -0
  47. package/dist/task-state.test.js.map +1 -1
  48. package/dist/types/index.d.ts +44 -1
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/utils/status.d.ts.map +1 -1
  51. package/dist/utils/status.js +5 -2
  52. package/dist/utils/status.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/domain/calendar.test.ts +164 -0
  55. package/src/domain/calendar.ts +457 -0
  56. package/src/domain/cpm.test.ts +61 -0
  57. package/src/domain/cpm.ts +47 -4
  58. package/src/domain/events.ts +27 -2
  59. package/src/domain/index.ts +1 -0
  60. package/src/domain/ladder.ts +25 -4
  61. package/src/domain/metrics.test.ts +6 -4
  62. package/src/domain/workload.test.ts +73 -0
  63. package/src/domain/workload.ts +73 -19
  64. package/src/engine/index.ts +152 -16
  65. package/src/index.test.ts +48 -0
  66. package/src/store/sqlite.test.ts +17 -1
  67. package/src/store/sqlite.ts +11 -1
  68. package/src/task-state.test.ts +60 -1
  69. package/src/types/index.ts +48 -0
  70. package/src/utils/status.ts +7 -2
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -17,9 +17,11 @@ import type {
17
17
  ConcreteTaskEvidence,
18
18
  ConcreteEvidenceSummary,
19
19
  RealityDelta,
20
- TaskLadderView
20
+ TaskLadderView,
21
+ WorkSchedule
21
22
  } from '../types/index.js';
22
23
  import { calculateCPM, getTaskDurationHours } from './cpm.js';
24
+ import { DEFAULT_WORK_SCHEDULE, getWorkingDaysBetween } from './calendar.js';
23
25
 
24
26
  export interface LadderContext {
25
27
  project: Project;
@@ -31,6 +33,7 @@ export interface LadderContext {
31
33
  attachments?: Attachment[];
32
34
  timeEntries?: TimeEntry[];
33
35
  activities?: Activity[];
36
+ schedule?: WorkSchedule;
34
37
  }
35
38
 
36
39
  export function aggregateConcreteEvidenceForTask(
@@ -38,7 +41,8 @@ export function aggregateConcreteEvidenceForTask(
38
41
  attachments: Attachment[] = [],
39
42
  deliverables: Deliverable[] = [],
40
43
  timeEntries: TimeEntry[] = [],
41
- activities: Activity[] = []
44
+ activities: Activity[] = [],
45
+ schedule?: WorkSchedule
42
46
  ): ConcreteTaskEvidence {
43
47
  const taskAttachments = attachments.filter((a) => a.taskId === task.id);
44
48
  const taskDeliverables = deliverables.filter(
@@ -70,10 +74,19 @@ export function aggregateConcreteEvidenceForTask(
70
74
  const isOverEstimate = Boolean(estimatedHours > 0 && effectiveLoggedHours > estimatedHours);
71
75
 
72
76
  let scheduleVarianceDays: number | undefined;
77
+ let scheduleVarianceWorkingDays: number | undefined;
73
78
  if (task.dueDate && task.actualEndDate) {
74
79
  const dueTime = new Date(task.dueDate).getTime();
75
80
  const actualTime = new Date(task.actualEndDate).getTime();
76
81
  scheduleVarianceDays = Math.round((actualTime - dueTime) / (1000 * 60 * 60 * 24));
82
+ const effectiveSched = schedule || DEFAULT_WORK_SCHEDULE;
83
+ if (actualTime > dueTime) {
84
+ scheduleVarianceWorkingDays = Math.max(0, getWorkingDaysBetween(task.dueDate, task.actualEndDate, effectiveSched) - 1);
85
+ } else if (actualTime < dueTime) {
86
+ scheduleVarianceWorkingDays = -Math.max(0, getWorkingDaysBetween(task.actualEndDate, task.dueDate, effectiveSched) - 1);
87
+ } else {
88
+ scheduleVarianceWorkingDays = 0;
89
+ }
77
90
  }
78
91
 
79
92
  let durationVarianceHours: number | undefined;
@@ -100,6 +113,7 @@ export function aggregateConcreteEvidenceForTask(
100
113
  effortVarianceHours: varianceHours,
101
114
  durationVarianceHours,
102
115
  scheduleVarianceDays,
116
+ scheduleVarianceWorkingDays,
103
117
  accuracyRatio,
104
118
  isOverdue,
105
119
  isOverEstimate
@@ -309,8 +323,14 @@ export function buildTimelineLadder(
309
323
  const targetLevel = options.level || 'all';
310
324
  const generatedAt = new Date().toISOString();
311
325
 
326
+ const effectiveSchedule = context.schedule || project.schedule || DEFAULT_WORK_SCHEDULE;
327
+ const projectStartDate = project.startDate || (tasks.length > 0 ? tasks.find((t) => t.plannedStartDate)?.plannedStartDate : undefined);
328
+
312
329
  // Run Critical Path Method (CPM)
313
- const cpm = calculateCPM(project.id, tasks, dependencies);
330
+ const cpm = calculateCPM(project.id, tasks, dependencies, {
331
+ schedule: effectiveSchedule,
332
+ projectStartDate
333
+ });
314
334
  const criticalSet = new Set(cpm.criticalTaskIds);
315
335
  const scheduleMap = new Map(cpm.tasks.map((s) => [s.taskId, s]));
316
336
 
@@ -352,7 +372,8 @@ export function buildTimelineLadder(
352
372
  attachments,
353
373
  deliverables,
354
374
  timeEntries,
355
- activities
375
+ activities,
376
+ effectiveSchedule
356
377
  );
357
378
  }
358
379
 
@@ -148,6 +148,8 @@ describe('Task Metrics Domain', () => {
148
148
  });
149
149
 
150
150
  describe('reconstructTaskProgressHistory & Curve Profile', () => {
151
+ const referenceDate = new Date('2026-09-09T12:00:00Z');
152
+
151
153
  it('detects linear progress', () => {
152
154
  const activities: Activity[] = [
153
155
  { id: 'a1', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 25 }, createdAt: '2026-09-02T10:00:00Z' },
@@ -156,7 +158,7 @@ describe('Task Metrics Domain', () => {
156
158
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 100 }, createdAt: '2026-09-08T10:00:00Z' }
157
159
  ];
158
160
  const task: Task = { ...baseTask, progress: 100, updatedAt: '2026-09-08T10:00:00Z' };
159
- const history = reconstructTaskProgressHistory(task, activities);
161
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
160
162
 
161
163
  expect(history.points.length).toBeGreaterThanOrEqual(4);
162
164
  expect(history.curveProfile).toBe('linear');
@@ -170,7 +172,7 @@ describe('Task Metrics Domain', () => {
170
172
  { id: 'a4', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 95 }, createdAt: '2026-09-09T10:00:00Z' }
171
173
  ];
172
174
  const task: Task = { ...baseTask, progress: 95, updatedAt: '2026-09-09T10:00:00Z' };
173
- const history = reconstructTaskProgressHistory(task, activities);
175
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
174
176
 
175
177
  expect(history.curveProfile).toBe('s_curve');
176
178
  });
@@ -182,7 +184,7 @@ describe('Task Metrics Domain', () => {
182
184
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 78 }, createdAt: '2026-09-08T10:00:00Z' }
183
185
  ];
184
186
  const task: Task = { ...baseTask, progress: 78, updatedAt: '2026-09-08T10:00:00Z' };
185
- const history = reconstructTaskProgressHistory(task, activities);
187
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
186
188
 
187
189
  expect(history.curveProfile).toBe('early_surge');
188
190
  });
@@ -194,7 +196,7 @@ describe('Task Metrics Domain', () => {
194
196
  { id: 'a3', taskId: 'task-1', actorId: 'u1', action: 'task.updated', details: { progress: 90 }, createdAt: '2026-09-08T10:00:00Z' }
195
197
  ];
196
198
  const task: Task = { ...baseTask, progress: 90, updatedAt: '2026-09-08T10:00:00Z' };
197
- const history = reconstructTaskProgressHistory(task, activities);
199
+ const history = reconstructTaskProgressHistory(task, activities, { referenceDate });
198
200
 
199
201
  expect(history.curveProfile).toBe('late_rush');
200
202
  });
@@ -235,4 +235,77 @@ describe('calculateWorkloadDistribution', () => {
235
235
  expect(distribution.buckets.length).toBeGreaterThan(0);
236
236
  expect(distribution.seriesKeys).toContain('u1');
237
237
  });
238
+
239
+ it('distributes task effort only on working days and reduces capacity on holidays', () => {
240
+ // Friday 2026-09-18 to Tuesday 2026-09-22 (5 calendar days: Fri, Sat, Sun, Mon, Tue)
241
+ // Monday 2026-09-21 is a Holiday!
242
+ // Active working days: Friday 2026-09-18 and Tuesday 2026-09-22 (2 days total)
243
+ // 16 estimated hours should be divided into 8h on Fri, 0h on Sat, 0h on Sun, 0h on Mon, 8h on Tue!
244
+ const customSchedule = {
245
+ name: 'Holiday Calendar',
246
+ defaultHoursPerDay: 8,
247
+ days: [
248
+ { dayOfWeek: 0, isWorkingDay: false },
249
+ { dayOfWeek: 1, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
250
+ { dayOfWeek: 2, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
251
+ { dayOfWeek: 3, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
252
+ { dayOfWeek: 4, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
253
+ { dayOfWeek: 5, isWorkingDay: true, hours: [{ start: '09:00', end: '17:00' }] },
254
+ { dayOfWeek: 6, isWorkingDay: false }
255
+ ],
256
+ holidays: [{ date: '2026-09-21', name: 'Monday Holiday' }]
257
+ };
258
+
259
+ const tasks: Task[] = [
260
+ {
261
+ id: 't_holiday',
262
+ projectId: 'p1',
263
+ title: 'Holiday Spanning Task',
264
+ status: 'todo',
265
+ priority: 'high',
266
+ assigneeId: 'u2',
267
+ estimatedHours: 16,
268
+ plannedStartDate: '2026-09-18', // Friday
269
+ dueDate: '2026-09-22', // Tuesday
270
+ createdAt: '2026-09-01',
271
+ updatedAt: '2026-09-01'
272
+ }
273
+ ];
274
+
275
+ const result = calculateWorkloadDistribution(
276
+ { tasks, users },
277
+ {
278
+ startDate: '2026-09-18',
279
+ endDate: '2026-09-22',
280
+ interval: 'day',
281
+ groupBy: 'assignee',
282
+ metric: 'scheduled',
283
+ schedule: customSchedule
284
+ }
285
+ );
286
+
287
+ const fri = result.buckets.find((b) => b.date === '2026-09-18');
288
+ const sat = result.buckets.find((b) => b.date === '2026-09-19');
289
+ const sun = result.buckets.find((b) => b.date === '2026-09-20');
290
+ const mon = result.buckets.find((b) => b.date === '2026-09-21');
291
+ const tue = result.buckets.find((b) => b.date === '2026-09-22');
292
+
293
+ expect(fri?.values.u2).toBe(8);
294
+ expect(fri?.capacity?.u2).toBe(8);
295
+
296
+ expect(sat?.values.u2).toBe(0);
297
+ expect(sat?.capacity?.u2).toBe(0);
298
+
299
+ expect(sun?.values.u2).toBe(0);
300
+ expect(sun?.capacity?.u2).toBe(0);
301
+
302
+ // Monday is holiday: 0 scheduled effort, 0 capacity
303
+ expect(mon?.values.u2).toBe(0);
304
+ expect(mon?.capacity?.u2).toBe(0);
305
+
306
+ expect(tue?.values.u2).toBe(8);
307
+ expect(tue?.capacity?.u2).toBe(8);
308
+
309
+ expect(result.totalHours).toBe(16);
310
+ });
238
311
  });
@@ -10,6 +10,12 @@ import type {
10
10
  WorkloadDistribution,
11
11
  WorkloadDistributionOptions
12
12
  } from '../types/index.js';
13
+ import {
14
+ DEFAULT_WORK_SCHEDULE,
15
+ getWorkingDaysList,
16
+ getNetAvailableCapacity,
17
+ getWorkingHoursInDay
18
+ } from './calendar.js';
13
19
 
14
20
  export interface WorkloadContext {
15
21
  tasks: Task[];
@@ -260,21 +266,53 @@ export function calculateWorkloadDistribution(
260
266
  taskEnd = taskStart;
261
267
  }
262
268
 
263
- // Calculate days in task window
269
+ // Resolve schedule for task assignee / team / project
270
+ const taskSchedule =
271
+ (task.assigneeId && options.userSchedules?.[task.assigneeId]) ||
272
+ (task.assigneeId && userMap.get(task.assigneeId)?.schedule) ||
273
+ (task.teamId && options.teamSchedules?.[task.teamId]) ||
274
+ (task.teamId && teamMap.get(task.teamId)?.schedule) ||
275
+ options.schedule ||
276
+ DEFAULT_WORK_SCHEDULE;
277
+
278
+ const workingDays = getWorkingDaysList(taskStart, taskEnd, taskSchedule);
264
279
  const msPerDay = 1000 * 60 * 60 * 24;
265
- const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
266
- const hoursPerDay = hoursToDistribute / totalDays;
267
-
268
- // Distribute across overlapping buckets
269
- for (const bucket of bucketList) {
270
- const bucketLastDay = addDays(bucket.endDate, -1);
271
- const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
272
- const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
273
-
274
- if (overlapStart <= overlapEnd) {
275
- const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
276
- const bucketEffort = overlapDays * hoursPerDay;
277
- addValue(bucket, dimKey, bucketEffort);
280
+
281
+ if (workingDays.length > 0) {
282
+ const hoursPerWorkingDay = hoursToDistribute / workingDays.length;
283
+
284
+ for (const bucket of bucketList) {
285
+ const bucketStart = bucket.startDate.getTime();
286
+ const bucketEnd = bucket.endDate.getTime();
287
+
288
+ let workingDaysInBucket = 0;
289
+ for (const wdStr of workingDays) {
290
+ const wdTime = parseDateOnly(wdStr).getTime();
291
+ if (wdTime >= bucketStart && wdTime < bucketEnd) {
292
+ workingDaysInBucket++;
293
+ }
294
+ }
295
+
296
+ if (workingDaysInBucket > 0) {
297
+ const bucketEffort = workingDaysInBucket * hoursPerWorkingDay;
298
+ addValue(bucket, dimKey, bucketEffort);
299
+ }
300
+ }
301
+ } else {
302
+ // Fallback if no working days in range (e.g. task scheduled on an all-holiday window)
303
+ const totalDays = Math.max(1, Math.round((taskEnd.getTime() - taskStart.getTime()) / msPerDay) + 1);
304
+ const hoursPerDay = hoursToDistribute / totalDays;
305
+
306
+ for (const bucket of bucketList) {
307
+ const bucketLastDay = addDays(bucket.endDate, -1);
308
+ const overlapStart = new Date(Math.max(taskStart.getTime(), bucket.startDate.getTime()));
309
+ const overlapEnd = new Date(Math.min(taskEnd.getTime(), bucketLastDay.getTime()));
310
+
311
+ if (overlapStart <= overlapEnd) {
312
+ const overlapDays = Math.round((overlapEnd.getTime() - overlapStart.getTime()) / msPerDay) + 1;
313
+ const bucketEffort = overlapDays * hoursPerDay;
314
+ addValue(bucket, dimKey, bucketEffort);
315
+ }
278
316
  }
279
317
  }
280
318
  }
@@ -325,6 +363,12 @@ export function calculateWorkloadDistribution(
325
363
  bucketTotalHours += roundedHours;
326
364
 
327
365
  // Capacity calculation
366
+ const entitySchedule =
367
+ (groupBy === 'assignee' && (options.userSchedules?.[key] || userMap.get(key)?.schedule)) ||
368
+ (groupBy === 'team' && (options.teamSchedules?.[key] || teamMap.get(key)?.schedule)) ||
369
+ options.schedule ||
370
+ DEFAULT_WORK_SCHEDULE;
371
+
328
372
  let weeklyCap = defaultWeeklyCapacity;
329
373
  if (options.capacityOverrides && typeof options.capacityOverrides[key] === 'number') {
330
374
  weeklyCap = options.capacityOverrides[key];
@@ -344,13 +388,23 @@ export function calculateWorkloadDistribution(
344
388
  }
345
389
 
346
390
  let bucketCap = 0;
347
- if (interval === 'week') {
348
- bucketCap = weeklyCap;
391
+ if (key === 'unassigned') {
392
+ bucketCap = 0;
393
+ } else if (interval === 'week') {
394
+ const netWeekCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
395
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
396
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
397
+ bucketCap = Math.round(netWeekCap * scale * 100) / 100;
349
398
  } else if (interval === 'day') {
350
- bucketCap = Math.round((weeklyCap / 5) * 100) / 100;
399
+ const dayHours = getWorkingHoursInDay(ib.startDate, entitySchedule);
400
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
401
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
402
+ bucketCap = Math.round(dayHours * scale * 100) / 100;
351
403
  } else if (interval === 'month') {
352
- const daysInMonth = new Date(Date.UTC(ib.startDate.getUTCFullYear(), ib.startDate.getUTCMonth() + 1, 0)).getUTCDate();
353
- bucketCap = Math.round((weeklyCap * (daysInMonth / 7)) * 100) / 100;
404
+ const netMonthCap = getNetAvailableCapacity(ib.startDate, addDays(ib.endDate, -1), entitySchedule);
405
+ const baseline = (entitySchedule.defaultHoursPerDay ?? 8) * 5;
406
+ const scale = baseline > 0 ? weeklyCap / baseline : 1;
407
+ bucketCap = Math.round(netMonthCap * scale * 100) / 100;
354
408
  }
355
409
 
356
410
  capacity[key] = bucketCap;
@@ -51,6 +51,7 @@ import {
51
51
  TaskCreatedEvent,
52
52
  TaskUpdatedEvent,
53
53
  TaskStatusChangedEvent,
54
+ TaskBlockedEvent,
54
55
  TaskUnblockedEvent,
55
56
  TaskDeletedEvent,
56
57
  ProjectCreatedEvent,
@@ -79,7 +80,7 @@ import {
79
80
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
80
81
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
81
82
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
82
- import { calculateCPM } from '../domain/cpm.js';
83
+ import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
83
84
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
84
85
  import {
85
86
  calculateTaskMetrics,
@@ -89,6 +90,7 @@ import {
89
90
  import { calculateWorkloadDistribution } from '../domain/workload.js';
90
91
 
91
92
  export class CriticalPathEngine {
93
+ public readonly config: CriticalPathConfig;
92
94
  public readonly store: StorageAdapter;
93
95
  public readonly fileStorage?: FileStorageAdapter;
94
96
  public readonly plugins: PluginRegistry;
@@ -96,6 +98,7 @@ export class CriticalPathEngine {
96
98
  public readonly ready: Promise<void> = Promise.resolve();
97
99
 
98
100
  constructor(config: CriticalPathConfig = {}) {
101
+ this.config = config;
99
102
  this.store = typeof config.store === 'object' && config.store !== null
100
103
  ? (config.store as StorageAdapter)
101
104
  : new InMemoryStore();
@@ -428,30 +431,76 @@ export class CriticalPathEngine {
428
431
  return created;
429
432
  }
430
433
 
431
- async updateTask(id: string, updates: Partial<Task>): Promise<Task | null> {
434
+ async updateTask(
435
+ id: string,
436
+ updates: Partial<Task> & {
437
+ actorId?: string;
438
+ actorName?: string;
439
+ actorType?: string;
440
+ actor?: { userId: string; username?: string; actorType?: string };
441
+ },
442
+ options?: {
443
+ actorId?: string;
444
+ actorName?: string;
445
+ actorType?: string;
446
+ actor?: { userId: string; username?: string; actorType?: string };
447
+ }
448
+ ): Promise<Task | null> {
432
449
  const existing = await this.store.getTask(id);
433
450
  if (!existing) return null;
434
451
 
452
+ const actorId =
453
+ options?.actorId ||
454
+ updates.actorId ||
455
+ options?.actor?.userId ||
456
+ updates.actor?.userId ||
457
+ 'system';
458
+ const actorName =
459
+ options?.actorName ||
460
+ updates.actorName ||
461
+ options?.actor?.username ||
462
+ updates.actor?.username ||
463
+ (actorId === 'system' ? 'System' : undefined);
464
+ const actorType =
465
+ options?.actorType ||
466
+ updates.actorType ||
467
+ options?.actor?.actorType ||
468
+ updates.actor?.actorType ||
469
+ (actorId === 'system' ? 'system' : 'user');
470
+ const actorObj = options?.actor || updates.actor || {
471
+ userId: actorId,
472
+ username: actorName,
473
+ actorType
474
+ };
475
+
476
+ const {
477
+ actorId: _aId,
478
+ actorName: _aName,
479
+ actorType: _aType,
480
+ actor: _aObj,
481
+ ...taskUpdates
482
+ } = updates;
483
+
435
484
  const project = await this.store.getProject(existing.projectId);
436
485
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
437
486
 
438
487
  // Validate workflow transition invariant
439
- if (updates.status && updates.status !== existing.status) {
440
- const isValid = validateTransition(workflow || undefined, existing.status, updates.status);
488
+ if (taskUpdates.status && taskUpdates.status !== existing.status) {
489
+ const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
441
490
  if (!isValid) {
442
- throw new WorkflowValidationError(existing.status, updates.status, workflow?.id);
491
+ throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
443
492
  }
444
493
  }
445
494
 
446
495
  // Validate custom field invariants
447
- if (project?.customFieldDefinitions && updates.customFields) {
496
+ if (project?.customFieldDefinitions && taskUpdates.customFields) {
448
497
  validateCustomFieldValues(project.customFieldDefinitions, {
449
498
  ...existing.customFields,
450
- ...updates.customFields
499
+ ...taskUpdates.customFields
451
500
  });
452
501
  }
453
502
 
454
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, updates);
503
+ const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
455
504
 
456
505
  // Auto-update execution/completion timestamps if status changes
457
506
  if (processedUpdates.status && processedUpdates.status !== existing.status) {
@@ -504,7 +553,10 @@ export class CriticalPathEngine {
504
553
  payload: {
505
554
  task: updated,
506
555
  previousStatus: existing.status,
507
- newStatus: updated.status
556
+ newStatus: updated.status,
557
+ actorId,
558
+ ...(actorName ? { actorName } : {}),
559
+ actor: actorObj
508
560
  }
509
561
  };
510
562
  await this.events.publish(statusEvent);
@@ -525,18 +577,83 @@ export class CriticalPathEngine {
525
577
  occurredAt: now,
526
578
  payload: {
527
579
  task: updated,
528
- previous: existing
580
+ previous: existing,
581
+ actorId,
582
+ ...(actorName ? { actorName } : {}),
583
+ actor: actorObj
529
584
  }
530
585
  };
531
586
  await this.events.publish(updateEvent);
532
587
  }
533
588
 
589
+ const wasBlocked = Boolean(existing.isBlocked || existing.customFields?.isBlocked || existing.status === 'blocked');
590
+ const isNowBlocked = Boolean(updated.isBlocked || updated.customFields?.isBlocked || updated.status === 'blocked');
591
+
592
+ if (!wasBlocked && isNowBlocked) {
593
+ const blockedReason = updated.blockedReason ?? (updated.customFields?.blockedReason as string | undefined) ?? null;
594
+ const blockedEvent: TaskBlockedEvent = {
595
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
596
+ name: 'task.blocked',
597
+ aggregateId: updated.id,
598
+ aggregateType: 'Task',
599
+ occurredAt: now,
600
+ payload: {
601
+ task: updated,
602
+ reason: blockedReason,
603
+ actorId,
604
+ ...(actorName ? { actorName } : {}),
605
+ actor: actorObj
606
+ }
607
+ };
608
+ await this.events.publish(blockedEvent);
609
+ await this.store.logActivity({
610
+ projectId: updated.projectId,
611
+ taskId: updated.id,
612
+ actorId,
613
+ action: 'task.blocked',
614
+ details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
615
+ });
616
+ this.dispatchWebhook('task.blocked', {
617
+ task: updated,
618
+ reason: blockedReason
619
+ });
620
+ } else if (wasBlocked && !isNowBlocked) {
621
+ const unblockedEvent: TaskUnblockedEvent = {
622
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
623
+ name: 'task.unblocked',
624
+ aggregateId: updated.id,
625
+ aggregateType: 'Task',
626
+ occurredAt: now,
627
+ payload: {
628
+ task: updated,
629
+ actorId,
630
+ ...(actorName ? { actorName } : {}),
631
+ actor: actorObj
632
+ }
633
+ };
634
+ await this.events.publish(unblockedEvent);
635
+ await this.store.logActivity({
636
+ projectId: updated.projectId,
637
+ taskId: updated.id,
638
+ actorId,
639
+ action: 'task.unblocked',
640
+ details: { ...(actorName ? { actorName } : {}) }
641
+ });
642
+ this.dispatchWebhook('task.unblocked', {
643
+ task: updated
644
+ });
645
+ }
646
+
534
647
  await this.store.logActivity({
535
648
  projectId: updated.projectId,
536
649
  taskId: updated.id,
537
- actorId: updated.assigneeId || 'system',
650
+ actorId,
538
651
  action: isStatusChange ? 'task.status_changed' : 'task.updated',
539
- details: { fromStatus: existing.status, toStatus: updated.status }
652
+ details: {
653
+ fromStatus: existing.status,
654
+ toStatus: updated.status,
655
+ ...(actorName ? { actorName } : {})
656
+ }
540
657
  });
541
658
 
542
659
  this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
@@ -577,6 +694,11 @@ export class CriticalPathEngine {
577
694
  updates.status = defaultStatus;
578
695
  }
579
696
 
697
+ if (downstream.isBlocked || downstream.blockedReason) {
698
+ updates.isBlocked = false;
699
+ updates.blockedReason = null;
700
+ }
701
+
580
702
  if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
581
703
  updates.customFields = {
582
704
  ...downstream.customFields,
@@ -1379,7 +1501,11 @@ export class CriticalPathEngine {
1379
1501
  }
1380
1502
 
1381
1503
  // --- Ladder of Abstraction & Critical Path Method ---
1382
- async calculateCriticalPath(projectId: string): Promise<CriticalPathAnalysis> {
1504
+ async calculateCriticalPath(
1505
+ projectId: string,
1506
+ options: CPMOptions = {}
1507
+ ): Promise<CriticalPathAnalysis> {
1508
+ const project = await this.store.getProject(projectId);
1383
1509
  const tasks = await this.store.getTasks(projectId);
1384
1510
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
1385
1511
  const seenDepIds = new Set<string>();
@@ -1392,7 +1518,9 @@ export class CriticalPathEngine {
1392
1518
  }
1393
1519
  }
1394
1520
  }
1395
- return calculateCPM(projectId, tasks, dependencies);
1521
+ const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1522
+ const projectStartDate = options.projectStartDate || project?.startDate;
1523
+ return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
1396
1524
  }
1397
1525
 
1398
1526
  async getTimelineLadder(
@@ -1435,6 +1563,8 @@ export class CriticalPathEngine {
1435
1563
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1436
1564
  const timeEntries: TimeEntry[] = timeEntriesNested.flat();
1437
1565
 
1566
+ const schedule = project.schedule || this.config.defaultSchedule;
1567
+
1438
1568
  return buildTimelineLadder(
1439
1569
  {
1440
1570
  project,
@@ -1445,7 +1575,8 @@ export class CriticalPathEngine {
1445
1575
  deliverables,
1446
1576
  attachments,
1447
1577
  timeEntries,
1448
- activities
1578
+ activities,
1579
+ schedule
1449
1580
  },
1450
1581
  options
1451
1582
  );
@@ -1518,6 +1649,8 @@ export class CriticalPathEngine {
1518
1649
  ): Promise<WorkloadDistribution> {
1519
1650
  const tasks = await this.store.getTasks(projectId);
1520
1651
  const teams = await this.store.getTeams();
1652
+ const project = projectId ? await this.store.getProject(projectId) : undefined;
1653
+ const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1521
1654
 
1522
1655
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
1523
1656
  const timeEntries: TimeEntry[] = timeEntriesNested.flat();
@@ -1529,7 +1662,10 @@ export class CriticalPathEngine {
1529
1662
  teams,
1530
1663
  projectId
1531
1664
  },
1532
- options
1665
+ {
1666
+ ...options,
1667
+ schedule
1668
+ }
1533
1669
  );
1534
1670
  }
1535
1671
 
package/src/index.test.ts CHANGED
@@ -440,6 +440,54 @@ describe('CriticalPathEngine Core Tests', () => {
440
440
  const updated = await engine.updateTask(task.id, { status: 'in_production' });
441
441
  expect(updated?.status).toBe('in_production');
442
442
  });
443
+
444
+ it('attributes activities and domain events to the real actor rather than the assignee', async () => {
445
+ const engine = new CriticalPathEngine();
446
+ const proj = await engine.createProject({ key: 'ACT', name: 'Actor Attribution Project' });
447
+ const task = await engine.createTask({
448
+ projectId: proj.id,
449
+ title: 'Assigned Task',
450
+ status: 'todo',
451
+ assigneeId: 'agent_worker_1'
452
+ });
453
+
454
+ const receivedEvents: any[] = [];
455
+ engine.events.subscribe('task.status_changed', async (evt) => {
456
+ receivedEvents.push(evt);
457
+ });
458
+ engine.events.subscribe('task.updated', async (evt) => {
459
+ receivedEvents.push(evt);
460
+ });
461
+
462
+ // Update with explicit actor (e.g. human user updating an assigned agent task)
463
+ await engine.updateTask(task.id, {
464
+ status: 'in_progress',
465
+ actorId: 'user_human_42',
466
+ actorName: 'Jack James'
467
+ });
468
+
469
+ const activities = await engine.store.getActivities({ taskId: task.id });
470
+ const statusActivity = activities.find((a) => a.action === 'task.status_changed');
471
+ expect(statusActivity).toBeDefined();
472
+ expect(statusActivity?.actorId).toBe('user_human_42');
473
+ expect((statusActivity?.details as any)?.actorName).toBe('Jack James');
474
+ expect(statusActivity?.actorId).not.toBe('agent_worker_1');
475
+
476
+ expect(receivedEvents).toHaveLength(1);
477
+ expect(receivedEvents[0].payload.actorId).toBe('user_human_42');
478
+ expect(receivedEvents[0].payload.actorName).toBe('Jack James');
479
+
480
+ // Update without explicit actor - should fall back to 'system', NEVER task.assigneeId
481
+ await engine.updateTask(task.id, {
482
+ title: 'Updated title'
483
+ });
484
+
485
+ const updatedActivities = await engine.store.getActivities({ taskId: task.id });
486
+ const updateActivity = updatedActivities.find((a) => a.action === 'task.updated');
487
+ expect(updateActivity).toBeDefined();
488
+ expect(updateActivity?.actorId).toBe('system');
489
+ expect(updateActivity?.actorId).not.toBe('agent_worker_1');
490
+ });
443
491
  });
444
492
 
445
493