@critical-path/core 0.10.0 → 0.12.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 (67) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -0
  3. package/dist/deliverables.test.d.ts +2 -0
  4. package/dist/deliverables.test.d.ts.map +1 -0
  5. package/dist/deliverables.test.js +232 -0
  6. package/dist/deliverables.test.js.map +1 -0
  7. package/dist/domain/entities.d.ts +25 -1
  8. package/dist/domain/entities.d.ts.map +1 -1
  9. package/dist/domain/entities.js +153 -0
  10. package/dist/domain/entities.js.map +1 -1
  11. package/dist/domain/events.d.ts +31 -3
  12. package/dist/domain/events.d.ts.map +1 -1
  13. package/dist/domain/events.js.map +1 -1
  14. package/dist/engine/index.d.ts +8 -1
  15. package/dist/engine/index.d.ts.map +1 -1
  16. package/dist/engine/index.js +170 -3
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/index.test.js +25 -0
  19. package/dist/index.test.js.map +1 -1
  20. package/dist/store/firebase.d.ts +6 -1
  21. package/dist/store/firebase.d.ts.map +1 -1
  22. package/dist/store/firebase.js +41 -0
  23. package/dist/store/firebase.js.map +1 -1
  24. package/dist/store/index.d.ts +15 -2
  25. package/dist/store/index.d.ts.map +1 -1
  26. package/dist/store/index.js +38 -0
  27. package/dist/store/index.js.map +1 -1
  28. package/dist/store/sqlite.d.ts +7 -1
  29. package/dist/store/sqlite.d.ts.map +1 -1
  30. package/dist/store/sqlite.js +107 -9
  31. package/dist/store/sqlite.js.map +1 -1
  32. package/dist/store/sqlite.test.js +6 -1
  33. package/dist/store/sqlite.test.js.map +1 -1
  34. package/dist/types/index.d.ts +34 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/utils/index.d.ts +1 -0
  37. package/dist/utils/index.d.ts.map +1 -1
  38. package/dist/utils/index.js +1 -0
  39. package/dist/utils/index.js.map +1 -1
  40. package/dist/utils/mentions.d.ts +24 -0
  41. package/dist/utils/mentions.d.ts.map +1 -0
  42. package/dist/utils/mentions.js +73 -0
  43. package/dist/utils/mentions.js.map +1 -0
  44. package/dist/utils/mentions.test.d.ts +2 -0
  45. package/dist/utils/mentions.test.d.ts.map +1 -0
  46. package/dist/utils/mentions.test.js +71 -0
  47. package/dist/utils/mentions.test.js.map +1 -0
  48. package/dist/utils/workflow.d.ts +1 -0
  49. package/dist/utils/workflow.d.ts.map +1 -1
  50. package/dist/utils/workflow.js +37 -0
  51. package/dist/utils/workflow.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/deliverables.test.ts +284 -0
  54. package/src/domain/entities.ts +185 -2
  55. package/src/domain/events.ts +28 -3
  56. package/src/engine/index.ts +195 -4
  57. package/src/index.test.ts +30 -0
  58. package/src/store/firebase.ts +47 -1
  59. package/src/store/index.ts +54 -1
  60. package/src/store/sqlite.test.ts +6 -1
  61. package/src/store/sqlite.ts +148 -6
  62. package/src/types/index.ts +52 -1
  63. package/src/utils/index.ts +1 -0
  64. package/src/utils/mentions.test.ts +82 -0
  65. package/src/utils/mentions.ts +89 -0
  66. package/src/utils/workflow.ts +38 -0
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -8,7 +8,10 @@ import type {
8
8
  TaskTodoItem,
9
9
  CustomFieldDefinition,
10
10
  CreateTaskInput,
11
- CreateProjectInput
11
+ CreateProjectInput,
12
+ Deliverable,
13
+ DeliverableStatus,
14
+ CreateDeliverableInput
12
15
  } from '../types/index.js';
