@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
@@ -13,7 +13,9 @@ import type {
13
13
  Activity,
14
14
  Webhook,
15
15
  TaskDependency,
16
- Workflow
16
+ Workflow,
17
+ Deliverable,
18
+ CreateDeliverableInput
17
19
  } from '../types/index.js';
18
20
  import { generateProjectKey } from '../utils/key.js';
19
21
 
@@ -102,12 +104,21 @@ export interface WebhookRepository {
102
104
  addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
103
105
  }
104
106
 
107
+ export interface DeliverableRepository {
108
+ getDeliverables(projectId: string): Promise<Deliverable[]>;
109
+ getDeliverable(id: string): Promise<Deliverable | null>;
110
+ createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable>;
111
+ updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null>;
112
+ deleteDeliverable(id: string): Promise<boolean>;
113
+ }
114
+
105
115
  export interface StorageAdapter
106
116
  extends ProjectRepository,
107
117
  WorkflowRepository,
108
118
  TaskRepository,
109
119
  TeamRepository,
110
120
  ContainerRepository,
121
+ DeliverableRepository,
111
122
  IterationRepository,
112
123
  CommentRepository,
113
124
  AttachmentRepository,
@@ -122,6 +133,7 @@ export class InMemoryStore implements StorageAdapter {
122
133
  private tasks = new Map<string, Task>();
123
134
  private teams = new Map<string, Team>();
124
135
  private containers = new Map<string, TaskContainer>();
136
+ private deliverables = new Map<string, Deliverable>();
125
137
  private iterations = new Map<string, Iteration>();
126
138
  private comments = new Map<string, Comment>();
127
139
  private attachments = new Map<string, Attachment>();
@@ -306,6 +318,47 @@ export class InMemoryStore implements StorageAdapter {
306
318
  return this.containers.delete(id);
307
319
  }
308
320
 
321
+ // Deliverables
322
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
323
+ return Array.from(this.deliverables.values()).filter((d) => d.projectId === projectId);
324
+ }
325
+
326
+ async getDeliverable(id: string): Promise<Deliverable | null> {
327
+ return this.deliverables.get(id) || null;
328
+ }
329
+
330
+ async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
331
+ const id = (deliverable as any).id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
332
+ const now = new Date().toISOString();
333
+ const newDeliverable: Deliverable = {
334
+ ...deliverable,
335
+ id,
336
+ status: deliverable.status || 'planned',
337
+ outputUrls: deliverable.outputUrls ? [...deliverable.outputUrls] : [],
338
+ customFields: deliverable.customFields ? { ...deliverable.customFields } : {},
339
+ createdAt: now,
340
+ updatedAt: now
341
+ };
342
+ this.deliverables.set(id, newDeliverable);
343
+ return newDeliverable;
344
+ }
345
+
346
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
347
+ const existing = this.deliverables.get(id);
348
+ if (!existing) return null;
349
+ const updated: Deliverable = {
350
+ ...existing,
351
+ ...updates,
352
+ updatedAt: new Date().toISOString()
353
+ };
354
+ this.deliverables.set(id, updated);
355
+ return updated;
356
+ }
357
+
358
+ async deleteDeliverable(id: string): Promise<boolean> {
359
+ return this.deliverables.delete(id);
360
+ }
361
+
309
362
  // Iterations
