@critical-path/core 0.2.1 → 0.4.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 (61) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/engine/index.d.ts +8 -1
  4. package/dist/engine/index.d.ts.map +1 -1
  5. package/dist/engine/index.js +90 -2
  6. package/dist/engine/index.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.test.js +44 -0
  12. package/dist/index.test.js.map +1 -1
  13. package/dist/store/firebase.d.ts +6 -1
  14. package/dist/store/firebase.d.ts.map +1 -1
  15. package/dist/store/firebase.js +40 -1
  16. package/dist/store/firebase.js.map +1 -1
  17. package/dist/store/firebase.test.js +16 -0
  18. package/dist/store/firebase.test.js.map +1 -1
  19. package/dist/store/index.d.ts +12 -1
  20. package/dist/store/index.d.ts.map +1 -1
  21. package/dist/store/index.js +38 -0
  22. package/dist/store/index.js.map +1 -1
  23. package/dist/store/sqlite.d.ts +7 -1
  24. package/dist/store/sqlite.d.ts.map +1 -1
  25. package/dist/store/sqlite.js +84 -11
  26. package/dist/store/sqlite.js.map +1 -1
  27. package/dist/store/sqlite.test.js +21 -0
  28. package/dist/store/sqlite.test.js.map +1 -1
  29. package/dist/types/index.d.ts +34 -2
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/dist/utils/index.d.ts +4 -0
  32. package/dist/utils/index.d.ts.map +1 -0
  33. package/dist/utils/index.js +4 -0
  34. package/dist/utils/index.js.map +1 -0
  35. package/dist/utils/key.d.ts +14 -0
  36. package/dist/utils/key.d.ts.map +1 -0
  37. package/dist/utils/key.js +49 -0
  38. package/dist/utils/key.js.map +1 -0
  39. package/dist/utils/key.test.d.ts +2 -0
  40. package/dist/utils/key.test.d.ts.map +1 -0
  41. package/dist/utils/key.test.js +48 -0
  42. package/dist/utils/key.test.js.map +1 -0
  43. package/dist/utils/workflow.d.ts +13 -0
  44. package/dist/utils/workflow.d.ts.map +1 -0
  45. package/dist/utils/workflow.js +101 -0
  46. package/dist/utils/workflow.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/engine/index.ts +103 -3
  49. package/src/index.test.ts +54 -0
  50. package/src/index.ts +2 -1
  51. package/src/store/firebase.test.ts +21 -0
  52. package/src/store/firebase.ts +45 -2
  53. package/src/store/index.ts +51 -1
  54. package/src/store/sqlite.test.ts +28 -0
  55. package/src/store/sqlite.ts +119 -10
  56. package/src/types/index.ts +40 -2
  57. package/src/utils/index.ts +4 -0
  58. package/src/utils/key.test.ts +55 -0
  59. package/src/utils/key.ts +51 -0
  60. package/src/utils/workflow.ts +128 -0
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -11,8 +11,10 @@ import type {
11
11
  TimeEntry,
12
12
  Activity,
13
13
  Webhook,
14
- TaskDependency
14
+ TaskDependency,
15
+ Workflow
15
16
  } from '../types/index.js';
17
+ import { generateProjectKey } from '../utils/key.js';
16
18
 
