@critical-path/core 0.53.0 → 0.55.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 (44) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -3
  3. package/dist/domain/cpm.d.ts +14 -3
  4. package/dist/domain/cpm.d.ts.map +1 -1
  5. package/dist/domain/cpm.js +146 -48
  6. package/dist/domain/cpm.js.map +1 -1
  7. package/dist/domain/effort.d.ts +17 -0
  8. package/dist/domain/effort.d.ts.map +1 -0
  9. package/dist/domain/effort.js +30 -0
  10. package/dist/domain/effort.js.map +1 -0
  11. package/dist/domain/entities.d.ts.map +1 -1
  12. package/dist/domain/entities.js +4 -6
  13. package/dist/domain/entities.js.map +1 -1
  14. package/dist/domain/errors.d.ts +7 -0
  15. package/dist/domain/errors.d.ts.map +1 -1
  16. package/dist/domain/errors.js +10 -0
  17. package/dist/domain/errors.js.map +1 -1
  18. package/dist/domain/index.d.ts +1 -0
  19. package/dist/domain/index.d.ts.map +1 -1
  20. package/dist/domain/index.js +1 -0
  21. package/dist/domain/index.js.map +1 -1
  22. package/dist/domain/ladder.d.ts.map +1 -1
  23. package/dist/domain/ladder.js +33 -21
  24. package/dist/domain/ladder.js.map +1 -1
  25. package/dist/domain/metrics.d.ts.map +1 -1
  26. package/dist/domain/metrics.js +18 -4
  27. package/dist/domain/metrics.js.map +1 -1
  28. package/dist/domain/workload.d.ts.map +1 -1
  29. package/dist/domain/workload.js +3 -1
  30. package/dist/domain/workload.js.map +1 -1
  31. package/dist/engine/index.d.ts +27 -1
  32. package/dist/engine/index.d.ts.map +1 -1
  33. package/dist/engine/index.js +130 -48
  34. package/dist/engine/index.js.map +1 -1
  35. package/dist/schemas/index.d.ts +8 -60
  36. package/dist/schemas/index.d.ts.map +1 -1
  37. package/dist/schemas/index.js +7 -11
  38. package/dist/schemas/index.js.map +1 -1
  39. package/dist/types/index.d.ts +15 -1
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/utils/status.d.ts.map +1 -1
  42. package/dist/utils/status.js +3 -13
  43. package/dist/utils/status.js.map +1 -1
  44. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
8
  import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
9
9
  import { detectDependencyCycle, CircularDependencyError, precedenceOf } from '../domain/graph.js';
10
- import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
10
+ import { ValidationError, NotFoundError, ForbiddenError, ConflictError } from '../domain/errors.js';
11
11
  import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
12
12
  import { buildStorageKey, normalizeUploadData } from '../storage/file-storage.js';
13
13
  import { generateWebhookSecret } from '../webhooks/signature.js';
@@ -16,6 +16,8 @@ import { CreateTaskSchema } from '../schemas/index.js';
16
16
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
17
17
  import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
18
18
  import { calculateWorkloadDistribution } from '../domain/workload.js';
19
+ import { getTaskEstimateHours, getSummaryTaskIds } from '../domain/effort.js';
20
+ import { inferTaskProgress } from '../domain/metrics.js';
19
21
  /** Every caller-settable task field, derived from the create schema so new fields are never dropped. */
20
22
  const CREATE_TASK_FIELDS = Object.keys(CreateTaskSchema.shape);
21
23
  export class CriticalPathEngine {
@@ -361,12 +363,28 @@ export class CriticalPathEngine {
361
363
  });
362
364
  return created;
363
365
  }