13
16
  import type {
14
17
  DomainEvent,
@@ -16,7 +19,10 @@ import type {
16
19
  TaskStatusChangedEvent,
17
20
  TaskUpdatedEvent,
18
21
  TimeLoggedEvent,
19
- ProjectCreatedEvent
22
+ ProjectCreatedEvent,
23
+ DeliverableCreatedEvent,
24
+ DeliverableUpdatedEvent,
25
+ DeliverableStatusChangedEvent
20
26
  } from './events.js';
21
27
  import { validateTransition, WorkflowValidationError } from '../utils/workflow.js';
22
28
  import { resolveStatusDefinition } from '../utils/status.js';
@@ -67,6 +73,7 @@ export class TaskEntity extends BaseEntity {
67
73
  public iterationId?: string;
68
74
  public teamId?: string;
69
75
  public containerId?: string;
76
+ public deliverableId?: string;
70
77
  public plannedStartDate?: string;
71
78
  public actualStartDate?: string;
72
79
  public actualEndDate?: string;
@@ -99,6 +106,7 @@ export class TaskEntity extends BaseEntity {
99
106
  this.iterationId = data.iterationId;
100
107
  this.teamId = data.teamId;
101
108
  this.containerId = data.containerId;
109
+ this.deliverableId = data.deliverableId;
102
110
  this.plannedStartDate = data.plannedStartDate;
103
111
  this.actualStartDate = data.actualStartDate;
104
112
  this.actualEndDate = data.actualEndDate;
@@ -365,6 +373,7 @@ export class TaskEntity extends BaseEntity {
365
373
  iterationId: this.iterationId,
366
374
  teamId: this.teamId,
367
375
  containerId: this.containerId,
376
+ deliverableId: this.deliverableId,
368
377
  plannedStartDate: this.plannedStartDate,
369
378
  actualStartDate: this.actualStartDate,
370
379
  actualEndDate: this.actualEndDate,
@@ -471,6 +480,180 @@ export class ProjectEntity extends BaseEntity {
471
480
  }
472
481
  }
473
482
 
483
+ export class DeliverableEntity extends BaseEntity {
484
+ public projectId: string;
485
+ public title: string;
486
+ public description?: string;
487
+ public status: DeliverableStatus;
488
+ public format?: string;
489
+ public specs?: Record<string, unknown>;
490
+ public leadId?: string;
491
+ public reviewerId?: string;
492
+ public dueDate?: string;
493
+ public deliveredAt?: string;
494
+ public outputUrls?: string[];
495
+ public customFields?: Record<string, unknown>;
496
+
497
+ constructor(data: Deliverable) {
498
+ super(data.id, data.createdAt, data.updatedAt);
499
+ this.projectId = data.projectId;
500
+ this.title = data.title;
501
+ this.description = data.description;
502
+ this.status = data.status;
503
+ this.format = data.format;
504
+ this.specs = data.specs ? { ...data.specs } : undefined;
505
+ this.leadId = data.leadId;
506
+ this.reviewerId = data.reviewerId;
507
+ this.dueDate = data.dueDate;
508
+ this.deliveredAt = data.deliveredAt;
509
+ this.outputUrls = data.outputUrls ? [...data.outputUrls] : [];
510
+ this.customFields = data.customFields ? { ...data.customFields } : {};
511
+ }
512
+
513
+ public static create(
514
+ input: CreateDeliverableInput,
515
+ options?: { id?: string; customFieldDefs?: CustomFieldDefinition[] }
516
+ ): DeliverableEntity {
517
+ const id = options?.id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
518
+ const now = new Date().toISOString();
519
+
520
+ if (options?.customFieldDefs) {
521
+ validateCustomFieldValues(options.customFieldDefs, input.customFields);
522
+ }
523
+
524
+ const deliverable = new DeliverableEntity({
525
+ ...input,
526
+ id,
527
+ createdAt: now,
528
+ updatedAt: now,
529
+ status: input.status || 'planned',
530
+ outputUrls: input.outputUrls ?? [],
531
+ customFields: input.customFields ?? {}
532
+ });
533
+
534
+ const createdEvent: DeliverableCreatedEvent = {
535
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
536
+ name: 'deliverable.created',
537
+ aggregateId: deliverable.id,
538
+ aggregateType: 'Deliverable',
539
+ occurredAt: now,
540
+ payload: { deliverable: deliverable.toPlain() }
541
+ };
542
+ deliverable.raiseEvent(createdEvent);
543
+
544
+ return deliverable;
545
+ }
546
+
547
+ public transitionTo(newStatus: DeliverableStatus): this {
548
+ if (this.status === newStatus) return this;
549
+
550
+ const previousStatus = this.status;
551
+ this.status = newStatus;
552
+ const now = new Date().toISOString();
553
+
554
+ if (newStatus === 'delivered' && !this.deliveredAt) {
555
+ this.deliveredAt = now;
556
+ }
557
+
558
+ this.markUpdated();
559
+
560
+ const event: DeliverableStatusChangedEvent = {
561
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
562
+ name: 'deliverable.status_changed',
563
+ aggregateId: this.id,
564
+ aggregateType: 'Deliverable',
565
+ occurredAt: now,
566
+ payload: {
567
+ deliverable: this.toPlain(),
568
+ previousStatus,
569
+ newStatus
570
+ }
571
+ };
572
+ this.raiseEvent(event);
573
+
574
+ return this;
575
+ }
576
+
577
+ public addOutputUrl(url: string): this {
578
+ if (!this.outputUrls) {
579
+ this.outputUrls = [];
580
+ }
581
+ if (!this.outputUrls.includes(url)) {
582
+ const previous = this.toPlain();
583
+ this.outputUrls.push(url);
584
+ this.markUpdated();
585
+
586
+ const event: DeliverableUpdatedEvent = {
587
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
588
+ name: 'deliverable.updated',
589
+ aggregateId: this.id,
590
+ aggregateType: 'Deliverable',
591
+ occurredAt: new Date().toISOString(),
592
+ payload: {
593
+ deliverable: this.toPlain(),
594
+ previous
595
+ }
596
+ };
597
+ this.raiseEvent(event);
598
+ }
599
+ return this;
600
+ }
601
+
602
+ public update(updates: Partial<Deliverable>, customFieldDefs?: CustomFieldDefinition[]): this {
603
+ const previous = this.toPlain();
604
+
605
+ if (updates.customFields && customFieldDefs) {
606
+ validateCustomFieldValues(customFieldDefs, {
607
+ ...this.customFields,
608
+ ...updates.customFields
609
+ });
610
+ }
611
+
612
+ if (updates.status && updates.status !== this.status) {
613
+ this.transitionTo(updates.status);
614
+ delete updates.status;
615
+ }
616
+
617
+ Object.assign(this, updates);
618
+ this.markUpdated();
619
+
620
+ const event: DeliverableUpdatedEvent = {
621
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
622
+ name: 'deliverable.updated',
623
+ aggregateId: this.id,
624
+ aggregateType: 'Deliverable',
625
+ occurredAt: new Date().toISOString(),
626
+ payload: {
627
+ deliverable: this.toPlain(),
628
+ previous
629
+ }
630
+ };
631
+ this.raiseEvent(event);
632
+
633
+ return this;
634
+ }
635
+
636
+ public toPlain(): Deliverable {
637
+ return {
638
+ id: this.id,
639
+ projectId: this.projectId,
640
+ title: this.title,
641
+ description: this.description,
642
+ status: this.status,
643
+ format: this.format,
644
+ specs: this.specs ? { ...this.specs } : undefined,
645
+ leadId: this.leadId,
646
+ reviewerId: this.reviewerId,
647
+ dueDate: this.dueDate,
648
+ deliveredAt: this.deliveredAt,
649
+ outputUrls: this.outputUrls ? [...this.outputUrls] : [],
650
+ customFields: this.customFields ? { ...this.customFields } : {},
651
+ createdAt: this.createdAt,
652
+ updatedAt: this.updatedAt
653
+ };
654
+ }
655
+ }
656
+
474
657
  export class AttachmentValidationError extends Error {
475
658
  constructor(message: string) {
476
659
  super(message);
@@ -9,14 +9,15 @@ import type {
9
9
  TimeEntry,
10
10
  Comment,
11
11
  CommentReaction,
12
- Attachment
12
+ Attachment,
13
+ Deliverable
13
14
  } from '../types/index.js';
14
15
 
15
16
  export interface DomainEvent<TPayload = Record<string, unknown>> {
16
17
  readonly id: string;
17
18
  readonly name: string;
18
19
  readonly aggregateId: string;
19
- readonly aggregateType: 'Task' | 'Project' | 'Workflow' | 'Iteration' | 'Team' | 'Container' | 'Dependency' | 'Comment' | 'Attachment';
20
+ readonly aggregateType: 'Task' | 'Project' | 'Workflow' | 'Iteration' | 'Team' | 'Container' | 'Dependency' | 'Comment' | 'Attachment' | 'Deliverable';
20
21
  readonly occurredAt: string;
21
22
  readonly payload: TPayload;
22
23
  }
@@ -136,6 +137,26 @@ export interface ContainerCreatedEvent extends DomainEvent<{ container: TaskCont
136
137
  readonly aggregateType: 'Container';
137
138
  }
138
139
 
140
+ export interface DeliverableCreatedEvent extends DomainEvent<{ deliverable: Deliverable }> {
141
+ readonly name: 'deliverable.created';
142
+ readonly aggregateType: 'Deliverable';
143
+ }
144
+
145
+ export interface DeliverableUpdatedEvent extends DomainEvent<{ deliverable: Deliverable; previous: Deliverable }> {
146
+ readonly name: 'deliverable.updated';
147
+ readonly aggregateType: 'Deliverable';
148
+ }
149
+
150
+ export interface DeliverableStatusChangedEvent extends DomainEvent<{ deliverable: Deliverable; previousStatus: string; newStatus: string }> {
151
+ readonly name: 'deliverable.status_changed';
152
+ readonly aggregateType: 'Deliverable';
153
+ }
154
+
155
+ export interface DeliverableDeletedEvent extends DomainEvent<{ deliverableId: string; projectId: string }> {
156
+ readonly name: 'deliverable.deleted';
157
+ readonly aggregateType: 'Deliverable';
158
+ }
159
+
139
160
  export type CriticalPathDomainEvent =
140
161
  | TaskCreatedEvent
141
162
  | TaskUpdatedEvent
@@ -158,7 +179,11 @@ export type CriticalPathDomainEvent =
158
179
  | IterationStartedEvent
159
180
  | IterationCompletedEvent
160
181
  | TeamCreatedEvent
161
- | ContainerCreatedEvent;
182
+ | ContainerCreatedEvent
183
+ | DeliverableCreatedEvent
184
+ | DeliverableUpdatedEvent
185
+ | DeliverableStatusChangedEvent
186
+ | DeliverableDeletedEvent;
162
187
 
163
188
  export type DomainEventHandler<T extends DomainEvent = DomainEvent> = (event: T) => Promise<void> | void;
164
189
 
@@ -17,7 +17,10 @@ import type {
17
17
  WebhookEvent,
18
18
  Workflow,
19
19
  CreateTaskInput,
20
- CreateProjectInput
20
+ CreateProjectInput,
21
+ Deliverable,
22
+ DeliverableSummary,
23
+ CreateDeliverableInput
21
24
  } from '../types/index.js';
22
25
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
23
26
  import { PluginRegistry } from '../plugins/index.js';
@@ -28,6 +31,7 @@ import {
28
31
  WorkflowValidationError,
29
32
  DEFAULT_SOFTWARE_WORKFLOW
30
33
  } from '../utils/workflow.js';
34
+ import { extractMentions } from '../utils/mentions.js';
31
35
  import {
32
36
  DomainEventBus,
33
37
  TaskCreatedEvent,
@@ -43,6 +47,10 @@ import {
43
47
  IterationCompletedEvent,
44
48
  TeamCreatedEvent,
45
49
  ContainerCreatedEvent,
50
+ DeliverableCreatedEvent,
51
+ DeliverableUpdatedEvent,
52
+ DeliverableStatusChangedEvent,
53
+ DeliverableDeletedEvent,
46
54
  TaskDependencyAddedEvent,
47
55
  TimeLoggedEvent,
48
56
  CommentAddedEvent,
@@ -62,6 +70,7 @@ export class CriticalPathEngine {
62
70
  public readonly fileStorage?: FileStorageAdapter;
63
71
  public readonly plugins: PluginRegistry;
64
72
  public readonly events: DomainEventBus;
73
+ public readonly ready: Promise<void> = Promise.resolve();
65
74
 
66
75
  constructor(config: CriticalPathConfig = {}) {
67
76
  this.store = typeof config.store === 'object' && config.store !== null
@@ -79,7 +88,7 @@ export class CriticalPathEngine {
79
88
  }
80
89
 
81
90
  if (config.initialData) {
82
- this.seedInitialData(config.initialData);
91
+ this.ready = this.seedInitialData(config.initialData);
83
92
  }
84
93
  }
85
94
 
@@ -104,6 +113,11 @@ export class CriticalPathEngine {
104
113
  await this.store.createContainer(c);
105
114
  }
106
115
  }
116
+ if (data.deliverables) {
117
+ for (const d of data.deliverables) {
118
+ await this.store.createDeliverable(d);
119
+ }
120
+ }
107
121
  if (data.iterations) {
108
122
  for (const it of data.iterations) {
109
123
  await this.store.createIteration(it);
@@ -331,6 +345,7 @@ export class CriticalPathEngine {
331
345
  iterationId: processedInput.iterationId ?? taskInput.iterationId,
332
346
  teamId: processedInput.teamId ?? taskInput.teamId,
333
347
  containerId: processedInput.containerId ?? taskInput.containerId,
348
+ deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
334
349
  plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
335
350
  actualStartDate,
336
351
  actualEndDate,
@@ -624,7 +639,8 @@ export class CriticalPathEngine {
624
639
  }
625
640
 
626
641
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
627
- const created = await this.store.addComment(comment);
642
+ const mentions = comment.mentions ?? extractMentions(comment.content);
643
+ const created = await this.store.addComment({ ...comment, mentions });
628
644
  const now = new Date().toISOString();
629
645
 
630
646
  const event: CommentAddedEvent = {
@@ -645,7 +661,10 @@ export class CriticalPathEngine {
645
661
  const existing = await this.store.getComment(id);
646
662
  if (!existing) return null;
647
663
 
648
- const updated = await this.store.updateComment(id, updates);
664
+ const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
665
+ const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
666
+
667
+ const updated = await this.store.updateComment(id, toUpdate);
649
668
  if (!updated) return null;
650
669
 
651
670
  const now = new Date().toISOString();
@@ -932,6 +951,178 @@ export class CriticalPathEngine {
932
951
  return this.store.deleteContainer(id);
933
952
  }
934
953
 
954
+ // --- Deliverables ---
955
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
956
+ return this.store.getDeliverables(projectId);
957
+ }
958
+
959
+ async getDeliverable(id: string): Promise<Deliverable | null> {
960
+ return this.store.getDeliverable(id);
961
+ }
962
+
963
+ async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
964
+ const project = await this.store.getProject(input.projectId);
965
+ if (project?.customFieldDefinitions && input.customFields) {
966
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
967
+ }
968
+
969
+ const created = await this.store.createDeliverable({
970
+ ...input,
971
+ status: input.status || 'planned',
972
+ outputUrls: input.outputUrls ?? [],
973
+ customFields: input.customFields ?? {}
974
+ });
975
+ const now = new Date().toISOString();
976
+
977
+ const event: DeliverableCreatedEvent = {
978
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
979
+ name: 'deliverable.created',
980
+ aggregateId: created.id,
981
+ aggregateType: 'Deliverable',
982
+ occurredAt: now,
983
+ payload: { deliverable: created }
984
+ };
985
+ await this.events.publish(event);
986
+
987
+ await this.store.logActivity({
988
+ projectId: created.projectId,
989
+ actorId: 'system',
990
+ action: 'deliverable.created',
991
+ details: { title: created.title, format: created.format }
992
+ });
993
+ this.dispatchWebhook('deliverable.created', { deliverable: created });
994
+ return created;
995
+ }
996
+
997
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
998
+ const existing = await this.store.getDeliverable(id);
999
+ if (!existing) return null;
1000
+
1001
+ if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1002
+ updates.deliveredAt = new Date().toISOString();
1003
+ }
1004
+
1005
+ const updated = await this.store.updateDeliverable(id, updates);
1006
+ if (updated) {
1007
+ const now = new Date().toISOString();
1008
+
1009
+ if (updates.status && updates.status !== existing.status) {
1010
+ const statusEvent: DeliverableStatusChangedEvent = {
1011
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1012
+ name: 'deliverable.status_changed',
1013
+ aggregateId: updated.id,
1014
+ aggregateType: 'Deliverable',
1015
+ occurredAt: now,
1016
+ payload: {
1017
+ deliverable: updated,
1018
+ previousStatus: existing.status,
1019
+ newStatus: updated.status
1020
+ }
1021
+ };
1022
+ await this.events.publish(statusEvent);
1023
+ this.dispatchWebhook('deliverable.status_changed', {
1024
+ deliverable: updated,
1025
+ previousStatus: existing.status,
1026
+ newStatus: updated.status
1027
+ });
1028
+ }
1029
+
1030
+ const updateEvent: DeliverableUpdatedEvent = {
1031
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1032
+ name: 'deliverable.updated',
1033
+ aggregateId: updated.id,
1034
+ aggregateType: 'Deliverable',
1035
+ occurredAt: now,
1036
+ payload: { deliverable: updated, previous: existing }
1037
+ };
1038
+ await this.events.publish(updateEvent);
1039
+
1040
+ await this.store.logActivity({
1041
+ projectId: updated.projectId,
1042
+ actorId: 'system',
1043
+ action: 'deliverable.updated',
1044
+ details: { title: updated.title, status: updated.status }
1045
+ });
1046
+ this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1047
+ }
1048
+ return updated;
1049
+ }
1050
+
1051
+ async deleteDeliverable(id: string): Promise<boolean> {
1052
+ const existing = await this.store.getDeliverable(id);
1053
+ if (!existing) return false;
1054
+
1055
+ const deleted = await this.store.deleteDeliverable(id);
1056
+ if (deleted) {
1057
+ const now = new Date().toISOString();
1058
+ const event: DeliverableDeletedEvent = {
1059
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1060
+ name: 'deliverable.deleted',
1061
+ aggregateId: id,
1062
+ aggregateType: 'Deliverable',
1063
+ occurredAt: now,
1064
+ payload: { deliverableId: id, projectId: existing.projectId }
1065
+ };
1066
+ await this.events.publish(event);
1067
+
1068
+ await this.store.logActivity({
1069
+ projectId: existing.projectId,
1070
+ actorId: 'system',
1071
+ action: 'deliverable.deleted',
1072
+ details: { title: existing.title }
1073
+ });
1074
+ this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1075
+ }
1076
+ return deleted;
1077
+ }
1078
+
1079
+ async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1080
+ const deliverable = await this.store.getDeliverable(deliverableId);
1081
+ if (!deliverable) return null;
1082
+
1083
+ const project = await this.store.getProject(deliverable.projectId);
1084
+ const workflow = await this.resolveProjectWorkflow(deliverable.projectId);
1085
+ const allTasks = await this.store.getTasks(deliverable.projectId);
1086
+ const tasks = allTasks.filter((t) => t.deliverableId === deliverableId);
1087
+
1088
+ const totalTasks = tasks.length;
1089
+ let completedTasks = 0;
1090
+ let activeTasks = 0;
1091
+ let estimatedHours = 0;
1092
+ let loggedHours = 0;
1093
+ let totalProgress = 0;
1094
+
1095
+ for (const task of tasks) {
1096
+ const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
1097
+ if (statusDef.completionState === 'done') {
1098
+ completedTasks++;
1099
+ } else if (statusDef.executionState === 'active') {
1100
+ activeTasks++;
1101
+ }
1102
+
1103
+ if (task.estimatedHours) estimatedHours += task.estimatedHours;
1104
+ if (task.loggedHours) loggedHours += task.loggedHours;
1105
+
1106
+ if (typeof task.progress === 'number') {
1107
+ totalProgress += task.progress;
1108
+ } else {
1109
+ totalProgress += statusDef.completionState === 'done' ? 100 : 0;
1110
+ }
1111
+ }
1112
+
1113
+ const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
1114
+
1115
+ return {
1116
+ deliverable,
1117
+ totalTasks,
1118
+ completedTasks,
1119
+ activeTasks,
1120
+ progressPercentage,
1121
+ estimatedHours,
1122
+ loggedHours
1123
+ };
1124
+ }
1125
+
935
1126
  // --- Iterations ---
936
1127
  async getIterations(projectId: string): Promise<Iteration[]> {
937
1128
  return this.store.getIterations(projectId);
package/src/index.test.ts CHANGED
@@ -310,5 +310,35 @@ describe('CriticalPathEngine Core Tests', () => {
310
310
  expect(afterRemoval?.reactions?.[0].emoji).toBe('🚀');
311
311
  expect(receivedEvents).toContain('comment.reaction.removed');
312
312
  });
313
+
314
+ it('supports mentions in comments with auto-extraction and explicit override', async () => {
315
+ const engine = new CriticalPathEngine();
316
+ const proj = await engine.createProject({ key: 'MNT', name: 'Mentions Project' });
317
+ const task = await engine.createTask({ projectId: proj.id, title: 'Mentions Task', status: 'todo' });
318
+
319
+ // Auto-extracted mentions
320
+ const comment1 = await engine.addComment({
321
+ taskId: task.id,
322
+ authorId: 'user_1',
323
+ content: 'Hey @planner and @user_2, please check with @"Supervisor Agent"!'
324
+ });
325
+ expect(comment1.mentions).toEqual(['planner', 'user_2', 'Supervisor Agent']);
326
+
327
+ // Explicit mentions passed
328
+ const comment2 = await engine.addComment({
329
+ taskId: task.id,
330
+ authorId: 'user_1',
331
+ content: 'Custom mention assignment',
332
+ mentions: ['custom_agent_id']
333
+ });
334
+ expect(comment2.mentions).toEqual(['custom_agent_id']);
335
+
336
+ // Update comment content updates mentions
337
+ const updated = await engine.updateComment(comment1.id, {
338
+ content: 'Now calling only @coordinator'
339
+ });
340
+ expect(updated?.mentions).toEqual(['coordinator']);
341
+ });
313
342
  });
314
343
 
344
+
@@ -11,7 +11,9 @@ import type {
11
11
  Activity,
12
12
  Webhook,
13
13
  TaskDependency,
14
- Workflow
14
+ Workflow,
15
+ Deliverable,
16
+ CreateDeliverableInput
15
17
  } from '../types/index.js';
16
18
  import { generateProjectKey } from '../utils/key.js';
17
19
 
@@ -283,6 +285,50 @@ export class FirebaseStore implements StorageAdapter {
283
285
  return true;
284
286
  }
285
287
 
288
+ // --- Deliverables ---
289
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
290
+ const snap = await this.db.collection('deliverables').where('projectId', '==', projectId).get();
291
+ return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
292
+ }
293
+
294
+ async getDeliverable(id: string): Promise<Deliverable | null> {
295
+ const snap = await this.db.collection('deliverables').doc(id).get();
296
+ if (!snap.exists) return null;
297
+ return { ...snap.data(), id: snap.id };
298
+ }
299
+
300
+ async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
301
+ const docRef = this.db.collection('deliverables').doc();
302
+ const now = new Date().toISOString();
303
+ const newDeliverable: Deliverable = {
304
+ ...deliverable,
305
+ id: docRef.id,
306
+ status: deliverable.status || 'planned',
307
+ outputUrls: deliverable.outputUrls ? [...deliverable.outputUrls] : [],
308
+ customFields: deliverable.customFields ? { ...deliverable.customFields } : {},
309
+ createdAt: now,
310
+ updatedAt: now
311
+ };
312
+ await docRef.set(sanitizeFirestoreData(newDeliverable));
313
+ return newDeliverable;
314
+ }
315
+
316
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
317
+ const existing = await this.getDeliverable(id);
318
+ if (!existing) return null;
319
+
320
+ const updated: Deliverable = { ...existing, ...updates, updatedAt: new Date().toISOString() };
321
+ await this.db.collection('deliverables').doc(id).set(sanitizeFirestoreData(updated), { merge: true });
322
+ return updated;
323
+ }
324
+
325
+ async deleteDeliverable(id: string): Promise<boolean> {
326
+ const existing = await this.getDeliverable(id);
327
+ if (!existing) return false;
328
+ await this.db.collection('deliverables').doc(id).delete();
329
+ return true;
330
+ }
331
+
286
332
  // --- Tasks ---
287
333
  async getTasks(projectId?: string): Promise<Task[]> {
288
334
  let snap;