@taskforcehq/taskforce 0.3.300 → 0.3.302

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 (153) hide show
  1. package/README.md +19 -0
  2. package/dist/Taskforce.module.css +26 -2
  3. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  4. package/dist/TaskforceCore.js +13 -24
  5. package/dist/TaskforceCore.test.js +167 -152
  6. package/dist/components/features/TaskSettings.js +14 -82
  7. package/dist/components/features/TaskSettings.test.js +33 -70
  8. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  9. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  10. package/dist/components/task/TaskActionHeader.js +6 -3
  11. package/dist/components/task/TaskCard.js +6 -3
  12. package/dist/components/task/TaskForm.js +2 -2
  13. package/dist/components/task/TaskForm.test.js +41 -2
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.js +9 -8
  16. package/dist/components/views/StandaloneLayout.js +490 -207
  17. package/dist/components/views/panels/FilterToolbar.d.ts +2 -0
  18. package/dist/components/views/panels/FilterToolbarControls.d.ts +1 -0
  19. package/dist/components/views/panels/FilterToolbarControls.js +1 -1
  20. package/dist/components/views/panels/PlanningDrawer.js +19 -2
  21. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +43 -0
  22. package/dist/components/views/standalone/modals/AccountHubModal.js +22 -0
  23. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  24. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  25. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  26. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  27. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  28. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  29. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  30. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  31. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  32. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  33. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  34. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  35. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  36. package/dist/components/views/standalone/modals/types.js +1 -0
  37. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  38. package/dist/components/views/teamManagementAccess.js +2 -2
  39. package/dist/components/views/teamManagementAccess.test.js +3 -3
  40. package/dist/config/deployment.js +2 -2
  41. package/dist/config/deployment.test.js +5 -3
  42. package/dist/core/AiProfileService.js +7 -2
  43. package/dist/core/AiProfiles.test.js +19 -0
  44. package/dist/core/SyncReconciliationService.d.ts +2 -0
  45. package/dist/core/SyncReconciliationService.js +3 -0
  46. package/dist/core/TaskAuditTimeline.test.js +40 -0
  47. package/dist/core/Taskforce.d.ts +49 -0
  48. package/dist/core/Taskforce.ids.test.js +133 -0
  49. package/dist/core/Taskforce.js +241 -78
  50. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  51. package/dist/core/UserProfileAvatarDrafts.test.js +102 -0
  52. package/dist/core/taskReferencePolicy.d.ts +28 -0
  53. package/dist/core/taskReferencePolicy.js +189 -0
  54. package/dist/core/types.d.ts +2 -0
  55. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  56. package/dist/hooks/useTaskData.js +16 -11
  57. package/dist/hooks/useTaskData.test.js +127 -0
  58. package/dist/hooks/useTaskforce.d.ts +5 -0
  59. package/dist/hooks/useTaskforce.js +63 -15
  60. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  61. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  62. package/dist/mcp/aiProfileBootstrap.js +80 -0
  63. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  64. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  65. package/dist/mcp/clientIntegrations.d.ts +2 -8
  66. package/dist/mcp/clientIntegrations.js +21 -40
  67. package/dist/mcp/clientIntegrations.test.js +28 -2
  68. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  69. package/dist/mcp/clientProfileDefaults.js +37 -0
  70. package/dist/mcp/runtime.d.ts +10 -7
  71. package/dist/mcp/runtime.js +45 -23
  72. package/dist/mcp/runtime.test.js +56 -3
  73. package/dist/migrations/taskSchemaMigrations.js +53 -1
  74. package/dist/qaOpen.test.d.ts +1 -0
  75. package/dist/qaOpen.test.js +56 -0
  76. package/dist/server/index.cookieProxy.test.js +1 -0
  77. package/dist/server/index.d.ts +1 -0
  78. package/dist/server/index.js +15 -1
  79. package/dist/server/index.test.js +14 -1
  80. package/dist/server/routes/admin.js +25 -22
  81. package/dist/server/routes/auth.d.ts +5 -0
  82. package/dist/server/routes/auth.js +350 -16
  83. package/dist/server/routes/billing.js +11 -1
  84. package/dist/server/routes/billing.test.js +32 -2
  85. package/dist/server/routes/documents.js +6 -1
  86. package/dist/server/routes/shared.d.ts +1 -1
  87. package/dist/server/routes/shared.js +11 -3
  88. package/dist/server/routes/sync.integration.test.js +130 -0
  89. package/dist/server/routes/sync.js +226 -1924
  90. package/dist/server/routes/tasks.js +18 -4
  91. package/dist/server/routes.js +26 -19
  92. package/dist/server/routes.test.js +352 -6
  93. package/dist/shared/runtimeContract.d.ts +1 -1
  94. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  95. package/dist/sync/collaborationSyncPayload.js +131 -0
  96. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  97. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  98. package/dist/sync/collaborationSyncState.d.ts +15 -0
  99. package/dist/sync/collaborationSyncState.js +60 -0
  100. package/dist/sync/contentSyncPayload.d.ts +45 -0
  101. package/dist/sync/contentSyncPayload.js +104 -0
  102. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  103. package/dist/sync/contentSyncPayload.test.js +104 -0
  104. package/dist/sync/contentSyncState.d.ts +88 -0
  105. package/dist/sync/contentSyncState.js +743 -0
  106. package/dist/sync/planningSyncPayload.d.ts +9 -0
  107. package/dist/sync/planningSyncPayload.js +82 -0
  108. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  109. package/dist/sync/planningSyncPayload.test.js +68 -0
  110. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  111. package/dist/sync/syncApplyHandlers.js +445 -0
  112. package/dist/sync/taskSyncPayload.d.ts +33 -0
  113. package/dist/sync/taskSyncPayload.js +126 -0
  114. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  115. package/dist/sync/taskSyncPayload.test.js +54 -0
  116. package/dist/sync/workspacePullFeed.d.ts +28 -0
  117. package/dist/sync/workspacePullFeed.js +301 -0
  118. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  119. package/dist/sync/workspacePullFeed.test.js +166 -0
  120. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  121. package/dist/sync/workspaceSyncModel.js +9 -2
  122. package/dist/sync/workspaceSyncModel.test.js +29 -0
  123. package/dist/test/setup.js +1 -1
  124. package/dist/types.d.ts +1 -0
  125. package/dist/ui/assets/{AgentsModule-TVmCgizS.js → AgentsModule-JG5jc3he.js} +1 -1
  126. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-CAyhnPeR.js → AnnotatedAttachmentWorkspace-Dvi02Y7q.js} +1 -1
  127. package/dist/ui/assets/{DocumentWorkspace-BOCw5e4D.js → DocumentWorkspace-iys3zfca.js} +2 -2
  128. package/dist/ui/assets/{InitiativesModule-CN0r02Oi.js → InitiativesModule-DJvLJI3A.js} +1 -1
  129. package/dist/ui/assets/PlansPage-BMPlpFpw.js +1 -0
  130. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +8 -0
  131. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  132. package/dist/ui/assets/{WorkflowsModule-CY9R2zPB.js → WorkflowsModule-BguHeQaV.js} +1 -1
  133. package/dist/ui/assets/index-MXeWoebC.css +1 -0
  134. package/dist/ui/assets/index-YJ4k5QNa.js +6 -0
  135. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-C8EED3Oh.js} +1 -1
  136. package/dist/ui/index.html +3 -3
  137. package/dist/utils/billingStatusCache.d.ts +21 -0
  138. package/dist/utils/billingStatusCache.js +70 -0
  139. package/dist/utils/taskActivity.js +17 -4
  140. package/dist/utils/taskActivity.test.js +27 -0
  141. package/dist/utils/taskNormalization.js +10 -2
  142. package/dist/utils/taskReferences.d.ts +11 -1
  143. package/dist/utils/taskReferences.js +33 -1
  144. package/package.json +4 -1
  145. package/scripts/deploy-prod.sh +41 -0
  146. package/scripts/deploy-staging.sh +23 -0
  147. package/scripts/qa-open-lib.mjs +175 -0
  148. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  149. package/scripts/qa-open.mjs +352 -0
  150. package/dist/ui/assets/PlansPage-FcQSNLZd.js +0 -1
  151. package/dist/ui/assets/TaskSettings-Cw5zYD1L.js +0 -8
  152. package/dist/ui/assets/index-Bzqy5QGm.js +0 -6
  153. package/dist/ui/assets/index-DxRiI3tY.css +0 -1