366
+ /**
367
+ * Updates a workflow. Removing a status that tasks in a project using this workflow are still in
368
+ * is refused (`ConflictError`): move those tasks first, or switch the project with `statusMapping`.
369
+ */
364
370
  async updateWorkflow(id, updates) {
365
371
  const existing = await this.getWorkflow(id);
366
372
  if (!existing)
367
373
  return null;
368
374
  await this.requireWorkspaceAccess('workspace.manage');
369
375
  updates = this.withoutTenant(updates);
376
+ if (updates.statuses) {
377
+ const kept = new Set(updates.statuses.map((status) => status.key));
378
+ const removed = existing.statuses.map((status) => status.key).filter((key) => !kept.has(key));
379
+ if (removed.length) {
380
+ for (const project of await this.projectsUsingWorkflow(existing)) {
381
+ const stranded = [...new Set((await this.store.getTasks(project.id)).map((t) => t.status))].filter((key) => removed.includes(key));
382
+ if (stranded.length) {
383
+ throw new ConflictError(`Tasks in project "${project.id}" are still in ${stranded.map((k) => `"${k}"`).join(', ')}, which this update removes. Move them to another status first.`);
384
+ }
385
+ }
386
+ }
387
+ }
370
388
  const updated = await this.store.updateWorkflow(id, updates);
371
389
  if (updated) {
372
390
  const now = new Date().toISOString();
@@ -387,11 +405,16 @@ export class CriticalPathEngine {
387
405
  }
388
406
  return updated;
389
407
  }
408
+ /** Deletes a workflow. Refused (`ConflictError`) while any project uses it, even as its default. */
390
409
  async deleteWorkflow(id) {
391
410
  const existing = await this.getWorkflow(id);
392
411
  if (!existing)
393
412
  return false;
394
413
  await this.requireWorkspaceAccess('workspace.manage');
414
+ const users = await this.projectsUsingWorkflow(existing);
415
+ if (users.length) {
416
+ throw new ConflictError(`Workflow "${id}" is used by ${users.length} project(s) (${users.map((p) => p.id).join(', ')}). Switch them to another workflow first.`);
417
+ }
395
418
  const deleted = await this.store.deleteWorkflow(id);
396
419
  if (deleted) {
397
420
  const now = new Date().toISOString();
@@ -413,7 +436,10 @@ export class CriticalPathEngine {
413
436
  return deleted;
414
437
  }
415
438
  async resolveProjectWorkflow(projectId) {
416
- const project = await this.store.getProject(projectId);
439
+ return this.workflowFor(await this.store.getProject(projectId));
440
+ }
441
+ /** The workflow a project (or a project as it would be after an update) uses. */
442
+ async workflowFor(project) {
417
443
  if (project?.workflow)
418
444
  return project.workflow;
419
445
  if (project?.workflowId) {
@@ -421,14 +447,33 @@ export class CriticalPathEngine {
421
447
  if (wf && (wf.tenantId ?? undefined) === (project.tenantId ?? undefined))
422
448
  return wf;
423
449
  }
450
+ return (await this.defaultWorkflowFor(project?.tenantId)) ?? DEFAULT_SOFTWARE_WORKFLOW;
451
+ }
452
+ /** The tenant's stored default workflow: the one marked `isDefault`, else the first. */
453
+ async defaultWorkflowFor(tenantId) {
424
454
  // Only fall back to workflows from the project's own tenant.
425
- const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
426
- const defaultWf = workflows.find((w) => w.isDefault);
427
- if (defaultWf)
428
- return defaultWf;
429
- if (workflows.length > 0)
430
- return workflows[0];
431
- return DEFAULT_SOFTWARE_WORKFLOW;
455
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === tenantId);
456
+ return workflows.find((w) => w.isDefault) ?? workflows[0];
457
+ }
458
+ /** The statuses tasks in a project may have: its own definitions, else its workflow's. */
459
+ async statusDefinitionsFor(project) {
460
+ return project?.statusDefinitions?.length ? project.statusDefinitions : (await this.workflowFor(project)).statuses;
461
+ }
462
+ /** Projects whose tasks follow `workflow`, explicitly or as their tenant's default. */
463
+ async projectsUsingWorkflow(workflow) {
464
+ const projects = await this.store.getProjects(workflow.tenantId ? { tenantId: workflow.tenantId } : undefined);
465
+ const using = [];
466
+ for (const project of projects) {
467
+ if ((project.tenantId ?? undefined) === (workflow.tenantId ?? undefined) && (await this.workflowFor(project)).id === workflow.id)
468
+ using.push(project);
469
+ }
470
+ return using;
471
+ }
472
+ /** Throws unless `status` is one of the project's statuses. */
473
+ assertKnownStatus(status, definitions) {
474
+ if (definitions.length && !definitions.some((d) => d.key === status)) {
475
+ throw new ValidationError(`Status "${status}" is not in this project's workflow (${definitions.map((d) => d.key).join(', ')}).`);
476
+ }
432
477
  }
433
478
  async getAllowedTaskTransitions(taskId) {
434
479
  const task = await this.getTask(taskId);
@@ -469,6 +514,13 @@ export class CriticalPathEngine {
469
514
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
470
515
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
471
516
  await this.assertUsableWorkflow(input.workflowId, input.tenantId);
517
+ // Pin the workflow the project starts with, so adding a new default workflow later does not
518
+ // silently change it.
519
+ if (!input.workflowId && !input.workflow) {
520
+ const workflow = await this.defaultWorkflowFor(input.tenantId);
521
+ if (workflow)
522
+ input.workflowId = workflow.id;
523
+ }
472
524
  // The creator administers the project they create.
473
525
  if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
474
526
  input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
@@ -492,7 +544,16 @@ export class CriticalPathEngine {
492
544
  });
493
545
  return created;
494
546
  }
495
- async updateProject(id, updates) {
547
+ /**
548
+ * Updates a project. When the update changes which statuses the project has (`workflowId`,
549
+ * `workflow` or `statusDefinitions`), every task in a status the project no longer has must be
550
+ * moved: pass `statusMapping` (old status key -> new status key). Without a mapping for each such
551
+ * status the update is refused with a `ConflictError`, so no task is left in an unreachable status.
552
+ */
553
+ async updateProject(id, updates, options = {}) {
554
+ return this.inTransaction((engine) => engine.updateProjectNow(id, updates, options));
555
+ }
556
+ async updateProjectNow(id, updates, options) {
496
557
  const existing = await this.getProject(id);
497
558
  if (!existing)
498
559
  return null;
@@ -512,7 +573,24 @@ export class CriticalPathEngine {
512
573
  if (updates.workflowId && updates.workflowId !== existing.workflowId) {
513
574
  await this.assertUsableWorkflow(updates.workflowId, existing.tenantId);
514
575
  }
576
+ // Tasks whose status the project will no longer have, and where they go
577
+ const moves = [];
578
+ if ('workflowId' in updates || 'workflow' in updates || 'statusDefinitions' in updates) {
579
+ const next = new Set((await this.statusDefinitionsFor({ ...existing, ...updates })).map((d) => d.key));
580
+ const mapping = options.statusMapping ?? {};
581
+ const stranded = (await this.store.getTasks(id)).filter((task) => !next.has(task.status));
582
+ const unmapped = [...new Set(stranded.map((task) => task.status))].filter((key) => !next.has(mapping[key]));
583
+ if (unmapped.length) {
584
+ throw new ConflictError(`Tasks are in ${unmapped.map((k) => `"${k}"`).join(', ')}, which the project will no longer have. ` +
585
+ `Pass statusMapping with a new status for each (available: ${[...next].join(', ')}).`);
586
+ }
587
+ moves.push(...stranded.map((task) => ({ taskId: task.id, status: mapping[task.status] })));
588
+ }
515
589
  const updated = await this.store.updateProject(id, updates);
590
+ // The old status is not in the new workflow, so there is no transition to check
591
+ for (const move of moves) {
592
+ await this.elevated().updateTask(move.taskId, { status: move.status }, { ...(this.actor ? { actor: this.actor } : {}), ignoreTransitions: true });
593
+ }
516
594
  if (updated) {
517
595
  const now = new Date().toISOString();
518
596
  const event = {
@@ -648,9 +726,11 @@ export class CriticalPathEngine {
648
726
  await this.assertValidParent(input.parentId, projectId);
649
727
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
650
728
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
729
+ const statusDefinitions = await this.statusDefinitionsFor(project);
730
+ this.assertKnownStatus(initialStatus, statusDefinitions);
651
731
  const now = new Date().toISOString();
652
732
  // Derive initial lifecycle timestamps and semantic status
653
- const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
733
+ const statusDef = resolveStatusDefinition(initialStatus, statusDefinitions);
654
734
  const inProgress = statusDef.category === 'in_progress';
655
735
  const completed = statusDef.category === 'completed';
656
736
  const isBlocked = input.isBlocked ?? false;
@@ -659,15 +739,16 @@ export class CriticalPathEngine {
659
739
  projectId,
660
740
  title: processedInput.title || taskInput.title,
661
741
  status: initialStatus,
662
- semanticStatus: input.semanticStatus ?? statusDef.category,
742
+ // Always derived from the status, so every module agrees on it
743
+ semanticStatus: statusDef.category,
663
744
  priority: processedInput.priority || taskInput.priority || 'medium',
664
745
  taskType: processedInput.taskType || taskInput.taskType || 'task',
665
746
  actualStartDate: input.actualStartDate ?? (inProgress ? now : undefined),
666
747
  actualEndDate: input.actualEndDate ?? (completed || statusDef.category === 'canceled' ? now : undefined),
667
748
  completedAt: input.completedAt ?? (completed ? now : undefined),
668
- loggedHours: input.loggedHours ?? 0,
669
- inProgressSince: input.inProgressSince ?? (inProgress ? now : undefined),
670
- blockedSince: input.blockedSince ?? (isBlocked && inProgress ? now : undefined),
749
+ loggedHours: 0,
750
+ inProgressSince: inProgress ? now : undefined,
751
+ blockedSince: isBlocked && inProgress ? now : undefined,
671
752
  progress: input.progress ?? (completed ? 100 : 0),
672
753
  isBlocked,
673
754
  blockedReason: input.blockedReason ?? null,
@@ -711,8 +792,13 @@ export class CriticalPathEngine {
711
792
  // Plugins run first; their output is then validated like caller input. Identity and
712
793
  // ownership fields are never changed by hooks.
713
794
  const { id: _id, projectId: _projectId, createdAt: _createdAt, ...processedUpdates } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
795
+ // Derived from the status below; never taken from callers or hooks
796
+ delete processedUpdates.semanticStatus;
714
797
  // Validate workflow transition invariant
715
798
  if (processedUpdates.status && processedUpdates.status !== existing.status) {
799
+ this.assertKnownStatus(processedUpdates.status, await this.statusDefinitionsFor(project));
800
+ }
801
+ if (processedUpdates.status && processedUpdates.status !== existing.status && !options?.ignoreTransitions) {
716
802
  const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
717
803
  if (!isValid) {
718
804
  throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
@@ -733,7 +819,7 @@ export class CriticalPathEngine {
733
819
  const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
734
820
  // Auto-update execution/completion timestamps if status changes
735
821
  if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
736
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
822
+ processedUpdates.semanticStatus = newStatusDef.category;
737
823
  // 1. Leaving in_progress: finalize active session duration and blocked duration
738
824
  if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
739
825
  const inProgressSince = existing.inProgressSince;
@@ -744,9 +830,6 @@ export class CriticalPathEngine {
744
830
  const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
745
831
  processedUpdates.actualDurationSeconds =
746
832
  (processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
747
- if (processedUpdates.actualHours === undefined) {
748
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
749
- }
750
833
  if (processedUpdates.actualDurationMinutes === undefined) {
751
834
  processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
752
835
  }
@@ -854,18 +937,10 @@ export class CriticalPathEngine {
854
937
  else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
855
938
  processedUpdates.blockedSince = null;
856
939
  }
857
- // Mutual sync for manual duration/effort edits
858
- if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
859
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
860
- if (processedUpdates.actualDurationMinutes === undefined) {
861
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
862
- }
863
- }
864
- else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
865
- processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
866
- if (processedUpdates.actualDurationMinutes === undefined) {
867
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
868
- }
940
+ // Elapsed time (actualDuration*) and effort (actualHours, loggedHours) are separate quantities:
941
+ // keep the elapsed minutes in step with the seconds, and never derive effort from the clock.
942
+ if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualDurationMinutes === undefined) {
943
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
869
944
  }
870
945
  const updated = await this.store.updateTask(id, processedUpdates);
871
946
  if (!updated)
@@ -1947,6 +2022,10 @@ export class CriticalPathEngine {
1947
2022
  let estimatedHours = 0;
1948
2023
  let loggedHours = 0;
1949
2024
  let totalProgress = 0;
2025
+ let progressCount = 0;
2026
+ // Summary tasks are spanned by their subtasks (counted once, through the leaves); canceled
2027
+ // tasks keep their logged hours but leave the estimate and progress.
2028
+ const summaries = getSummaryTaskIds(allTasks);
1950
2029
  for (const task of tasks) {
1951
2030
  const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1952
2031
  if (statusDef.category === 'completed') {
@@ -1955,18 +2034,16 @@ export class CriticalPathEngine {
1955
2034
  else if (statusDef.category === 'in_progress') {
1956
2035
  activeTasks++;
1957
2036
  }
1958
- if (task.estimatedHours)
1959
- estimatedHours += task.estimatedHours;
1960
- if (task.loggedHours)
1961
- loggedHours += task.loggedHours;
1962
- if (typeof task.progress === 'number') {
1963
- totalProgress += task.progress;
1964
- }
1965
- else {
1966
- totalProgress += statusDef.category === 'completed' ? 100 : 0;
1967
- }
2037
+ if (summaries.has(task.id))
2038
+ continue;
2039
+ loggedHours += task.loggedHours ?? 0;
2040
+ if (statusDef.category === 'canceled')
2041
+ continue;
2042
+ estimatedHours += getTaskEstimateHours(task) ?? 0;
2043
+ totalProgress += inferTaskProgress(task, { customStatusDefinitions: project?.statusDefinitions || workflow?.statuses }).progressPercentage;
2044
+ progressCount++;
1968
2045
  }
1969
- const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
2046
+ const progressPercentage = progressCount > 0 ? Math.round(totalProgress / progressCount) : 0;
1970
2047
  return {
1971
2048
  deliverable,
1972
2049
  totalTasks,
@@ -2119,9 +2196,9 @@ export class CriticalPathEngine {
2119
2196
  const dependencies = await this.loadDependencies(tasks);
2120
2197
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
2121
2198
  const projectStartDate = options.projectStartDate || project?.startDate;
2122
- const { calendars, levelResources, levelingPriority, effort } = this.resolveCpmModes(options);
2199
+ const { calendars, levelResources, levelingPriority, effort, defaultEstimateHours } = this.resolveCpmModes(options);
2123
2200
  if (calendars === 'project')
2124
- return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate, effort });
2201
+ return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate, effort, defaultEstimateHours });
2125
2202
  const { users, teams } = await this.loadPeople(options, [project?.tenantId]);
2126
2203
  return calculateCPM(projectId, tasks, dependencies, {
2127
2204
  schedule,
@@ -2131,7 +2208,8 @@ export class CriticalPathEngine {
2131
2208
  teams,
2132
2209
  levelResources,
2133
2210
  levelingPriority,
2134
- effort
2211
+ effort,
2212
+ defaultEstimateHours
2135
2213
  });
2136
2214
  }
2137
2215
  /**
@@ -2143,7 +2221,7 @@ export class CriticalPathEngine {
2143
2221
  * Rejects runs above `portfolioTaskLimit` tasks (default 5000).
2144
2222
  */
2145
2223
  async calculatePortfolioCriticalPath(options = {}) {
2146
- const { calendars, levelResources, levelingPriority, effort } = this.resolveCpmModes(options);
2224
+ const { calendars, levelResources, levelingPriority, effort, defaultEstimateHours } = this.resolveCpmModes(options);
2147
2225
  let projects;
2148
2226
  if (options.projectIds) {
2149
2227
  const ids = [...new Set(options.projectIds)];
@@ -2189,7 +2267,7 @@ export class CriticalPathEngine {
2189
2267
  projectStartDate: p.startDate || options.projectStartDate,
2190
2268
  schedule: p.schedule || options.schedule || this.config.defaultSchedule,
2191
2269
  ...(mode ? { background: mode } : {})
2192
- })), dependencies, { calendars, users, teams, levelResources, levelingPriority, effort, projectOrder: options.projectOrder });
2270
+ })), dependencies, { calendars, users, teams, levelResources, levelingPriority, effort, defaultEstimateHours, projectOrder: options.projectOrder });
2193
2271
  }
2194
2272
  /** Validated calendar and levelling modes, with engine defaults applied. */
2195
2273
  resolveCpmModes(options) {
@@ -2209,7 +2287,11 @@ export class CriticalPathEngine {
2209
2287
  if (effort !== 'estimate' && effort !== 'remaining') {
2210
2288
  throw new ValidationError(`Unknown effort "${effort}"; use "estimate" or "remaining".`);
2211
2289
  }
2212
- return { calendars, levelResources, levelingPriority, effort };
2290
+ const defaultEstimateHours = options.defaultEstimateHours ?? this.config.criticalPathDefaultEstimateHours;
2291
+ if (defaultEstimateHours !== undefined && !(Number.isFinite(defaultEstimateHours) && defaultEstimateHours >= 0)) {
2292
+ throw new ValidationError('defaultEstimateHours must be a number of hours, 0 or more.');
2293
+ }
2294
+ return { calendars, levelResources, levelingPriority, effort, defaultEstimateHours };
2213
2295
  }
2214
2296
  /** Every dependency touching `tasks`, once each. */
2215
2297
  async loadDependencies(tasks) {