310
363
  async getIterations(projectId: string): Promise<Iteration[]> {
311
364
  return Array.from(this.iterations.values()).filter((s) => s.projectId === projectId);
@@ -77,11 +77,16 @@ describe('SQLiteStore', () => {
77
77
  const comments = await store.getComments('task_1');
78
78
  expect(comments).toHaveLength(2);
79
79
 
80
- const updatedComment = await store.updateComment(comment.id, { content: 'Updated content' });
80
+ const updatedComment = await store.updateComment(comment.id, {
81
+ content: 'Updated content',
82
+ mentions: ['planner', 'coordinator']
83
+ });
81
84
  expect(updatedComment?.content).toBe('Updated content');
85
+ expect(updatedComment?.mentions).toEqual(['planner', 'coordinator']);
82
86
 
83
87
  const fetchedComment = await store.getComment(comment.id);
84
88
  expect(fetchedComment?.content).toBe('Updated content');
89
+ expect(fetchedComment?.mentions).toEqual(['planner', 'coordinator']);
85
90
 
86
91
  const timeEntry = await store.logTime({
87
92
  taskId: 'task_1',
@@ -12,7 +12,9 @@ import type {
12
12
  Activity,
13
13
  Webhook,
14
14
  TaskDependency,
15
- Workflow
15
+ Workflow,
16
+ Deliverable,
17
+ CreateDeliverableInput
16
18
  } from '../types/index.js';
17
19
  import { generateProjectKey } from '../utils/key.js';
18
20
 
@@ -116,6 +118,7 @@ export class SQLiteStore implements StorageAdapter {
116
118
  iterationId TEXT,
117
119
  teamId TEXT,
118
120
  containerId TEXT,
121
+ deliverableId TEXT,
119
122
  plannedStartDate TEXT,
120
123
  actualStartDate TEXT,
121
124
  actualEndDate TEXT,
@@ -135,6 +138,24 @@ export class SQLiteStore implements StorageAdapter {
135
138
  updatedAt TEXT NOT NULL
136
139
  );
137
140
 
141
+ CREATE TABLE IF NOT EXISTS deliverables (
142
+ id TEXT PRIMARY KEY,
143
+ projectId TEXT NOT NULL,
144
+ title TEXT NOT NULL,
145
+ description TEXT,
146
+ status TEXT NOT NULL,
147
+ format TEXT,
148
+ specs TEXT,
149
+ leadId TEXT,
150
+ reviewerId TEXT,
151
+ dueDate TEXT,
152
+ deliveredAt TEXT,
153
+ outputUrls TEXT,
154
+ customFields TEXT,
155
+ createdAt TEXT NOT NULL,
156
+ updatedAt TEXT NOT NULL
157
+ );
158
+
138
159
  CREATE TABLE IF NOT EXISTS iterations (
139
160
  id TEXT PRIMARY KEY,
140
161
  projectId TEXT NOT NULL,
@@ -154,6 +175,7 @@ export class SQLiteStore implements StorageAdapter {
154
175
  authorType TEXT,
155
176
  parentId TEXT,
156
177
  content TEXT NOT NULL,
178
+ mentions TEXT,
157
179
  reactions TEXT,
158
180
  metadata TEXT,
159
181
  createdAt TEXT NOT NULL,
@@ -219,6 +241,18 @@ export class SQLiteStore implements StorageAdapter {
219
241
  } catch {
220
242
  // Column may already exist
221
243
  }
244
+
245
+ try {
246
+ this.db.exec('ALTER TABLE comments ADD COLUMN mentions TEXT');
247
+ } catch {
248
+ // Column may already exist
249
+ }
250
+
251
+ try {
252
+ this.db.exec('ALTER TABLE tasks ADD COLUMN deliverableId TEXT');
253
+ } catch {
254
+ // Column may already exist
255
+ }
222
256
  }
223
257
 
224
258
  // --- Projects ---
@@ -496,6 +530,98 @@ export class SQLiteStore implements StorageAdapter {
496
530
  return (result.changes ?? 0) > 0;
497
531
  }
498
532
 
533
+ // --- Deliverables ---
534
+ async getDeliverables(projectId: string): Promise<Deliverable[]> {
535
+ const stmt = this.db.prepare('SELECT * FROM deliverables WHERE projectId = ?');
536
+ const rows = stmt.all(projectId) as any[];
537
+ return rows.map((r) => this.mapDeliverable(r));
538
+ }
539
+
540
+ async getDeliverable(id: string): Promise<Deliverable | null> {
541
+ const stmt = this.db.prepare('SELECT * FROM deliverables WHERE id = ?');
542
+ const row = stmt.get(id) as any;
543
+ return row ? this.mapDeliverable(row) : null;
544
+ }
545
+
546
+ async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
547
+ const id = `deliv_${Math.random().toString(36).substring(2, 9)}`;
548
+ const now = new Date().toISOString();
549
+ const newDeliverable: Deliverable = {
550
+ ...deliverable,
551
+ id,
552
+ status: deliverable.status || 'planned',
553
+ outputUrls: deliverable.outputUrls ? [...deliverable.outputUrls] : [],
554
+ customFields: deliverable.customFields ? { ...deliverable.customFields } : {},
555
+ createdAt: now,
556
+ updatedAt: now
557
+ };
558
+
559
+ const stmt = this.db.prepare(`
560
+ INSERT INTO deliverables (
561
+ id, projectId, title, description, status, format, specs, leadId, reviewerId,
562
+ dueDate, deliveredAt, outputUrls, customFields, createdAt, updatedAt
563
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
564
+ `);
565
+ stmt.run(
566
+ newDeliverable.id,
567
+ newDeliverable.projectId,
568
+ newDeliverable.title,
569
+ newDeliverable.description || null,
570
+ newDeliverable.status,
571
+ newDeliverable.format || null,
572
+ newDeliverable.specs ? JSON.stringify(newDeliverable.specs) : null,
573
+ newDeliverable.leadId || null,
574
+ newDeliverable.reviewerId || null,
575
+ newDeliverable.dueDate || null,
576
+ newDeliverable.deliveredAt || null,
577
+ JSON.stringify(newDeliverable.outputUrls || []),
578
+ JSON.stringify(newDeliverable.customFields || {}),
579
+ newDeliverable.createdAt,
580
+ newDeliverable.updatedAt
581
+ );
582
+ return newDeliverable;
583
+ }
584
+
585
+ async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
586
+ const existing = await this.getDeliverable(id);
587
+ if (!existing) return null;
588
+
589
+ const updated: Deliverable = {
590
+ ...existing,
591
+ ...updates,
592
+ updatedAt: new Date().toISOString()
593
+ };
594
+
595
+ const stmt = this.db.prepare(`
596
+ UPDATE deliverables SET
597
+ title = ?, description = ?, status = ?, format = ?, specs = ?, leadId = ?, reviewerId = ?,
598
+ dueDate = ?, deliveredAt = ?, outputUrls = ?, customFields = ?, updatedAt = ?
599
+ WHERE id = ?
600
+ `);
601
+ stmt.run(
602
+ updated.title,
603
+ updated.description || null,
604
+ updated.status,
605
+ updated.format || null,
606
+ updated.specs ? JSON.stringify(updated.specs) : null,
607
+ updated.leadId || null,
608
+ updated.reviewerId || null,
609
+ updated.dueDate || null,
610
+ updated.deliveredAt || null,
611
+ JSON.stringify(updated.outputUrls || []),
612
+ JSON.stringify(updated.customFields || {}),
613
+ updated.updatedAt,
614
+ id
615
+ );
616
+ return updated;
617
+ }
618
+
619
+ async deleteDeliverable(id: string): Promise<boolean> {
620
+ const stmt = this.db.prepare('DELETE FROM deliverables WHERE id = ?');
621
+ const result = stmt.run(id);
622
+ return (result.changes ?? 0) > 0;
623
+ }
624
+
499
625
  // --- Tasks ---
500
626
  async getTasks(projectId?: string): Promise<Task[]> {
501
627
  let rows: any[];
@@ -523,11 +649,11 @@ export class SQLiteStore implements StorageAdapter {
523
649
  const stmt = this.db.prepare(`
524
650
  INSERT INTO tasks (
525
651
  id, projectId, title, description, status, priority, taskType, assigneeId, reporterId,
526
- reviewerId, iterationId, teamId, containerId, plannedStartDate, actualStartDate, actualEndDate,
652
+ reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
527
653
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
528
654
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
529
655
  tags, customFields, parentId, createdAt, updatedAt
530
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
656
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
531
657
  `);
532
658
  stmt.run(
533
659
  newTask.id,
@@ -543,6 +669,7 @@ export class SQLiteStore implements StorageAdapter {
543
669
  newTask.iterationId || null,
544
670
  newTask.teamId || null,
545
671
  newTask.containerId || null,
672
+ newTask.deliverableId || null,
546
673
  newTask.plannedStartDate || null,
547
674
  newTask.actualStartDate || null,
548
675
  newTask.actualEndDate || null,
@@ -577,7 +704,7 @@ export class SQLiteStore implements StorageAdapter {
577
704
  const stmt = this.db.prepare(`
578
705
  UPDATE tasks SET
579
706
  projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
580
- assigneeId = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?,
707
+ assigneeId = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
581
708
  plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
582
709
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
583
710
  estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?, progress = ?,
@@ -597,6 +724,7 @@ export class SQLiteStore implements StorageAdapter {
597
724
  updated.iterationId || null,
598
725
  updated.teamId || null,
599
726
  updated.containerId || null,
727
+ updated.deliverableId || null,
600
728
  updated.plannedStartDate || null,
601
729
  updated.actualStartDate || null,
602
730
  updated.actualEndDate || null,
@@ -697,6 +825,7 @@ export class SQLiteStore implements StorageAdapter {
697
825
  authorType: row.authorType,
698
826
  parentId: row.parentId || undefined,
699
827
  content: row.content,
828
+ mentions: row.mentions ? JSON.parse(row.mentions) : undefined,
700
829
  reactions: row.reactions ? JSON.parse(row.reactions) : undefined,
701
830
  metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
702
831
  createdAt: row.createdAt,
@@ -723,8 +852,8 @@ export class SQLiteStore implements StorageAdapter {
723
852
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
724
853
 
725
854
  const stmt = this.db.prepare(`
726
- INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, reactions, metadata, createdAt, updatedAt)
727
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
855
+ INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, mentions, reactions, metadata, createdAt, updatedAt)
856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
728
857
  `);
729
858
  stmt.run(
730
859
  newComment.id,
@@ -733,6 +862,7 @@ export class SQLiteStore implements StorageAdapter {
733
862
  newComment.authorType || 'user',
734
863
  newComment.parentId || null,
735
864
  newComment.content,
865
+ newComment.mentions ? JSON.stringify(newComment.mentions) : null,
736
866
  newComment.reactions ? JSON.stringify(newComment.reactions) : null,
737
867
  newComment.metadata ? JSON.stringify(newComment.metadata) : null,
738
868
  newComment.createdAt,
@@ -757,6 +887,7 @@ export class SQLiteStore implements StorageAdapter {
757
887
  authorType = ?,
758
888
  parentId = ?,
759
889
  content = ?,
890
+ mentions = ?,
760
891
  reactions = ?,
761
892
  metadata = ?,
762
893
  updatedAt = ?
@@ -767,6 +898,7 @@ export class SQLiteStore implements StorageAdapter {
767
898
  updated.authorType || 'user',
768
899
  updated.parentId || null,
769
900
  updated.content,
901
+ updated.mentions ? JSON.stringify(updated.mentions) : null,
770
902
  updated.reactions ? JSON.stringify(updated.reactions) : null,
771
903
  updated.metadata ? JSON.stringify(updated.metadata) : null,
772
904
  updated.updatedAt,
@@ -1048,8 +1180,18 @@ export class SQLiteStore implements StorageAdapter {
1048
1180
  return {
1049
1181
  ...row,
1050
1182
  taskType: row.taskType || undefined,
1183
+ deliverableId: row.deliverableId || undefined,
1051
1184
  tags: row.tags ? JSON.parse(row.tags) : [],
1052
1185
  customFields: row.customFields ? JSON.parse(row.customFields) : {}
1053
1186
  };
1054
1187
  }
1188
+
1189
+ private mapDeliverable(row: any): Deliverable {
1190
+ return {
1191
+ ...row,
1192
+ specs: row.specs ? JSON.parse(row.specs) : undefined,
1193
+ outputUrls: row.outputUrls ? JSON.parse(row.outputUrls) : [],
1194
+ customFields: row.customFields ? JSON.parse(row.customFields) : {}
1195
+ };
1196
+ }
1055
1197
  }
@@ -142,6 +142,7 @@ export interface Task {
142
142
  iterationId?: string;
143
143
  teamId?: string;
144
144
  containerId?: string;
145
+ deliverableId?: string; // Direct link to parent deliverable
145
146
  plannedStartDate?: string;
146
147
  actualStartDate?: string;
147
148
  actualEndDate?: string;
@@ -169,6 +170,50 @@ export type CreateTaskInput = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'sta
169
170
  priority?: Priority;
170
171
  };
171
172
 
173
+ export type DeliverableStatus =
174
+ | 'draft'
175
+ | 'planned'
176
+ | 'in_production'
177
+ | 'internal_review'
178
+ | 'client_review'
179
+ | 'revision_requested'
180
+ | 'approved'
181
+ | 'delivered'
182
+ | 'canceled'
183
+ | (string & {});
184
+
185
+ export interface Deliverable {
186
+ id: string;
187
+ projectId: string;
188
+ title: string;
189
+ description?: string;
190
+ status: DeliverableStatus;
191
+ format?: string; // e.g., '16:9 4K ProRes', 'Figma', 'PDF'
192
+ specs?: Record<string, unknown>; // Technical specifications (resolution, frame rate, etc.)
193
+ leadId?: string; // Creative director or lead creator
194
+ reviewerId?: string; // Approver or client reviewer
195
+ dueDate?: string;
196
+ deliveredAt?: string;
197
+ outputUrls?: string[]; // Links to final rendered assets, exports, or storage keys
198
+ customFields?: Record<string, unknown>;
199
+ createdAt: string;
200
+ updatedAt: string;
201
+ }
202
+
203
+ export interface DeliverableSummary {
204
+ deliverable: Deliverable;
205
+ totalTasks: number;
206
+ completedTasks: number;
207
+ activeTasks: number;
208
+ progressPercentage: number;
209
+ estimatedHours: number;
210
+ loggedHours: number;
211
+ }
212
+
213
+ export type CreateDeliverableInput = Omit<Deliverable, 'id' | 'createdAt' | 'updatedAt' | 'status'> & {
214
+ status?: DeliverableStatus;
215
+ };
216
+
172
217
  export type CreateProjectInput = Omit<Project, 'id' | 'createdAt' | 'updatedAt'>;
173
218
 
174
219
  export interface Iteration {
@@ -198,6 +243,7 @@ export interface Comment {
198
243
  authorType?: AuthorType;
199
244
  parentId?: string; // Threaded reply support
200
245
  content: string;
246
+ mentions?: string[];
201
247
  reactions?: CommentReaction[];
202
248
  metadata?: Record<string, unknown>;
203
249
  createdAt: string;
@@ -308,7 +354,11 @@ export type WebhookEvent =
308
354
  | 'container.created'
309
355
  | 'workflow.created'
310
356
  | 'workflow.updated'
311
- | 'workflow.deleted';
357
+ | 'workflow.deleted'
358
+ | 'deliverable.created'
359
+ | 'deliverable.updated'
360
+ | 'deliverable.deleted'
361
+ | 'deliverable.status_changed';
312
362
 
313
363
  export interface PluginHooks {
314
364
  beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
@@ -342,5 +392,6 @@ export interface CriticalPathConfig {
342
392
  teams?: Team[];
343
393
  containers?: TaskContainer[];
344
394
  workflows?: Workflow[];
395
+ deliverables?: Deliverable[];
345
396
  };
346
397
  }
@@ -1,4 +1,5 @@
1
1
  export * from './status.js';
2
2
  export * from './key.js';
3
3
  export * from './workflow.js';
4
+ export * from './mentions.js';
4
5
 
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractMentions, parseMentionSegments } from './mentions.js';
3
+
4
+ describe('mentions utility', () => {
5
+ describe('extractMentions', () => {
6
+ it('returns empty array for empty or non-string input', () => {
7
+ expect(extractMentions('')).toEqual([]);
8
+ expect(extractMentions(null as any)).toEqual([]);
9
+ expect(extractMentions(undefined as any)).toEqual([]);
10
+ });
11
+
12
+ it('extracts single mention', () => {
13
+ expect(extractMentions('Hello @planner, please review.')).toEqual(['planner']);
14
+ });
15
+
16
+ it('extracts multiple mentions and deduplicates them', () => {
17
+ const text = 'Hey @jane and @planner, can @jane check with @coordinator?';
18
+ expect(extractMentions(text)).toEqual(['jane', 'planner', 'coordinator']);
19
+ });
20
+
21
+ it('supports quoted handles with spaces', () => {
22
+ const text = 'Assigning to @"Agent Planner" and @"Jane Doe"';
23
+ expect(extractMentions(text)).toEqual(['Agent Planner', 'Jane Doe']);
24
+ });
25
+
26
+ it('ignores email addresses without leading space or boundary', () => {
27
+ const text = 'Send mail to user@example.com or ping @alex';
28
+ expect(extractMentions(text)).toEqual(['alex']);
29
+ });
30
+
31
+ it('handles mentions with dashes, underscores, and periods', () => {
32
+ const text = 'Call @agent-1, @super_user, and @john.doe';
33
+ expect(extractMentions(text)).toEqual(['agent-1', 'super_user', 'john.doe']);
34
+ });
35
+ });
36
+
37
+ describe('parseMentionSegments', () => {
38
+ it('returns empty array for empty string', () => {
39
+ expect(parseMentionSegments('')).toEqual([]);
40
+ });
41
+
42
+ it('returns single text segment if no mentions', () => {
43
+ expect(parseMentionSegments('Just a normal comment without tags.')).toEqual([
44
+ { type: 'text', value: 'Just a normal comment without tags.' }
45
+ ]);
46
+ });
47
+
48
+ it('correctly segments text with single mention in the middle', () => {
49
+ const result = parseMentionSegments('Hello @planner, please check.');
50
+ expect(result).toEqual([
51
+ { type: 'text', value: 'Hello ' },
52
+ { type: 'mention', value: '@planner', handle: 'planner' },
53
+ { type: 'text', value: ', please check.' }
54
+ ]);
55
+ });
56
+
57
+ it('correctly segments text starting with a mention', () => {
58
+ const result = parseMentionSegments('@planner can you take a look?');
59
+ expect(result).toEqual([
60
+ { type: 'mention', value: '@planner', handle: 'planner' },
61
+ { type: 'text', value: ' can you take a look?' }
62
+ ]);
63
+ });
64
+
65
+ it('correctly segments text ending with a mention', () => {
66
+ const result = parseMentionSegments('Assigned to @coordinator');
67
+ expect(result).toEqual([
68
+ { type: 'text', value: 'Assigned to ' },
69
+ { type: 'mention', value: '@coordinator', handle: 'coordinator' }
70
+ ]);
71
+ });
72
+
73
+ it('correctly segments quoted mentions', () => {
74
+ const result = parseMentionSegments('Ping @"Agent Supervisor" ASAP');
75
+ expect(result).toEqual([
76
+ { type: 'text', value: 'Ping ' },
77
+ { type: 'mention', value: '@"Agent Supervisor"', handle: 'Agent Supervisor' },
78
+ { type: 'text', value: ' ASAP' }
79
+ ]);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,89 @@
1
+ export interface MentionSegment {
2
+ type: 'text' | 'mention';
3
+ value: string;
4
+ handle?: string;
5
+ }
6
+
7
+ /**
8
+ * Regex matching mentions:
9
+ * - Quoted mentions: @"Jane Doe"
10
+ * - Standard mentions: @jane_doe, @planner, @user-1, @john.smith
11
+ * Note: Does not match emails like foo@bar.com (requires word boundary or start of string before @).
12
+ */
13
+ export const MENTION_REGEX = /(?:^|\s)@(?:"([^"]+)"|([a-zA-Z0-9_.-]+))/g;
14
+
15
+ /**
16
+ * Extracts unique mention handles from a text string.
17
+ * Strips quotes and the leading '@' symbol.
18
+ * Preserves the order of their first appearance.
19
+ */
20
+ export function extractMentions(content: string): string[] {
21
+ if (!content || typeof content !== 'string') return [];
22
+
23
+ const mentions: string[] = [];
24
+ const seen = new Set<string>();
25
+
26
+ // Reset regex lastIndex
27
+ const regex = new RegExp(MENTION_REGEX.source, 'g');
28
+ let match: RegExpExecArray | null;
29
+
30
+ while ((match = regex.exec(content)) !== null) {
31
+ const handle = match[1] ?? match[2];
32
+ if (handle && !seen.has(handle)) {
33
+ seen.add(handle);
34
+ mentions.push(handle);
35
+ }
36
+ }
37
+
38
+ return mentions;
39
+ }
40
+
41
+ /**
42
+ * Segments a text string into plain text parts and mention tokens.
43
+ * Useful for rendering highlighted mention badges or links in UI components.
44
+ */
45
+ export function parseMentionSegments(content: string): MentionSegment[] {
46
+ if (!content) return [];
47
+
48
+ const segments: MentionSegment[] = [];
49
+ const regex = new RegExp(MENTION_REGEX.source, 'g');
50
+ let lastIndex = 0;
51
+ let match: RegExpExecArray | null;
52
+
53
+ while ((match = regex.exec(content)) !== null) {
54
+ const fullMatch = match[0];
55
+ const leadingWhitespace = fullMatch.startsWith(' ') || fullMatch.startsWith('\t') || fullMatch.startsWith('\n')
56
+ ? fullMatch[0]
57
+ : '';
58
+ const mentionToken = fullMatch.slice(leadingWhitespace.length);
59
+ const handle = match[1] ?? match[2];
60
+ const matchStart = match.index + leadingWhitespace.length;
61
+
62
+ // Push text before this mention if any
63
+ if (matchStart > lastIndex) {
64
+ segments.push({
65
+ type: 'text',
66
+ value: content.slice(lastIndex, matchStart)
67
+ });
68
+ }
69
+
70
+ // Push the mention token
71
+ segments.push({
72
+ type: 'mention',
73
+ value: mentionToken,
74
+ handle
75
+ });
76
+
77
+ lastIndex = match.index + fullMatch.length;
78
+ }
79
+
80
+ // Push any remaining text after the last match
81
+ if (lastIndex < content.length) {
82
+ segments.push({
83
+ type: 'text',
84
+ value: content.slice(lastIndex)
85
+ });
86
+ }
87
+
88
+ return segments;
89
+ }
@@ -117,6 +117,44 @@ export const DEFAULT_VFX_WORKFLOW: Workflow = {
117
117
  updatedAt: '2026-01-01T00:00:00.000Z'
118
118
  };
119
119
 
120
+ export const DEFAULT_CREATIVE_WORKFLOW: Workflow = {
121
+ id: 'wf_creative_default',
122
+ name: 'Creative Production Workflow',
123
+ description: 'Production pipeline for creative deliverables from briefing and concepts to internal review, client feedback, and final delivery.',
124
+ statuses: [
125
+ { key: 'briefing', label: 'Briefing & Scoping', completionState: 'not_done', executionState: 'inactive' },
126
+ { key: 'concept', label: 'Concept & Storyboarding', completionState: 'not_done', executionState: 'active' },
127
+ { key: 'in_production', label: 'In Production', completionState: 'not_done', executionState: 'active' },
128
+ { key: 'internal_review', label: 'Internal Review', completionState: 'not_done', executionState: 'active' },
129
+ { key: 'client_review', label: 'Client Review', completionState: 'not_done', executionState: 'active' },
130
+ { key: 'revision_requested', label: 'Revision Requested', completionState: 'not_done', executionState: 'active' },
131
+ { key: 'approved', label: 'Approved', completionState: 'done', executionState: 'inactive' },
132
+ { key: 'delivered', label: 'Delivered', completionState: 'done', executionState: 'inactive' },
133
+ DEFAULT_STATUS_DEFINITIONS.canceled
134
+ ],
135
+ transitions: [
136
+ { name: 'Start Concepts', fromStatusKey: 'briefing', toStatusKey: 'concept' },
137
+ { name: 'Approve Concept for Production', fromStatusKey: 'concept', toStatusKey: 'in_production' },
138
+ { name: 'Submit for Internal Review', fromStatusKey: 'in_production', toStatusKey: 'internal_review' },
139
+ { name: 'Submit for Client Review', fromStatusKey: 'internal_review', toStatusKey: 'client_review' },
140
+ { name: 'Request Revisions (Internal)', fromStatusKey: 'internal_review', toStatusKey: 'revision_requested' },
141
+ { name: 'Request Revisions (Client)', fromStatusKey: 'client_review', toStatusKey: 'revision_requested' },
142
+ { name: 'Address Revisions', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
143
+ { name: 'Approve Work', fromStatusKey: 'client_review', toStatusKey: 'approved' },
144
+ { name: 'Deliver Final Assets', fromStatusKey: 'approved', toStatusKey: 'delivered' },
145
+ { name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' }
146
+ ],
147
+ taskTypes: [
148
+ { key: 'deliverable', label: 'Deliverable', description: 'Finished asset, cutdown, or packaged export', icon: 'package' },
149
+ { key: 'creative_task', label: 'Creative Task', description: 'Production task (e.g. Design, Copy, Editing, Color)', icon: 'pen-tool' },
150
+ { key: 'review', label: 'Review Gate', description: 'Internal or external client sign-off', icon: 'check-circle' }
151
+ ],
152
+ defaultStatusKey: 'briefing',
153
+ isDefault: false,
154
+ createdAt: '2026-01-01T00:00:00.000Z',
155
+ updatedAt: '2026-01-01T00:00:00.000Z'
156
+ };
157
+
120
158
  export function validateTransition(
121
159
  workflow: Workflow | undefined,
122
160
  fromStatus: string,