@@ -184,10 +184,15 @@ export function resolveAiProfileService(deps, input) {
184
184
  if (!existingRow) {
185
185
  throw deps.createRuleError('AI profile token is invalid for this workspace', 404, 'AI_PROFILE_NOT_FOUND');
186
186
  }
187
+ const existingProfile = deps.aiProfileRowToRecord(existingRow);
187
188
  const nextName = name;
188
189
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
189
- const nextIcon = deps.normalizeAiProfileIcon(input.icon);
190
- const nextColor = deps.normalizeAiProfileColor(input.color);
190
+ const nextIcon = input.icon === undefined
191
+ ? existingProfile.icon
192
+ : deps.normalizeAiProfileIcon(input.icon);
193
+ const nextColor = input.color === undefined
194
+ ? existingProfile.color
195
+ : deps.normalizeAiProfileColor(input.color);
191
196
  const nextDescription = normalizeOptionalProfileField(input.description);
192
197
  const nextRole = normalizeOptionalProfileField(input.role);
193
198
  const nextProvider = normalizeOptionalProfileField(input.provider);
@@ -130,6 +130,25 @@ describe('TaskforceCore ai profiles', () => {
130
130
  expect(second.profile.provider).toBe('Anthropic');
131
131
  expect(second.profile.model).toBe('claude-sonnet');
132
132
  });
133
+ it('preserves ai profile icon and color when re-resolved without those fields', () => {
134
+ const projectRoot = createProjectRoot();
135
+ createdRoots.push(projectRoot);
136
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
137
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
138
+ const first = core.resolveAiProfile({
139
+ workspaceId: 'workspace-1',
140
+ name: 'Codex',
141
+ icon: 'Sparkles',
142
+ color: '#0ea5e9'
143
+ });
144
+ const second = core.resolveAiProfile({
145
+ workspaceId: 'workspace-1',
146
+ name: 'Codex 2',
147
+ profileToken: first.profile.profileToken
148
+ });
149
+ expect(second.profile.icon).toBe('Sparkles');
150
+ expect(second.profile.color).toBe('#0ea5e9');
151
+ });
133
152
  it('clears ai profile surfaceType when re-resolved with null or an empty string', () => {
134
153
  const projectRoot = createProjectRoot();
135
154
  createdRoots.push(projectRoot);
@@ -21,6 +21,7 @@ export interface SyncReconciliationServiceDeps {
21
21
  persistBackup?: boolean;
22
22
  workspaceId?: string;
23
23
  allowClientProvidedId?: boolean;
24
+ preferIncomingReferenceNumberOnConflict?: boolean;
24
25
  skipRecompute?: boolean;
25
26
  syncUpdatedAt?: string;
26
27
  actorRef?: string;
@@ -28,6 +29,7 @@ export interface SyncReconciliationServiceDeps {
28
29
  }) => TaskItem;
29
30
  updateTask: (id: string, updates: Partial<TaskItem>, workspaceId?: string, options?: {
30
31
  persistBackup?: boolean;
32
+ preferIncomingReferenceNumberOnConflict?: boolean;
31
33
  skipRecompute?: boolean;
32
34
  syncUpdatedAt?: string;
33
35
  actorRef?: string;
@@ -198,6 +198,7 @@ export function applyWorkspaceSyncSnapshotService(deps, input, workspaceId) {
198
198
  createdBy: sourceTask.createdBy === undefined ? existing?.createdBy : sourceTask.createdBy,
199
199
  assignee: sourceTask.assignee === undefined ? existing?.assignee : sourceTask.assignee,
200
200
  referenceNumber: sourceTask.referenceNumber === undefined ? existing?.referenceNumber : sourceTask.referenceNumber,
201
+ localReferenceNumber: sourceTask.referenceNumber === undefined ? existing?.localReferenceNumber : null,
201
202
  scheduledDate: sourceTask.scheduledDate === undefined ? existing?.scheduledDate : sourceTask.scheduledDate,
202
203
  dueDate: sourceTask.dueDate === undefined ? existing?.dueDate : sourceTask.dueDate,
203
204
  scheduledWeekKey: sourceTask.scheduledWeekKey === undefined ? existing?.scheduledWeekKey : sourceTask.scheduledWeekKey,
@@ -228,6 +229,7 @@ export function applyWorkspaceSyncSnapshotService(deps, input, workspaceId) {
228
229
  persistBackup: false,
229
230
  workspaceId: normalizedWorkspaceId,
230
231
  allowClientProvidedId: true,
232
+ preferIncomingReferenceNumberOnConflict: true,
231
233
  skipRecompute: true,
232
234
  syncUpdatedAt: incomingUpdatedAt,
233
235
  skipTaskEvents: true
@@ -244,6 +246,7 @@ export function applyWorkspaceSyncSnapshotService(deps, input, workspaceId) {
244
246
  try {
245
247
  updatedTask = deps.updateTask(taskId, taskPatch, normalizedWorkspaceId, {
246
248
  persistBackup: false,
249
+ preferIncomingReferenceNumberOnConflict: true,
247
250
  skipRecompute: true,
248
251
  syncUpdatedAt: incomingUpdatedAt,
249
252
  skipTaskEvents: true
@@ -92,6 +92,46 @@ describe('TaskforceCore task audit timeline', () => {
92
92
  cleanup();
93
93
  }
94
94
  });
95
+ it('suppresses task events for autosave-style updates when skipTaskEvents is true', () => {
96
+ const { core, cleanup } = makeCore();
97
+ try {
98
+ const task = core.createTask({ title: 'Autosave task', createdBy: 'user' }, { workspaceId: 'workspace-a', actorRef: 'user' });
99
+ core.updateTask(task.id, { description: 'Draft body' }, 'workspace-a', {
100
+ actorRef: 'user',
101
+ skipTaskEvents: true
102
+ });
103
+ const events = core.listTaskEvents(task.id, 'workspace-a');
104
+ const hydrated = core.getTask(task.id, 'workspace-a');
105
+ expect(events.map((event) => event.action)).toEqual(['task-created']);
106
+ expect(hydrated?.description).toBe('Draft body');
107
+ expect(hydrated?.activity?.some((item) => item.type === 'event' && item.event.action === 'task-updated')).toBe(false);
108
+ }
109
+ finally {
110
+ cleanup();
111
+ }
112
+ });
113
+ it('captures assignment changes in the core task update event details', () => {
114
+ const { core, cleanup } = makeCore();
115
+ try {
116
+ const task = core.createTask({ title: 'Assignment audit task', createdBy: 'user' }, { workspaceId: 'workspace-a', actorRef: 'user' });
117
+ core.updateTask(task.id, { assignee: 'member-1' }, 'workspace-a', { actorRef: 'user' });
118
+ const assignmentEvent = core.listTaskEvents(task.id, 'workspace-a').find((event) => event.action === 'task-updated' && event.details?.changes?.assignee);
119
+ expect(assignmentEvent).toEqual(expect.objectContaining({
120
+ action: 'task-updated',
121
+ details: {
122
+ changes: {
123
+ assignee: {
124
+ from: 'unassigned',
125
+ to: 'member-1',
126
+ },
127
+ },
128
+ },
129
+ }));
130
+ }
131
+ finally {
132
+ cleanup();
133
+ }
134
+ });
95
135
  it('repairs legacy comments tables so raw submitted authors can be stored', () => {
96
136
  const { core, cleanup } = makeCore();
97
137
  try {
@@ -86,6 +86,7 @@ export interface TaskActor {
86
86
  export interface TaskItem {
87
87
  id: string;
88
88
  referenceNumber?: number | null;
89
+ localReferenceNumber?: number | null;
89
90
  referenceLabel?: string;
90
91
  title: string;
91
92
  description: string | null;
@@ -367,9 +368,24 @@ interface UserIdentityRecord {
367
368
  userId: string;
368
369
  email: string;
369
370
  displayName?: string | null;
371
+ avatarUrl?: string | null;
370
372
  systemRole?: SystemRole;
371
373
  betaAccess?: boolean;
372
374
  }
375
+ interface UserAvatarUploadDraftRecord {
376
+ draftId: string;
377
+ userId: string;
378
+ storageKey: string;
379
+ mimeType: string;
380
+ originalFilename: string;
381
+ sizeBytes: number;
382
+ storageProvider: 'local' | 'r2' | 'external';
383
+ finalizedAt?: string | null;
384
+ consumedAt?: string | null;
385
+ discardedAt?: string | null;
386
+ createdAt: string;
387
+ updatedAt: string;
388
+ }
373
389
  interface WorkspaceUserRecord {
374
390
  userId: string;
375
391
  email: string;
@@ -476,6 +492,8 @@ interface CreateTaskOptions {
476
492
  persistBackup?: boolean;
477
493
  workspaceId?: string;
478
494
  allowClientProvidedId?: boolean;
495
+ provisionalLocalReference?: boolean;
496
+ preferIncomingReferenceNumberOnConflict?: boolean;
479
497
  skipRecompute?: boolean;
480
498
  syncUpdatedAt?: string;
481
499
  actorRef?: string;
@@ -971,7 +989,9 @@ export declare class TaskforceCore implements TaskforceSyncCapable {
971
989
  private rowToWorkstream;
972
990
  private rowToChecklistItem;
973
991
  private sanitizeTaskReferenceNumber;
992
+ private getTaskReferencePolicyDeps;
974
993
  private getTaskByReferenceNumber;
994
+ private getTaskByLocalReferenceNumber;
975
995
  resolveTaskIdentifier(value: string, workspaceId?: string): TaskItem | null;
976
996
  getInitiative(id: string, workspaceId?: string): InitiativeItem | null;
977
997
  listInitiatives(workspaceId?: string): InitiativeItem[];
@@ -989,6 +1009,15 @@ export declare class TaskforceCore implements TaskforceSyncCapable {
989
1009
  upsertWorkstreamForSync(workstream: WorkstreamItem, workspaceId?: string | null): WorkstreamItem;
990
1010
  listChecklistItems(taskId: string, workspaceId?: string): ChecklistItem[];
991
1011
  replaceChecklistItems(taskId: string, items: Array<Partial<ChecklistItem>>, workspaceId?: string): ChecklistItem[];
1012
+ private getTaskReferenceSequenceValue;
1013
+ private getLocalTaskReferenceSequenceValue;
1014
+ private getMaxTaskReferenceNumber;
1015
+ private getMaxLocalTaskReferenceNumber;
1016
+ private persistTaskReferenceSequence;
1017
+ private persistLocalTaskReferenceSequence;
1018
+ private allocateNextTaskReferenceNumber;
1019
+ private allocateNextLocalTaskReferenceNumber;
1020
+ private relocateTaskReferenceConflict;
992
1021
  private reserveTaskReferenceNumber;
993
1022
  private reserveGenericReferenceNumber;
994
1023
  private reserveInitiativeReferenceNumber;
@@ -1081,7 +1110,25 @@ export declare class TaskforceCore implements TaskforceSyncCapable {
1081
1110
  }): UserAccessRecord;
1082
1111
  resolveUserAccess(userId: string, workspaceId: string, fallbackRole?: AccessRole): UserAccessRecord | null;
1083
1112
  resolveUserIdentity(userId: string): UserIdentityRecord | null;
1113
+ updateUserProfile(userId: string, input: {
1114
+ displayName: string;
1115
+ avatarUrl?: string | null;
1116
+ }): UserIdentityRecord | null;
1084
1117
  updateUserDisplayName(userId: string, displayName: string): UserIdentityRecord | null;
1118
+ createUserAvatarUploadDraft(input: {
1119
+ userId: string;
1120
+ draftId: string;
1121
+ storageKey: string;
1122
+ mimeType: string;
1123
+ originalFilename: string;
1124
+ sizeBytes: number;
1125
+ storageProvider?: 'local' | 'r2' | 'external';
1126
+ }): UserAvatarUploadDraftRecord;
1127
+ getUserAvatarUploadDraft(userId: string, draftId: string): UserAvatarUploadDraftRecord | null;
1128
+ markUserAvatarUploadDraftFinalized(userId: string, draftId: string): UserAvatarUploadDraftRecord | null;
1129
+ markUserAvatarUploadDraftConsumed(userId: string, draftId: string): UserAvatarUploadDraftRecord | null;
1130
+ discardUserAvatarUploadDraft(userId: string, draftId: string): UserAvatarUploadDraftRecord | null;
1131
+ cleanupStaleUserAvatarUploadDrafts(userId: string, olderThanIso: string): UserAvatarUploadDraftRecord[];
1085
1132
  getSystemRole(userId: string): SystemRole;
1086
1133
  getUserBetaAccess(userId: string): boolean;
1087
1134
  countSystemAdmins(): number;
@@ -1602,9 +1649,11 @@ export declare class TaskforceCore implements TaskforceSyncCapable {
1602
1649
  validateOnly?: boolean;
1603
1650
  workspaceId?: string;
1604
1651
  allowClientProvidedId?: boolean;
1652
+ provisionalLocalReference?: boolean;
1605
1653
  }): BulkCreateTaskResult;
1606
1654
  updateTask(id: string, updates: Partial<TaskItem>, workspaceId?: string, options?: {
1607
1655
  persistBackup?: boolean;
1656
+ preferIncomingReferenceNumberOnConflict?: boolean;
1608
1657
  skipRecompute?: boolean;
1609
1658
  syncUpdatedAt?: string;
1610
1659
  actorRef?: string;
@@ -80,6 +80,139 @@ describe('TaskforceCore id policy', () => {
80
80
  cleanup();
81
81
  }
82
82
  });
83
+ it('assigns provisional local task references without consuming canonical task references', () => {
84
+ const { core, cleanup } = makeCore();
85
+ try {
86
+ const provisionalFirst = core.createTask({ title: 'Offline first' }, {
87
+ workspaceId: 'workspace-a',
88
+ provisionalLocalReference: true
89
+ });
90
+ const provisionalSecond = core.createTask({ title: 'Offline second' }, {
91
+ workspaceId: 'workspace-a',
92
+ provisionalLocalReference: true
93
+ });
94
+ const canonical = core.createTask({ title: 'Cloud-backed task' }, { workspaceId: 'workspace-a' });
95
+ expect(provisionalFirst.referenceNumber).toBeNull();
96
+ expect(provisionalFirst.localReferenceNumber).toBe(1);
97
+ expect(provisionalFirst.referenceLabel).toBe('LT-1');
98
+ expect(provisionalSecond.referenceNumber).toBeNull();
99
+ expect(provisionalSecond.localReferenceNumber).toBe(2);
100
+ expect(provisionalSecond.referenceLabel).toBe('LT-2');
101
+ expect(canonical.referenceNumber).toBe(1);
102
+ expect(canonical.localReferenceNumber).toBeNull();
103
+ expect(canonical.referenceLabel).toBe('T-1');
104
+ }
105
+ finally {
106
+ cleanup();
107
+ }
108
+ });
109
+ it('replaces a provisional local reference with a canonical cloud reference during sync apply', () => {
110
+ const { core, cleanup } = makeCore();
111
+ try {
112
+ const provisional = core.createTask({ title: 'Offline draft' }, {
113
+ workspaceId: 'workspace-a',
114
+ provisionalLocalReference: true
115
+ });
116
+ expect(provisional.referenceLabel).toBe('LT-1');
117
+ const syncResult = core.applyWorkspaceSyncSnapshot({
118
+ tasks: [
119
+ {
120
+ id: provisional.id,
121
+ title: 'Offline draft',
122
+ status: 'task',
123
+ referenceNumber: 7,
124
+ updatedAt: '2099-04-10T17:00:00.000Z'
125
+ }
126
+ ]
127
+ }, 'workspace-a');
128
+ expect(syncResult.updated).toBe(1);
129
+ expect(core.getTask(provisional.id, 'workspace-a')).toEqual(expect.objectContaining({
130
+ referenceNumber: 7,
131
+ localReferenceNumber: null,
132
+ referenceLabel: 'T-7'
133
+ }));
134
+ }
135
+ finally {
136
+ cleanup();
137
+ }
138
+ });
139
+ it('preserves provisional local references across core reloads', () => {
140
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-id-reload-test-'));
141
+ const storagePath = path.join(projectRoot, '.taskforce');
142
+ const firstCore = new TaskforceCore({ projectRoot, storagePath });
143
+ try {
144
+ const provisional = firstCore.createTask({ title: 'Offline draft' }, {
145
+ workspaceId: 'workspace-a',
146
+ provisionalLocalReference: true
147
+ });
148
+ expect(provisional.referenceNumber).toBeNull();
149
+ expect(provisional.localReferenceNumber).toBe(1);
150
+ firstCore.close();
151
+ const reloadedCore = new TaskforceCore({ projectRoot, storagePath });
152
+ try {
153
+ const reloadedTask = reloadedCore.getTask(provisional.id, 'workspace-a');
154
+ expect(reloadedTask).toEqual(expect.objectContaining({
155
+ referenceNumber: null,
156
+ localReferenceNumber: 1,
157
+ referenceLabel: 'LT-1'
158
+ }));
159
+ }
160
+ finally {
161
+ reloadedCore.close();
162
+ }
163
+ }
164
+ finally {
165
+ fs.rmSync(projectRoot, { recursive: true, force: true });
166
+ }
167
+ });
168
+ it('lets the incoming synced task keep its preferred reference and renumbers the local incumbent', () => {
169
+ const { core, cleanup } = makeCore();
170
+ try {
171
+ const localTask = core.createTask({ title: 'Local task' }, { workspaceId: 'workspace-a' });
172
+ const syncResult = core.applyWorkspaceSyncSnapshot({
173
+ tasks: [
174
+ {
175
+ id: 'task-sync-imported-collision',
176
+ title: 'Remote task',
177
+ status: 'task',
178
+ referenceNumber: localTask.referenceNumber,
179
+ createdAt: '2026-04-10T15:00:00.000Z',
180
+ updatedAt: '2026-04-10T15:00:00.000Z',
181
+ }
182
+ ]
183
+ }, 'workspace-a');
184
+ expect(syncResult.created).toBe(1);
185
+ expect(core.getTask(localTask.id, 'workspace-a')?.referenceLabel).toBe('T-2');
186
+ expect(core.getTask('task-sync-imported-collision', 'workspace-a')?.referenceLabel).toBe('T-1');
187
+ }
188
+ finally {
189
+ cleanup();
190
+ }
191
+ });
192
+ it('lets incoming synced updates claim the preferred reference and renumbers the displaced task', () => {
193
+ const { core, cleanup } = makeCore();
194
+ try {
195
+ const first = core.createTask({ title: 'First' }, { workspaceId: 'workspace-a' });
196
+ const second = core.createTask({ title: 'Second' }, { workspaceId: 'workspace-a' });
197
+ const syncResult = core.applyWorkspaceSyncSnapshot({
198
+ tasks: [
199
+ {
200
+ id: second.id,
201
+ title: 'Second',
202
+ status: 'task',
203
+ referenceNumber: first.referenceNumber,
204
+ updatedAt: '2099-04-11T16:00:00.000Z',
205
+ }
206
+ ]
207
+ }, 'workspace-a');
208
+ expect(syncResult.updated).toBe(1);
209
+ expect(core.getTask(first.id, 'workspace-a')?.referenceLabel).toBe('T-3');
210
+ expect(core.getTask(second.id, 'workspace-a')?.referenceLabel).toBe('T-1');
211
+ }
212
+ finally {
213
+ cleanup();
214
+ }
215
+ });
83
216
  it('resolves task identifiers by task reference or internal id', () => {
84
217
  const { core, cleanup } = makeCore();
85
218
  try {