17
19
  export interface StorageAdapter {
18
20
  // Projects
@@ -22,6 +24,13 @@ export interface StorageAdapter {
22
24
  updateProject(id: string, updates: Partial<Project>): Promise<Project | null>;
23
25
  deleteProject(id: string): Promise<boolean>;
24
26
 
27
+ // Workflows
28
+ getWorkflows(): Promise<Workflow[]>;
29
+ getWorkflow(id: string): Promise<Workflow | null>;
30
+ createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow>;
31
+ updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null>;
32
+ deleteWorkflow(id: string): Promise<boolean>;
33
+
25
34
  // Tasks
26
35
  getTasks(projectId?: string): Promise<Task[]>;
27
36
  getTask(id: string): Promise<Task | null>;
@@ -71,6 +80,7 @@ export interface StorageAdapter {
71
80
 
72
81
  export class InMemoryStore implements StorageAdapter {
73
82
  private projects = new Map<string, Project>();
83
+ private workflows = new Map<string, Workflow>();
74
84
  private tasks = new Map<string, Task>();
75
85
  private teams = new Map<string, Team>();
76
86
  private containers = new Map<string, TaskContainer>();
@@ -92,8 +102,10 @@ export class InMemoryStore implements StorageAdapter {
92
102
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
93
103
  const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
94
104
  const now = new Date().toISOString();
105
+ const key = project.key || generateProjectKey(project.name);
95
106
  const newProject: Project = {
96
107
  ...project,
108
+ key,
97
109
  id,
98
110
  createdAt: now,
99
111
  updatedAt: now
@@ -118,6 +130,44 @@ export class InMemoryStore implements StorageAdapter {
118
130
  return this.projects.delete(id);
119
131
  }
120
132
 
133
+ // Workflows
134
+ async getWorkflows(): Promise<Workflow[]> {
135
+ return Array.from(this.workflows.values());
136
+ }
137
+
138
+ async getWorkflow(id: string): Promise<Workflow | null> {
139
+ return this.workflows.get(id) || null;
140
+ }
141
+
142
+ async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
143
+ const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
144
+ const now = new Date().toISOString();
145
+ const newWorkflow: Workflow = {
146
+ ...workflow,
147
+ id,
148
+ createdAt: now,
149
+ updatedAt: now
150
+ };
151
+ this.workflows.set(id, newWorkflow);
152
+ return newWorkflow;
153
+ }
154
+
155
+ async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
156
+ const existing = this.workflows.get(id);
157
+ if (!existing) return null;
158
+ const updated: Workflow = {
159
+ ...existing,
160
+ ...updates,
161
+ updatedAt: new Date().toISOString()
162
+ };
163
+ this.workflows.set(id, updated);
164
+ return updated;
165
+ }
166
+
167
+ async deleteWorkflow(id: string): Promise<boolean> {
168
+ return this.workflows.delete(id);
169
+ }
170
+
121
171
  async getTasks(projectId?: string): Promise<Task[]> {
122
172
  const all = Array.from(this.tasks.values());
123
173
  if (projectId) {
@@ -75,4 +75,32 @@ describe('SQLiteStore', () => {
75
75
  const entries = await store.getTimeEntries('task_1');
76
76
  expect(entries).toHaveLength(1);
77
77
  });
78
+
79
+ it('should create, retrieve, update, and delete workflows', async () => {
80
+ const workflow = await store.createWorkflow({
81
+ name: 'SQLite Workflow',
82
+ isDefault: true,
83
+ defaultStatusKey: 'todo',
84
+ statuses: [{ key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' }],
85
+ transitions: []
86
+ });
87
+
88
+ expect(workflow.id).toMatch(/^wf_/);
89
+ expect(workflow.name).toBe('SQLite Workflow');
90
+
91
+ const fetched = await store.getWorkflow(workflow.id);
92
+ expect(fetched?.isDefault).toBe(true);
93
+
94
+ const updated = await store.updateWorkflow(workflow.id, { name: 'Updated SQLite Workflow' });
95
+ expect(updated?.name).toBe('Updated SQLite Workflow');
96
+
97
+ const all = await store.getWorkflows();
98
+ expect(all).toHaveLength(1);
99
+
100
+ const deleted = await store.deleteWorkflow(workflow.id);
101
+ expect(deleted).toBe(true);
102
+
103
+ const remaining = await store.getWorkflows();
104
+ expect(remaining).toHaveLength(0);
105
+ });
78
106
  });
@@ -10,8 +10,10 @@ import type {
10
10
  TimeEntry,
11
11
  Activity,
12
12
  Webhook,
13
- TaskDependency
13
+ TaskDependency,
14
+ Workflow
14
15
  } from '../types/index.js';
16
+ import { generateProjectKey } from '../utils/key.js';
15
17
 
16
18
  export interface SQLiteStoreConfig {
17
19
  /**
@@ -47,6 +49,8 @@ export class SQLiteStore implements StorageAdapter {
47
49
  ownerId TEXT,
48
50
  members TEXT,
49
51
  teamIds TEXT,
52
+ workflowId TEXT,
53
+ taskTypes TEXT,
50
54
  statusDefinitions TEXT,
51
55
  priorityDefinitions TEXT,
52
56
  customFieldDefinitions TEXT,
@@ -54,6 +58,19 @@ export class SQLiteStore implements StorageAdapter {
54
58
  updatedAt TEXT NOT NULL
55
59
  );
56
60
 
61
+ CREATE TABLE IF NOT EXISTS workflows (
62
+ id TEXT PRIMARY KEY,
63
+ name TEXT NOT NULL,
64
+ description TEXT,
65
+ statuses TEXT NOT NULL,
66
+ transitions TEXT NOT NULL,
67
+ taskTypes TEXT,
68
+ defaultStatusKey TEXT,
69
+ isDefault INTEGER,
70
+ createdAt TEXT NOT NULL,
71
+ updatedAt TEXT NOT NULL
72
+ );
73
+
57
74
  CREATE TABLE IF NOT EXISTS teams (
58
75
  id TEXT PRIMARY KEY,
59
76
  name TEXT NOT NULL,
@@ -83,6 +100,7 @@ export class SQLiteStore implements StorageAdapter {
83
100
  description TEXT,
84
101
  status TEXT NOT NULL,
85
102
  priority TEXT NOT NULL,
103
+ taskType TEXT,
86
104
  assigneeId TEXT,
87
105
  reporterId TEXT,
88
106
  reviewerId TEXT,
@@ -183,20 +201,23 @@ export class SQLiteStore implements StorageAdapter {
183
201
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
184
202
  const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
185
203
  const now = new Date().toISOString();
186
- const newProj: Project = { ...project, id, createdAt: now, updatedAt: now };
204
+ const key = project.key || generateProjectKey(project.name);
205
+ const newProj: Project = { ...project, key, id, createdAt: now, updatedAt: now };
187
206
 
188
207
  const stmt = this.db.prepare(`
189
- INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, statusDefinitions, priorityDefinitions, customFieldDefinitions, createdAt, updatedAt)
190
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
208
+ INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, workflowId, taskTypes, statusDefinitions, priorityDefinitions, customFieldDefinitions, createdAt, updatedAt)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
210
  `);
192
211
  stmt.run(
193
212
  newProj.id,
194
- newProj.key,
213
+ newProj.key || '',
195
214
  newProj.name,
196
215
  newProj.description || null,
197
216
  newProj.ownerId || null,
198
217
  JSON.stringify(newProj.members || []),
199
218
  JSON.stringify(newProj.teamIds || []),
219
+ newProj.workflowId || null,
220
+ newProj.taskTypes ? JSON.stringify(newProj.taskTypes) : null,
200
221
  JSON.stringify(newProj.statusDefinitions || []),
201
222
  JSON.stringify(newProj.priorityDefinitions || []),
202
223
  JSON.stringify(newProj.customFieldDefinitions || []),
@@ -218,16 +239,18 @@ export class SQLiteStore implements StorageAdapter {
218
239
 
219
240
  const stmt = this.db.prepare(`
220
241
  UPDATE projects
221
- SET key = ?, name = ?, description = ?, ownerId = ?, members = ?, teamIds = ?, statusDefinitions = ?, priorityDefinitions = ?, customFieldDefinitions = ?, updatedAt = ?
242
+ SET key = ?, name = ?, description = ?, ownerId = ?, members = ?, teamIds = ?, workflowId = ?, taskTypes = ?, statusDefinitions = ?, priorityDefinitions = ?, customFieldDefinitions = ?, updatedAt = ?
222
243
  WHERE id = ?
223
244
  `);
224
245
  stmt.run(
225
- updated.key,
246
+ updated.key || '',
226
247
  updated.name,
227
248
  updated.description || null,
228
249
  updated.ownerId || null,
229
250
  JSON.stringify(updated.members || []),
230
251
  JSON.stringify(updated.teamIds || []),
252
+ updated.workflowId || null,
253
+ updated.taskTypes ? JSON.stringify(updated.taskTypes) : null,
231
254
  JSON.stringify(updated.statusDefinitions || []),
232
255
  JSON.stringify(updated.priorityDefinitions || []),
233
256
  JSON.stringify(updated.customFieldDefinitions || []),
@@ -237,6 +260,77 @@ export class SQLiteStore implements StorageAdapter {
237
260
  return updated;
238
261
  }
239
262
 
263
+ // --- Workflows ---
264
+ async getWorkflows(): Promise<Workflow[]> {
265
+ const stmt = this.db.prepare('SELECT * FROM workflows');
266
+ const rows = stmt.all() as any[];
267
+ return rows.map((r) => this.mapWorkflow(r));
268
+ }
269
+
270
+ async getWorkflow(id: string): Promise<Workflow | null> {
271
+ const stmt = this.db.prepare('SELECT * FROM workflows WHERE id = ?');
272
+ const row = stmt.get(id) as any;
273
+ return row ? this.mapWorkflow(row) : null;
274
+ }
275
+
276
+ async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
277
+ const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
278
+ const now = new Date().toISOString();
279
+ const newWf: Workflow = { ...workflow, id, createdAt: now, updatedAt: now };
280
+
281
+ const stmt = this.db.prepare(`
282
+ INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, createdAt, updatedAt)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
284
+ `);
285
+ stmt.run(
286
+ newWf.id,
287
+ newWf.name,
288
+ newWf.description || null,
289
+ JSON.stringify(newWf.statuses || []),
290
+ JSON.stringify(newWf.transitions || []),
291
+ newWf.taskTypes ? JSON.stringify(newWf.taskTypes) : null,
292
+ newWf.defaultStatusKey || null,
293
+ newWf.isDefault ? 1 : 0,
294
+ newWf.createdAt,
295
+ newWf.updatedAt
296
+ );
297
+ return newWf;
298
+ }
299
+
300
+ async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
301
+ const existing = await this.getWorkflow(id);
302
+ if (!existing) return null;
303
+
304
+ const updated: Workflow = {
305
+ ...existing,
306
+ ...updates,
307
+ updatedAt: new Date().toISOString()
308
+ };
309
+
310
+ const stmt = this.db.prepare(`
311
+ UPDATE workflows SET name = ?, description = ?, statuses = ?, transitions = ?, taskTypes = ?, defaultStatusKey = ?, isDefault = ?, updatedAt = ?
312
+ WHERE id = ?
313
+ `);
314
+ stmt.run(
315
+ updated.name,
316
+ updated.description || null,
317
+ JSON.stringify(updated.statuses || []),
318
+ JSON.stringify(updated.transitions || []),
319
+ updated.taskTypes ? JSON.stringify(updated.taskTypes) : null,
320
+ updated.defaultStatusKey || null,
321
+ updated.isDefault ? 1 : 0,
322
+ updated.updatedAt,
323
+ id
324
+ );
325
+ return updated;
326
+ }
327
+
328
+ async deleteWorkflow(id: string): Promise<boolean> {
329
+ const stmt = this.db.prepare('DELETE FROM workflows WHERE id = ?');
330
+ const result = stmt.run(id);
331
+ return (result.changes ?? 0) > 0;
332
+ }
333
+
240
334
  async deleteProject(id: string): Promise<boolean> {
241
335
  const stmt = this.db.prepare('DELETE FROM projects WHERE id = ?');
242
336
  const result = stmt.run(id);
@@ -392,12 +486,12 @@ export class SQLiteStore implements StorageAdapter {
392
486
 
393
487
  const stmt = this.db.prepare(`
394
488
  INSERT INTO tasks (
395
- id, projectId, title, description, status, priority, assigneeId, reporterId,
489
+ id, projectId, title, description, status, priority, taskType, assigneeId, reporterId,
396
490
  reviewerId, iterationId, teamId, containerId, plannedStartDate, actualStartDate, actualEndDate,
397
491
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
398
492
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
399
493
  tags, customFields, parentId, createdAt, updatedAt
400
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
494
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
401
495
  `);
402
496
  stmt.run(
403
497
  newTask.id,
@@ -406,6 +500,7 @@ export class SQLiteStore implements StorageAdapter {
406
500
  newTask.description || null,
407
501
  newTask.status,
408
502
  newTask.priority,
503
+ newTask.taskType || null,
409
504
  newTask.assigneeId || null,
410
505
  newTask.reporterId || null,
411
506
  newTask.reviewerId || null,
@@ -445,7 +540,7 @@ export class SQLiteStore implements StorageAdapter {
445
540
 
446
541
  const stmt = this.db.prepare(`
447
542
  UPDATE tasks SET
448
- projectId = ?, title = ?, description = ?, status = ?, priority = ?,
543
+ projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
449
544
  assigneeId = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?,
450
545
  plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
451
546
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
@@ -459,6 +554,7 @@ export class SQLiteStore implements StorageAdapter {
459
554
  updated.description || null,
460
555
  updated.status,
461
556
  updated.priority,
557
+ updated.taskType || null,
462
558
  updated.assigneeId || null,
463
559
  updated.reporterId || null,
464
560
  updated.reviewerId || null,
@@ -704,6 +800,8 @@ export class SQLiteStore implements StorageAdapter {
704
800
  private mapProject(row: any): Project {
705
801
  return {
706
802
  ...row,
803
+ workflowId: row.workflowId || undefined,
804
+ taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
707
805
  members: row.members ? JSON.parse(row.members) : [],
708
806
  teamIds: row.teamIds ? JSON.parse(row.teamIds) : [],
709
807
  statusDefinitions: row.statusDefinitions ? JSON.parse(row.statusDefinitions) : [],
@@ -712,6 +810,16 @@ export class SQLiteStore implements StorageAdapter {
712
810
  };
713
811
  }
714
812
 
813
+ private mapWorkflow(row: any): Workflow {
814
+ return {
815
+ ...row,
816
+ statuses: row.statuses ? JSON.parse(row.statuses) : [],
817
+ transitions: row.transitions ? JSON.parse(row.transitions) : [],
818
+ taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
819
+ isDefault: row.isDefault !== null && row.isDefault !== undefined ? Boolean(row.isDefault) : undefined
820
+ };
821
+ }
822
+
715
823
  private mapTeam(row: any): Team {
716
824
  return {
717
825
  ...row,
@@ -722,6 +830,7 @@ export class SQLiteStore implements StorageAdapter {
722
830
  private mapTask(row: any): Task {
723
831
  return {
724
832
  ...row,
833
+ taskType: row.taskType || undefined,
725
834
  tags: row.tags ? JSON.parse(row.tags) : [],
726
835
  customFields: row.customFields ? JSON.parse(row.customFields) : {}
727
836
  };
@@ -46,14 +46,46 @@ export interface CustomFieldDefinition {
46
46
  defaultValue?: unknown;
47
47
  }
48
48
 
49
+ export interface WorkflowTransition {
50
+ id?: string;
51
+ name?: string;
52
+ fromStatusKey: string | '*';
53
+ toStatusKey: string;
54
+ }
55
+
56
+ export interface TaskTypeDefinition {
57
+ key: string;
58
+ label: string;
59
+ description?: string;
60
+ icon?: string;
61
+ defaultStatusKey?: string;
62
+ workflowId?: string;
63
+ }
64
+
65
+ export interface Workflow {
66
+ id: string;
67
+ name: string;
68
+ description?: string;
69
+ statuses: StatusDefinition[];
70
+ transitions: WorkflowTransition[];
71
+ taskTypes?: TaskTypeDefinition[];
72
+ defaultStatusKey?: string;
73
+ isDefault?: boolean;
74
+ createdAt: string;
75
+ updatedAt: string;
76
+ }
77
+
49
78
  export interface Project {
50
79
  id: string;
51
- key: string; // e.g. "CP" or "PROJ"
80
+ key?: string; // e.g. "CP" or "PROJ"
52
81
  name: string;
53
82
  description?: string;
54
83
  ownerId?: string;
55
84
  members?: string[]; // user IDs
56
85
  teamIds?: string[]; // team IDs
86
+ workflowId?: string;
87
+ workflow?: Workflow;
88
+ taskTypes?: TaskTypeDefinition[];
57
89
  statusDefinitions?: StatusDefinition[];
58
90
  priorityDefinitions?: Array<{ key: string; label: string; level?: number }>;
59
91
  customFieldDefinitions?: CustomFieldDefinition[];
@@ -90,10 +122,12 @@ export interface TaskDependencyGraph {
90
122
  export interface Task {
91
123
  id: string;
92
124
  projectId: string;
125
+ key?: string; // e.g. "MOB-12"
93
126
  title: string;
94
127
  description?: string;
95
128
  status: TaskStatus;
96
129
  priority: Priority;
130
+ taskType?: string; // e.g. "bug", "feature", "task", "epic"
97
131
  assigneeId?: string;
98
132
  reporterId?: string;
99
133
  reviewerId?: string;
@@ -183,7 +217,10 @@ export type WebhookEvent =
183
217
  | 'iteration.started'
184
218
  | 'iteration.completed'
185
219
  | 'team.created'
186
- | 'container.created';
220
+ | 'container.created'
221
+ | 'workflow.created'
222
+ | 'workflow.updated'
223
+ | 'workflow.deleted';
187
224
 
188
225
  export interface PluginHooks {
189
226
  beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
@@ -215,5 +252,6 @@ export interface CriticalPathConfig {
215
252
  iterations?: Iteration[];
216
253
  teams?: Team[];
217
254
  containers?: TaskContainer[];
255
+ workflows?: Workflow[];
218
256
  };
219
257
  }
@@ -0,0 +1,4 @@
1
+ export * from './status.js';
2
+ export * from './key.js';
3
+ export * from './workflow.js';
4
+
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateProjectKey, validateProjectKey, formatTaskKey } from './key.js';
3
+
4
+ describe('Project Key Utilities', () => {
5
+ describe('generateProjectKey', () => {
6
+ it('generates a key for a single word name', () => {
7
+ expect(generateProjectKey('Operative')).toBe('OPE');
8
+ expect(generateProjectKey('App')).toBe('APP');
9
+ expect(generateProjectKey('Go')).toBe('GO');
10
+ });
11
+
12
+ it('generates a key for multi-word names', () => {
13
+ expect(generateProjectKey('Mobile App')).toBe('MA');
14
+ expect(generateProjectKey('Frontend Core Platform')).toBe('FCP');
15
+ expect(generateProjectKey('Very Long Project Name Here')).toBe('VLPN');
16
+ });
17
+
18
+ it('handles special characters and whitespace', () => {
19
+ expect(generateProjectKey(' v2.0 ')).toBe('V20');
20
+ expect(generateProjectKey('Project #1')).toBe('P1');
21
+ });
22
+
23
+ it('returns default PRJ fallback for empty input', () => {
24
+ expect(generateProjectKey('')).toBe('PRJ');
25
+ expect(generateProjectKey(' ')).toBe('PRJ');
26
+ });
27
+ });
28
+
29
+ describe('validateProjectKey', () => {
30
+ it('returns true for valid keys', () => {
31
+ expect(validateProjectKey('CP')).toBe(true);
32
+ expect(validateProjectKey('PROJ')).toBe(true);
33
+ expect(validateProjectKey('APP123')).toBe(true);
34
+ });
35
+
36
+ it('returns false for invalid keys', () => {
37
+ expect(validateProjectKey('A')).toBe(false); // too short
38
+ expect(validateProjectKey('TOOLONGKEY')).toBe(false); // too long
39
+ expect(validateProjectKey('proj')).toBe(false); // lowercase
40
+ expect(validateProjectKey('P-1')).toBe(false); // special char
41
+ expect(validateProjectKey('')).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('formatTaskKey', () => {
46
+ it('formats project key and task number', () => {
47
+ expect(formatTaskKey('MOB', 42)).toBe('MOB-42');
48
+ expect(formatTaskKey('op', 1)).toBe('OP-1');
49
+ });
50
+
51
+ it('uses fallback for missing project key', () => {
52
+ expect(formatTaskKey('', 10)).toBe('TASK-10');
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Generates a 2-6 character uppercase project short code/key from a project name.
3
+ * e.g., "Mobile App" -> "MA", "Frontend Core" -> "FC", "Operative" -> "OPE".
4
+ */
5
+ export function generateProjectKey(name: string): string {
6
+ if (!name || !name.trim()) {
7
+ return 'PRJ';
8
+ }
9
+
10
+ const words = name.trim().split(/\s+/).filter(Boolean);
11
+ let rawKey = '';
12
+
13
+ if (words.length === 1) {
14
+ const word = words[0].replace(/[^a-zA-Z0-9]/g, '');
15
+ if (word.length <= 3) {
16
+ rawKey = word.toUpperCase();
17
+ } else {
18
+ rawKey = word.substring(0, 3).toUpperCase();
19
+ }
20
+ } else {
21
+ rawKey = words
22
+ .map((w) => w.replace(/[^a-zA-Z0-9]/g, '')[0] || '')
23
+ .join('')
24
+ .substring(0, 4)
25
+ .toUpperCase();
26
+ }
27
+
28
+ // Ensure key meets basic 2-6 char alphanumeric requirement
29
+ const cleanKey = rawKey.replace(/[^A-Z0-9]/g, '');
30
+ if (cleanKey.length >= 2 && cleanKey.length <= 6) {
31
+ return cleanKey;
32
+ }
33
+
34
+ return 'PRJ';
35
+ }
36
+
37
+ /**
38
+ * Validates whether a given key meets project key format rules (2-6 uppercase alphanumeric characters).
39
+ */
40
+ export function validateProjectKey(key: string): boolean {
41
+ if (!key) return false;
42
+ return /^[A-Z0-9]{2,6}$/.test(key);
43
+ }
44
+
45
+ /**
46
+ * Formats a task key using a project key and task index (e.g. "MOB-12").
47
+ */
48
+ export function formatTaskKey(projectKey: string, taskNumber: number): string {
49
+ const prefix = projectKey ? projectKey.toUpperCase() : 'TASK';
50
+ return `${prefix}-${taskNumber}`;
51
+ }
@@ -0,0 +1,128 @@
1
+ import type {
2
+ Workflow,
3
+ StatusDefinition,
4
+ TaskTypeDefinition
5
+ } from '../types/index.js';
6
+ import { DEFAULT_STATUS_DEFINITIONS } from './status.js';
7
+
8
+ export class WorkflowValidationError extends Error {
9
+ public readonly fromStatus: string;
10
+ public readonly toStatus: string;
11
+ public readonly workflowId?: string;
12
+
13
+ constructor(fromStatus: string, toStatus: string, workflowId?: string, message?: string) {
14
+ const msg = message || `Illegal status transition from "${fromStatus}" to "${toStatus}"${workflowId ? ` in workflow "${workflowId}"` : ''}.`;
15
+ super(msg);
16
+ this.name = 'WorkflowValidationError';
17
+ this.fromStatus = fromStatus;
18
+ this.toStatus = toStatus;
19
+ this.workflowId = workflowId;
20
+ }
21
+ }
22
+
23
+ export const DEFAULT_TASK_TYPES: TaskTypeDefinition[] = [
24
+ { key: 'task', label: 'Task', description: 'A standard work item', icon: 'check-square' },
25
+ { key: 'bug', label: 'Bug', description: 'A problem or defect that needs fixing', icon: 'bug' },
26
+ { key: 'feature', label: 'Feature', description: 'A new capability or enhancement', icon: 'sparkles' },
27
+ { key: 'epic', label: 'Epic', description: 'A large body of work that spans multiple tasks', icon: 'layers' },
28
+ { key: 'subtask', label: 'Subtask', description: 'A piece of work required to complete a parent task', icon: 'corner-down-right' }
29
+ ];
30
+
31
+ export const DEFAULT_SOFTWARE_WORKFLOW: Workflow = {
32
+ id: 'wf_software_default',
33
+ name: 'Software Development Workflow',
34
+ description: 'Standard software development lifecycle with backlog, review, and completion states.',
35
+ statuses: [
36
+ DEFAULT_STATUS_DEFINITIONS.backlog,
37
+ DEFAULT_STATUS_DEFINITIONS.todo,
38
+ DEFAULT_STATUS_DEFINITIONS.in_progress,
39
+ DEFAULT_STATUS_DEFINITIONS.in_review,
40
+ DEFAULT_STATUS_DEFINITIONS.done,
41
+ DEFAULT_STATUS_DEFINITIONS.canceled
42
+ ],
43
+ transitions: [
44
+ { name: 'Start Work', fromStatusKey: 'backlog', toStatusKey: 'todo' },
45
+ { name: 'Start Implementation', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
46
+ { name: 'Complete Task', fromStatusKey: 'todo', toStatusKey: 'done' },
47
+ { name: 'Submit for Review', fromStatusKey: 'in_progress', toStatusKey: 'in_review' },
48
+ { name: 'Complete Implementation', fromStatusKey: 'in_progress', toStatusKey: 'done' },
49
+ { name: 'Approve & Complete', fromStatusKey: 'in_review', toStatusKey: 'done' },
50
+ { name: 'Request Changes', fromStatusKey: 'in_review', toStatusKey: 'in_progress' },
51
+ { name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' },
52
+ { name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
53
+ { name: 'Restore', fromStatusKey: 'canceled', toStatusKey: 'todo' }
54
+ ],
55
+ taskTypes: DEFAULT_TASK_TYPES,
56
+ defaultStatusKey: 'todo',
57
+ isDefault: true,
58
+ createdAt: '2026-01-01T00:00:00.000Z',
59
+ updatedAt: '2026-01-01T00:00:00.000Z'
60
+ };
61
+
62
+ export const DEFAULT_SIMPLE_WORKFLOW: Workflow = {
63
+ id: 'wf_simple_default',
64
+ name: 'Simple Task Workflow',
65
+ description: 'Lightweight workflow with To Do, In Progress, and Done states.',
66
+ statuses: [
67
+ DEFAULT_STATUS_DEFINITIONS.todo,
68
+ DEFAULT_STATUS_DEFINITIONS.in_progress,
69
+ DEFAULT_STATUS_DEFINITIONS.done
70
+ ],
71
+ transitions: [
72
+ { name: 'Start', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
73
+ { name: 'Complete', fromStatusKey: 'todo', toStatusKey: 'done' },
74
+ { name: 'Complete', fromStatusKey: 'in_progress', toStatusKey: 'done' },
75
+ { name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' }
76
+ ],
77
+ taskTypes: DEFAULT_TASK_TYPES.filter((t) => ['task', 'subtask'].includes(t.key)),
78
+ defaultStatusKey: 'todo',
79
+ isDefault: true,
80
+ createdAt: '2026-01-01T00:00:00.000Z',
81
+ updatedAt: '2026-01-01T00:00:00.000Z'
82
+ };
83
+
84
+ export function validateTransition(
85
+ workflow: Workflow | undefined,
86
+ fromStatus: string,
87
+ toStatus: string
88
+ ): boolean {
89
+ if (fromStatus === toStatus) return true;
90
+ if (!workflow || !workflow.transitions || workflow.transitions.length === 0) return true;
91
+
92
+ return workflow.transitions.some(
93
+ (t) =>
94
+ (t.fromStatusKey === '*' || t.fromStatusKey === fromStatus) &&
95
+ (t.toStatusKey === '*' || t.toStatusKey === toStatus)
96
+ );
97
+ }
98
+
99
+ export function getAllowedNextStatuses(
100
+ workflow: Workflow | undefined,
101
+ currentStatus: string
102
+ ): string[] {
103
+ if (!workflow || !workflow.statuses || workflow.statuses.length === 0) {
104
+ return Object.keys(DEFAULT_STATUS_DEFINITIONS);
105
+ }
106
+
107
+ const allStatusKeys = workflow.statuses.map((s) => s.key);
108
+ if (!workflow.transitions || workflow.transitions.length === 0) {
109
+ return allStatusKeys;
110
+ }
111
+
112
+ const matches = workflow.transitions.filter(
113
+ (t) => t.fromStatusKey === '*' || t.fromStatusKey === currentStatus
114
+ );
115
+
116
+ if (matches.some((t) => t.toStatusKey === '*')) {
117
+ return allStatusKeys;
118
+ }
119
+
120
+ const allowed = new Set<string>();
121
+ for (const t of matches) {
122
+ if (allStatusKeys.includes(t.toStatusKey)) {
123
+ allowed.add(t.toStatusKey);
124
+ }
125
+ }
126
+
127
+ return Array.from(allowed);
128
+ }