@taskforcehq/taskforce 0.3.305 → 0.3.307

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 (440) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +603 -107
  3. package/dist/TaskforceCore.js +216 -43
  4. package/dist/TaskforceCore.test.js +611 -28
  5. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  8. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  10. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  11. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  12. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  13. package/dist/compat/workspaceSyncCompat.js +46 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  16. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  17. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  18. package/dist/components/context/ContextAttachmentManager.js +561 -0
  19. package/dist/components/features/AgentsModule.d.ts +14 -1
  20. package/dist/components/features/AgentsModule.js +366 -18
  21. package/dist/components/features/AgentsModule.test.js +507 -8
  22. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  23. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  24. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  25. package/dist/components/features/DocumentIndex.d.ts +2 -2
  26. package/dist/components/features/DocumentIndex.js +3 -4
  27. package/dist/components/features/DocumentViewer.d.ts +7 -1
  28. package/dist/components/features/DocumentViewer.js +62 -4
  29. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  30. package/dist/components/features/DocumentWorkspace.js +15 -4
  31. package/dist/components/features/DocumentWorkspace.test.js +51 -1
  32. package/dist/components/features/TaskSettings.d.ts +2 -0
  33. package/dist/components/features/TaskSettings.js +83 -78
  34. package/dist/components/features/TaskSettings.test.js +171 -82
  35. package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
  36. package/dist/components/task/TaskActionHeader.js +11 -5
  37. package/dist/components/task/TaskActionHeader.test.js +10 -0
  38. package/dist/components/task/TaskCard.js +24 -29
  39. package/dist/components/task/TaskCard.test.js +38 -3
  40. package/dist/components/task/TaskContextUpload.js +4 -526
  41. package/dist/components/task/TaskContextUpload.test.js +3 -3
  42. package/dist/components/task/TaskForm.js +61 -116
  43. package/dist/components/task/TaskForm.test.js +110 -8
  44. package/dist/components/task/TaskKanban.d.ts +2 -1
  45. package/dist/components/task/TaskKanban.js +94 -21
  46. package/dist/components/task/TaskKanban.test.js +36 -0
  47. package/dist/components/task/TaskList.js +5 -1
  48. package/dist/components/task/TaskList.test.js +4 -0
  49. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  50. package/dist/components/ui/EditableAvatarButton.js +18 -0
  51. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  52. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  53. package/dist/components/views/AppShellHeader.d.ts +3 -1
  54. package/dist/components/views/AppShellHeader.js +27 -11
  55. package/dist/components/views/AppShellHeader.test.js +18 -1
  56. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  57. package/dist/components/views/PlanComparisonPage.js +78 -14
  58. package/dist/components/views/PlanComparisonPage.test.js +257 -0
  59. package/dist/components/views/PlansPage.js +127 -36
  60. package/dist/components/views/PlansPage.test.js +294 -12
  61. package/dist/components/views/StandaloneLayout.js +529 -592
  62. package/dist/components/views/WidgetView.js +10 -8
  63. package/dist/components/views/panels/PlanningDrawer.d.ts +25 -5
  64. package/dist/components/views/panels/PlanningDrawer.js +270 -55
  65. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  66. package/dist/components/views/panels/PlanningDrawer.test.js +529 -0
  67. package/dist/components/views/planningScope.d.ts +20 -0
  68. package/dist/components/views/planningScope.js +25 -0
  69. package/dist/components/views/planningScope.test.d.ts +1 -0
  70. package/dist/components/views/planningScope.test.js +60 -0
  71. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
  72. package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
  73. package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
  74. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  75. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  76. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  77. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  78. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  79. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  80. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  81. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  82. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  83. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  84. package/dist/config/envSchema.js +1 -0
  85. package/dist/core/AiProfileService.d.ts +46 -2
  86. package/dist/core/AiProfileService.js +1073 -82
  87. package/dist/core/AiProfileService.test.js +19 -1
  88. package/dist/core/AiProfiles.test.js +1249 -44
  89. package/dist/core/AttachmentLinkService.d.ts +3 -0
  90. package/dist/core/AttachmentLinkService.js +110 -23
  91. package/dist/core/AuthIdentityService.d.ts +107 -0
  92. package/dist/core/AuthIdentityService.js +284 -0
  93. package/dist/core/AuthTokenService.d.ts +4 -0
  94. package/dist/core/AuthTokenService.js +34 -6
  95. package/dist/core/AuthTokenService.test.js +3 -3
  96. package/dist/core/EntitlementsPolicy.test.js +2 -2
  97. package/dist/core/GlobalSettingsService.js +127 -6
  98. package/dist/core/McpTokenService.d.ts +12 -0
  99. package/dist/core/McpTokenService.js +44 -1
  100. package/dist/core/PlanEntitlementService.d.ts +23 -2
  101. package/dist/core/PlanEntitlementService.js +343 -57
  102. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  103. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  104. package/dist/core/PlanVersionPolicy.test.js +35 -2
  105. package/dist/core/PlanningEntities.test.js +418 -4
  106. package/dist/core/SignupMonetizationSettings.test.js +199 -27
  107. package/dist/core/SyncReconciliationService.js +2 -65
  108. package/dist/core/SystemAdmin.test.js +42 -42
  109. package/dist/core/TaskActivityService.d.ts +28 -1
  110. package/dist/core/TaskActivityService.js +227 -46
  111. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  112. package/dist/core/TaskActivitySync.test.js +90 -0
  113. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  114. package/dist/core/TaskAuditTimeline.test.js +96 -0
  115. package/dist/core/Taskforce.d.ts +415 -40
  116. package/dist/core/Taskforce.ids.test.js +58 -0
  117. package/dist/core/Taskforce.js +1543 -266
  118. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  119. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  120. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  121. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  122. package/dist/core/WorkspaceLifecycleService.js +0 -5
  123. package/dist/core/WorkspacePermissions.test.js +25 -1
  124. package/dist/core/WorkspacePlanMode.test.js +117 -16
  125. package/dist/core/shared.d.ts +1 -1
  126. package/dist/core/shared.js +5 -1
  127. package/dist/core/types.d.ts +75 -2
  128. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +28 -2
  129. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  130. package/dist/hooks/bootstrapTimeouts.js +8 -0
  131. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  132. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  133. package/dist/hooks/sync/bootstrap.js +2 -0
  134. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  135. package/dist/hooks/sync/controlPlane.js +78 -0
  136. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  137. package/dist/hooks/sync/controlPlane.test.js +121 -0
  138. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  139. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  140. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  141. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  142. package/dist/hooks/sync/orchestratorShared.d.ts +13 -4
  143. package/dist/hooks/sync/orchestratorShared.js +129 -48
  144. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  145. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  146. package/dist/hooks/sync/pullLifecycle.js +24 -0
  147. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  148. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  149. package/dist/hooks/sync/recovery.d.ts +16 -2
  150. package/dist/hooks/sync/recovery.js +179 -53
  151. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  152. package/dist/hooks/sync/recovery.test.js +292 -0
  153. package/dist/hooks/sync/transfers.d.ts +16 -3
  154. package/dist/hooks/sync/transfers.js +210 -66
  155. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  156. package/dist/hooks/sync/transfers.test.js +928 -0
  157. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  158. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  159. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  160. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  161. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  162. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  163. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  164. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  165. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  166. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  167. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  168. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  169. package/dist/hooks/useFilterSort.js +13 -13
  170. package/dist/hooks/useFilterSort.test.js +87 -1
  171. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  172. package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
  173. package/dist/hooks/useSyncOrchestrator.js +409 -183
  174. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
  175. package/dist/hooks/useTaskData.js +8 -3
  176. package/dist/hooks/useTaskData.test.js +45 -0
  177. package/dist/hooks/useTaskforce.d.ts +145 -52
  178. package/dist/hooks/useTaskforce.js +592 -253
  179. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  180. package/dist/hooks/useTaskforce.runtime-routing.test.js +246 -0
  181. package/dist/hooks/useTaskforce.sync-behavior.test.js +2468 -201
  182. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  183. package/dist/hooks/useWorkspaceSyncController.js +13 -2
  184. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  185. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  186. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +328 -40
  187. package/dist/localization/locales/en-US.d.ts +0 -1
  188. package/dist/localization/locales/en-US.js +0 -1
  189. package/dist/localization/locales/es-419.js +0 -1
  190. package/dist/localization/locales/pt-BR.js +0 -1
  191. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  192. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  193. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  194. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  195. package/dist/mcp/clientIntegrations.d.ts +1 -0
  196. package/dist/mcp/clientIntegrations.js +25 -0
  197. package/dist/mcp/clientIntegrations.test.js +14 -3
  198. package/dist/mcp/clientProfileDefaults.js +3 -3
  199. package/dist/mcp/collaborationRegistrar.js +7 -7
  200. package/dist/mcp/documentAssetRegistrar.js +116 -10
  201. package/dist/mcp/runtime.d.ts +3 -0
  202. package/dist/mcp/runtime.js +1099 -185
  203. package/dist/mcp/runtime.test.js +1491 -140
  204. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  205. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  206. package/dist/mcp/toolRegistry.d.ts +56 -0
  207. package/dist/mcp/toolRegistry.js +8 -0
  208. package/dist/mcp/toolRegistry.test.js +4 -0
  209. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  210. package/dist/migrations/taskSchemaMigrations.js +192 -61
  211. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  212. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  213. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  214. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  215. package/dist/resources/templates/workflows/plan.yaml +1 -1
  216. package/dist/resources/templates/workflows/review.yaml +2 -2
  217. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  218. package/dist/server/aiProfileAvatarAssets.js +319 -0
  219. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  220. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  221. package/dist/server/annotatedAttachmentsRoutes.test.js +69 -10
  222. package/dist/server/auth/providers/apple.d.ts +33 -0
  223. package/dist/server/auth/providers/apple.js +82 -0
  224. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  225. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  226. package/dist/server/auth/providers/github.d.ts +26 -0
  227. package/dist/server/auth/providers/github.js +86 -0
  228. package/dist/server/auth/providers/google.d.ts +21 -0
  229. package/dist/server/auth/providers/google.js +56 -0
  230. package/dist/server/auth/providers/types.d.ts +21 -0
  231. package/dist/server/auth/providers/types.js +1 -0
  232. package/dist/server/auth.d.ts +2 -0
  233. package/dist/server/auth.js +6 -3
  234. package/dist/server/documentReviewRoutes.test.js +36 -3
  235. package/dist/server/index.cookieProxy.test.js +1 -0
  236. package/dist/server/index.d.ts +12 -0
  237. package/dist/server/index.js +124 -9
  238. package/dist/server/index.rateLimit.test.js +3 -0
  239. package/dist/server/index.test.js +111 -3
  240. package/dist/server/routes/admin.d.ts +3 -0
  241. package/dist/server/routes/admin.js +459 -28
  242. package/dist/server/routes/annotatedAttachments.js +51 -0
  243. package/dist/server/routes/auth.d.ts +26 -2
  244. package/dist/server/routes/auth.js +1170 -71
  245. package/dist/server/routes/authSupport.d.ts +5 -1
  246. package/dist/server/routes/authSupport.js +13 -2
  247. package/dist/server/routes/billing.js +372 -35
  248. package/dist/server/routes/billing.test.js +595 -12
  249. package/dist/server/routes/documents.d.ts +1 -0
  250. package/dist/server/routes/documents.js +182 -70
  251. package/dist/server/routes/shared.d.ts +28 -2
  252. package/dist/server/routes/shared.js +69 -14
  253. package/dist/server/routes/sync.integration.test.js +658 -39
  254. package/dist/server/routes/sync.js +74 -608
  255. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  256. package/dist/server/routes/syncAuxRoutes.js +91 -0
  257. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  258. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  259. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  260. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  261. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  262. package/dist/server/routes/syncPushRoutes.js +212 -0
  263. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  264. package/dist/server/routes/syncRouteGuards.js +67 -0
  265. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  266. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  267. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  268. package/dist/server/routes/syncRouteTypes.js +1 -0
  269. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  270. package/dist/server/routes/syncSnapshotRoutes.js +182 -0
  271. package/dist/server/routes/tasks.js +55 -10
  272. package/dist/server/routes/workspaces.js +36 -5
  273. package/dist/server/routes.js +147 -11
  274. package/dist/server/routes.test.js +2704 -183
  275. package/dist/services/workflowExportSnapshots.test.js +2 -0
  276. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  277. package/dist/shared/aiProfileSeatScope.js +21 -0
  278. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  279. package/dist/shared/aiProfileSurfaceType.js +10 -8
  280. package/dist/shared/passwordPolicy.d.ts +13 -0
  281. package/dist/shared/passwordPolicy.js +84 -0
  282. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  283. package/dist/shared/passwordPolicy.test.js +34 -0
  284. package/dist/storage/objectStorage.d.ts +1 -0
  285. package/dist/storage/objectStorage.js +2 -1
  286. package/dist/storage/objectStorageClient.d.ts +1 -0
  287. package/dist/storage/objectStorageClient.js +41 -8
  288. package/dist/storage/objectStorageClient.test.js +26 -1
  289. package/dist/storage/objectStorageConfig.js +3 -1
  290. package/dist/storage/objectStorageConfig.test.js +13 -1
  291. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  292. package/dist/storage/workspaceAssetStore.js +132 -44
  293. package/dist/storage/workspaceAssetStore.test.js +93 -6
  294. package/dist/styles/fonts.css +70 -0
  295. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  296. package/dist/sync/collaborationSyncPayload.js +19 -11
  297. package/dist/sync/collaborationSyncPayload.test.js +14 -1
  298. package/dist/sync/collaborationSyncState.d.ts +3 -3
  299. package/dist/sync/collaborationSyncState.js +19 -12
  300. package/dist/sync/contentSyncPayload.d.ts +5 -1
  301. package/dist/sync/contentSyncPayload.js +9 -0
  302. package/dist/sync/contentSyncPayload.test.js +39 -0
  303. package/dist/sync/contentSyncState.d.ts +9 -9
  304. package/dist/sync/contentSyncState.js +248 -107
  305. package/dist/sync/planningSyncPayload.js +5 -0
  306. package/dist/sync/planningSyncPayload.test.js +24 -0
  307. package/dist/sync/syncApplyHandlers.d.ts +11 -4
  308. package/dist/sync/syncApplyHandlers.js +115 -42
  309. package/dist/sync/syncApplyHandlers.test.js +251 -4
  310. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  311. package/dist/sync/syncFieldSemantics.js +55 -0
  312. package/dist/sync/syncResourceAdapters.d.ts +46 -0
  313. package/dist/sync/syncResourceAdapters.js +79 -0
  314. package/dist/sync/syncService.d.ts +6 -0
  315. package/dist/sync/syncService.js +65 -1
  316. package/dist/sync/syncService.test.js +96 -3
  317. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  318. package/dist/sync/taskSyncChangeKey.js +143 -0
  319. package/dist/sync/taskSyncPayload.js +3 -8
  320. package/dist/sync/workspacePullFeed.d.ts +4 -0
  321. package/dist/sync/workspacePullFeed.js +132 -72
  322. package/dist/sync/workspacePullFeed.test.js +92 -0
  323. package/dist/sync/workspaceRepair.js +5 -2
  324. package/dist/sync/workspaceSyncModel.d.ts +73 -6
  325. package/dist/sync/workspaceSyncModel.js +342 -73
  326. package/dist/sync/workspaceSyncModel.test.js +559 -14
  327. package/dist/sync/workspaceSyncState.d.ts +17 -0
  328. package/dist/sync/workspaceSyncState.js +2 -1
  329. package/dist/sync/workspaceSyncSurface.js +3 -8
  330. package/dist/types.d.ts +50 -2
  331. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  332. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  333. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  334. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  335. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  336. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  337. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  338. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  339. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  340. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  341. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  342. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  343. package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-D4XseTwW.js} +1 -1
  344. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  345. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  346. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  347. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  348. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  349. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  350. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  351. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  352. package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  353. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  354. package/dist/ui/assets/index-607WPo_X.js +4 -0
  355. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  356. package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-B32hGuF2.js} +1 -1
  357. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  358. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  359. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  360. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  361. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  362. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  363. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  364. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  365. package/dist/ui/fonts/Noir_medium.otf +0 -0
  366. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  367. package/dist/ui/fonts/Noir_regular.otf +0 -0
  368. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  369. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  370. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  371. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  372. package/dist/ui/index.html +3 -3
  373. package/dist/ui/product/agent manager.png +0 -0
  374. package/dist/ui/product/agent_manager_02.png +0 -0
  375. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  376. package/dist/utils/aiProfileEvents.d.ts +7 -0
  377. package/dist/utils/aiProfileEvents.js +6 -0
  378. package/dist/utils/assignees.d.ts +8 -0
  379. package/dist/utils/assignees.js +10 -1
  380. package/dist/utils/assignees.test.d.ts +1 -0
  381. package/dist/utils/assignees.test.js +25 -0
  382. package/dist/utils/avatarDisplay.d.ts +1 -0
  383. package/dist/utils/avatarDisplay.js +15 -0
  384. package/dist/utils/avatarUpload.d.ts +27 -0
  385. package/dist/utils/avatarUpload.js +132 -0
  386. package/dist/utils/avatarUpload.test.d.ts +1 -0
  387. package/dist/utils/avatarUpload.test.js +40 -0
  388. package/dist/utils/bootstrapTrace.d.ts +7 -0
  389. package/dist/utils/bootstrapTrace.js +29 -0
  390. package/dist/utils/commercialLifecyclePresentation.js +3 -0
  391. package/dist/utils/contextAssetEvents.d.ts +2 -0
  392. package/dist/utils/contextFiles.d.ts +3 -1
  393. package/dist/utils/contextFiles.js +2 -1
  394. package/dist/utils/syncEventDetails.d.ts +3 -0
  395. package/dist/utils/syncEventDetails.js +64 -0
  396. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  397. package/dist/utils/syncEventDetails.test.js +23 -0
  398. package/dist/utils/syncEventPresentation.d.ts +15 -0
  399. package/dist/utils/syncEventPresentation.js +129 -0
  400. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  401. package/dist/utils/syncEventPresentation.test.js +64 -0
  402. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  403. package/dist/utils/syncStatusPresentation.js +217 -0
  404. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  405. package/dist/utils/syncStatusPresentation.test.js +164 -0
  406. package/dist/utils/taskActivity.d.ts +9 -1
  407. package/dist/utils/taskActivity.js +290 -22
  408. package/dist/utils/taskActivity.test.js +141 -8
  409. package/dist/utils/taskSearch.d.ts +22 -0
  410. package/dist/utils/taskSearch.js +87 -0
  411. package/dist/utils/taskSearch.test.d.ts +1 -0
  412. package/dist/utils/taskSearch.test.js +69 -0
  413. package/dist/utils/taskUtils.js +10 -3
  414. package/dist/utils/taskUtils.test.d.ts +1 -0
  415. package/dist/utils/taskUtils.test.js +19 -0
  416. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  417. package/dist/utils/workspaceSyncPresentation.js +68 -2
  418. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  419. package/package.json +3 -2
  420. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  421. package/scripts/run-e2e-realtime.sh +17 -3
  422. package/scripts/run-smoke.sh +17 -5
  423. package/scripts/run-soak.sh +17 -5
  424. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  425. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  426. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  427. package/src/resources/templates/workflows/plan.yaml +1 -1
  428. package/src/resources/templates/workflows/review.yaml +2 -2
  429. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
  430. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BG6P_GVW.js +0 -3
  431. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  432. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
  433. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
  434. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  435. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
  436. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  437. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +0 -9
  438. package/dist/ui/assets/index-C2-OXZV7.css +0 -1
  439. package/dist/ui/assets/index-DcSI5F86.js +0 -6
  440. package/scripts/migrate-planning-model.ts +0 -233
@@ -73,6 +73,9 @@ describe('TaskforceCore ai profiles', () => {
73
73
  expect(fetched?.assigneeActor?.label).toBe('Codex');
74
74
  expect(fetched?.createdByActor?.label).toBe('Codex');
75
75
  expect(fetched?.comments?.[0]?.actor?.label).toBe('Codex');
76
+ expect(fetched?.assigneeActor?.meta).not.toHaveProperty('profileToken');
77
+ expect(fetched?.createdByActor?.meta).not.toHaveProperty('profileToken');
78
+ expect(fetched?.comments?.[0]?.actor?.meta).not.toHaveProperty('profileToken');
76
79
  });
77
80
  it('updates existing ai profile presentation fields when profileToken is presented again', () => {
78
81
  const projectRoot = createProjectRoot();
@@ -98,6 +101,319 @@ describe('TaskforceCore ai profiles', () => {
98
101
  expect(second.profile.icon).toBe('Sparkles');
99
102
  expect(second.profile.color).toBe('#0ea5e9');
100
103
  });
104
+ it('updates ai profile avatar urls without changing the registered icon fallback', () => {
105
+ const projectRoot = createProjectRoot();
106
+ createdRoots.push(projectRoot);
107
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
108
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
109
+ const resolved = core.resolveAiProfile({
110
+ workspaceId: 'workspace-1',
111
+ name: 'Codex',
112
+ icon: 'Bot',
113
+ color: '#0ea5e9'
114
+ });
115
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
116
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
117
+ const updated = core.updateAiProfileAvatar({
118
+ workspaceId: 'workspace-1',
119
+ profileId: resolved.profile.id,
120
+ avatarUrl,
121
+ avatarSourceUrl
122
+ });
123
+ expect(updated.avatarUrl).toBe(avatarUrl);
124
+ expect(updated.avatarSourceUrl).toBe(avatarSourceUrl);
125
+ expect(updated.avatarRevision).toBe(1);
126
+ expect(updated.avatarUpdatedAt).toBe(updated.updatedAt);
127
+ expect(updated.icon).toBe('Bot');
128
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarUrl).toBe(avatarUrl);
129
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarSourceUrl).toBe(avatarSourceUrl);
130
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarRevision).toBe(1);
131
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarUpdatedAt).toBe(updated.updatedAt);
132
+ const cleared = core.updateAiProfileAvatar({
133
+ workspaceId: 'workspace-1',
134
+ profileId: resolved.profile.id,
135
+ avatarUrl: null,
136
+ avatarSourceUrl: null
137
+ });
138
+ expect(cleared.avatarUrl).toBeNull();
139
+ expect(cleared.avatarSourceUrl).toBeNull();
140
+ expect(cleared.avatarRevision).toBe(2);
141
+ expect(cleared.avatarUpdatedAt).toBe(cleared.updatedAt);
142
+ });
143
+ it('seeds blank same-name runtime profiles when applying an ai profile avatar', () => {
144
+ const projectRoot = createProjectRoot();
145
+ createdRoots.push(projectRoot);
146
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
147
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
148
+ const local = core.resolveAiProfile({
149
+ workspaceId: 'workspace-1',
150
+ name: 'Codex',
151
+ icon: 'Bot',
152
+ color: '#0ea5e9'
153
+ }, { trustedSeatScope: 'local_unmetered' });
154
+ const cloud = core.resolveAiProfile({
155
+ workspaceId: 'workspace-1',
156
+ name: 'Codex',
157
+ icon: 'Bot',
158
+ color: '#0ea5e9'
159
+ }, { trustedSeatScope: 'cloud_metered' });
160
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
161
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
162
+ const updated = core.updateAiProfileAvatar({
163
+ workspaceId: 'workspace-1',
164
+ profileId: local.profile.id,
165
+ avatarUrl,
166
+ avatarSourceUrl
167
+ });
168
+ const profiles = core.listAiProfiles('workspace-1');
169
+ const refreshedLocal = profiles.find((profile) => profile.id === local.profile.id);
170
+ const refreshedCloud = profiles.find((profile) => profile.id === cloud.profile.id);
171
+ expect(refreshedLocal?.avatarUrl).toBe(avatarUrl);
172
+ expect(refreshedCloud?.avatarUrl).toBe(avatarUrl);
173
+ expect(refreshedCloud?.avatarSourceUrl).toBe(avatarSourceUrl);
174
+ expect(refreshedCloud?.avatarRevision).toBe(updated.avatarRevision);
175
+ expect(refreshedCloud?.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
176
+ });
177
+ it('repairs blank same-name runtime profile avatars on token lookup', () => {
178
+ const projectRoot = createProjectRoot();
179
+ createdRoots.push(projectRoot);
180
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
181
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
182
+ const local = core.resolveAiProfile({
183
+ workspaceId: 'workspace-1',
184
+ name: 'Codex',
185
+ icon: 'Bot',
186
+ color: '#0ea5e9'
187
+ }, { trustedSeatScope: 'local_unmetered' });
188
+ const cloud = core.resolveAiProfile({
189
+ workspaceId: 'workspace-1',
190
+ name: 'Codex',
191
+ icon: 'Bot',
192
+ color: '#0ea5e9'
193
+ }, { trustedSeatScope: 'cloud_metered' });
194
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
195
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
196
+ const updated = core.updateAiProfileAvatar({
197
+ workspaceId: 'workspace-1',
198
+ profileId: local.profile.id,
199
+ avatarUrl,
200
+ avatarSourceUrl
201
+ });
202
+ core.db.prepare(`
203
+ UPDATE ai_profiles
204
+ SET avatar_url = NULL,
205
+ avatar_source_url = NULL,
206
+ avatar_revision = ?
207
+ WHERE id = ?
208
+ `).run(updated.avatarRevision, cloud.profile.id);
209
+ const lookedUp = core.getAiProfileByToken({
210
+ workspaceId: 'workspace-1',
211
+ profileToken: cloud.profile.profileToken,
212
+ seatScope: 'cloud_metered'
213
+ });
214
+ expect(lookedUp?.id).toBe(cloud.profile.id);
215
+ expect(lookedUp?.avatarUrl).toBe(avatarUrl);
216
+ expect(lookedUp?.avatarSourceUrl).toBe(avatarSourceUrl);
217
+ expect(lookedUp?.avatarRevision).toBe(updated.avatarRevision);
218
+ });
219
+ it('preserves synced ai profile avatars when metadata-only sync payloads omit avatar fields', () => {
220
+ const projectRoot = createProjectRoot();
221
+ createdRoots.push(projectRoot);
222
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
223
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
224
+ const resolved = core.resolveAiProfile({
225
+ workspaceId: 'workspace-1',
226
+ name: 'Codex',
227
+ icon: 'Bot',
228
+ color: '#0ea5e9'
229
+ });
230
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
231
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
232
+ const updated = core.updateAiProfileAvatar({
233
+ workspaceId: 'workspace-1',
234
+ profileId: resolved.profile.id,
235
+ avatarUrl,
236
+ avatarSourceUrl
237
+ });
238
+ const { avatarUrl: _avatarUrl, avatarSourceUrl: _avatarSourceUrl, ...metadataOnlyProfile } = updated;
239
+ const metadataUpdatedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
240
+ const replayed = core.upsertAiProfileForSync({
241
+ ...metadataOnlyProfile,
242
+ name: 'Codex Metadata Refresh',
243
+ updatedAt: metadataUpdatedAt,
244
+ lastActiveAt: metadataUpdatedAt
245
+ });
246
+ expect(replayed.name).toBe('Codex Metadata Refresh');
247
+ expect(replayed.avatarUrl).toBe(avatarUrl);
248
+ expect(replayed.avatarSourceUrl).toBe(avatarSourceUrl);
249
+ });
250
+ it('does not clear synced ai profile avatars from legacy stale null sync payloads', () => {
251
+ const projectRoot = createProjectRoot();
252
+ createdRoots.push(projectRoot);
253
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
254
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
255
+ const resolved = core.resolveAiProfile({
256
+ workspaceId: 'workspace-1',
257
+ name: 'Codex',
258
+ icon: 'Bot',
259
+ color: '#0ea5e9'
260
+ });
261
+ const updated = core.updateAiProfileAvatar({
262
+ workspaceId: 'workspace-1',
263
+ profileId: resolved.profile.id,
264
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
265
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
266
+ });
267
+ const clearedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
268
+ const { avatarRevision: _avatarRevision, avatarUpdatedAt: _avatarUpdatedAt, ...legacyUpdated } = updated;
269
+ const cleared = core.upsertAiProfileForSync({
270
+ ...legacyUpdated,
271
+ avatarUrl: null,
272
+ avatarSourceUrl: null,
273
+ updatedAt: clearedAt,
274
+ lastActiveAt: clearedAt
275
+ });
276
+ expect(cleared.avatarUrl).toBe(updated.avatarUrl);
277
+ expect(cleared.avatarSourceUrl).toBe(updated.avatarSourceUrl);
278
+ expect(cleared.avatarRevision).toBe(updated.avatarRevision);
279
+ });
280
+ it('does not clear synced ai profile avatars from blank revisions without avatar update timestamps', () => {
281
+ const projectRoot = createProjectRoot();
282
+ createdRoots.push(projectRoot);
283
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
284
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
285
+ const resolved = core.resolveAiProfile({
286
+ workspaceId: 'workspace-1',
287
+ name: 'Codex',
288
+ icon: 'Bot',
289
+ color: '#0ea5e9'
290
+ });
291
+ const updated = core.updateAiProfileAvatar({
292
+ workspaceId: 'workspace-1',
293
+ profileId: resolved.profile.id,
294
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
295
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
296
+ });
297
+ const refreshedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
298
+ const replayed = core.upsertAiProfileForSync({
299
+ ...updated,
300
+ avatarUrl: null,
301
+ avatarSourceUrl: null,
302
+ avatarRevision: (updated.avatarRevision ?? 0) + 1,
303
+ avatarUpdatedAt: null,
304
+ updatedAt: refreshedAt,
305
+ lastActiveAt: refreshedAt
306
+ });
307
+ expect(replayed.avatarUrl).toBe(updated.avatarUrl);
308
+ expect(replayed.avatarSourceUrl).toBe(updated.avatarSourceUrl);
309
+ expect(replayed.avatarRevision).toBe(updated.avatarRevision);
310
+ expect(replayed.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
311
+ });
312
+ it('clears synced ai profile avatars when sync payloads send a newer avatar revision', () => {
313
+ const projectRoot = createProjectRoot();
314
+ createdRoots.push(projectRoot);
315
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
316
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
317
+ const resolved = core.resolveAiProfile({
318
+ workspaceId: 'workspace-1',
319
+ name: 'Codex',
320
+ icon: 'Bot',
321
+ color: '#0ea5e9'
322
+ });
323
+ const updated = core.updateAiProfileAvatar({
324
+ workspaceId: 'workspace-1',
325
+ profileId: resolved.profile.id,
326
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
327
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
328
+ });
329
+ const clearedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
330
+ const cleared = core.upsertAiProfileForSync({
331
+ ...updated,
332
+ avatarUrl: null,
333
+ avatarSourceUrl: null,
334
+ avatarRevision: (updated.avatarRevision ?? 0) + 1,
335
+ avatarUpdatedAt: clearedAt,
336
+ updatedAt: clearedAt,
337
+ lastActiveAt: clearedAt
338
+ });
339
+ expect(cleared.avatarUrl).toBeNull();
340
+ expect(cleared.avatarSourceUrl).toBeNull();
341
+ expect(cleared.avatarRevision).toBe((updated.avatarRevision ?? 0) + 1);
342
+ expect(cleared.avatarUpdatedAt).toBe(clearedAt);
343
+ });
344
+ it('repairs blank legacy ai profile avatar pointers from workspace asset metadata', () => {
345
+ const projectRoot = createProjectRoot();
346
+ createdRoots.push(projectRoot);
347
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
348
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
349
+ const resolved = core.resolveAiProfile({
350
+ workspaceId: 'workspace-1',
351
+ name: 'Felix Leiter',
352
+ icon: 'Bot',
353
+ color: '#0ea5e9'
354
+ });
355
+ const db = core.db;
356
+ const croppedKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-cropped-Felix.webp`;
357
+ const sourceKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-source-Felix.webp`;
358
+ db.prepare(`
359
+ INSERT INTO workspace_assets (
360
+ asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
361
+ storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
362
+ created_at, updated_at
363
+ ) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
364
+ `).run('asset-cropped', 'Felix avatar', 'cropped-Felix.webp', croppedKey, 'sha-cropped', '2026-04-28T15:00:00.000Z', '2026-04-28T15:00:00.000Z');
365
+ db.prepare(`
366
+ INSERT INTO workspace_assets (
367
+ asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
368
+ storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
369
+ created_at, updated_at
370
+ ) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
371
+ `).run('asset-source', 'Felix source', 'source-Felix.webp', sourceKey, 'sha-source', '2026-04-28T15:01:00.000Z', '2026-04-28T15:01:00.000Z');
372
+ const repaired = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
373
+ expect(repaired?.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(croppedKey)}`);
374
+ expect(repaired?.avatarSourceUrl).toBe(`/api/taskforce/context/${encodeURIComponent(sourceKey)}`);
375
+ expect(repaired?.avatarRevision).toBe(1);
376
+ expect(repaired?.avatarUpdatedAt).toBe('2026-04-28T15:00:00.000Z');
377
+ core.updateAiProfileAvatar({
378
+ workspaceId: 'workspace-1',
379
+ profileId: resolved.profile.id,
380
+ avatarUrl: null,
381
+ avatarSourceUrl: null
382
+ });
383
+ const cleared = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
384
+ expect(cleared?.avatarUrl).toBeNull();
385
+ expect(cleared?.avatarSourceUrl).toBeNull();
386
+ });
387
+ it('repairs missing synced ai profile avatars when timestamps match', () => {
388
+ const projectRoot = createProjectRoot();
389
+ createdRoots.push(projectRoot);
390
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
391
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
392
+ const resolved = core.resolveAiProfile({
393
+ workspaceId: 'workspace-1',
394
+ name: 'Codex',
395
+ icon: 'Bot',
396
+ color: '#0ea5e9'
397
+ });
398
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
399
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
400
+ const updated = core.updateAiProfileAvatar({
401
+ workspaceId: 'workspace-1',
402
+ profileId: resolved.profile.id,
403
+ avatarUrl,
404
+ avatarSourceUrl
405
+ });
406
+ core.db.prepare(`
407
+ UPDATE ai_profiles
408
+ SET avatar_url = NULL,
409
+ avatar_source_url = NULL
410
+ WHERE id = ?
411
+ `).run(updated.id);
412
+ const repaired = core.upsertAiProfileForSync(updated);
413
+ expect(repaired.updatedAt).toBe(updated.updatedAt);
414
+ expect(repaired.avatarUrl).toBe(avatarUrl);
415
+ expect(repaired.avatarSourceUrl).toBe(avatarSourceUrl);
416
+ });
101
417
  it('stores expanded ai profile fields and preserves them when omitted on re-resolve', () => {
102
418
  const projectRoot = createProjectRoot();
103
419
  createdRoots.push(projectRoot);
@@ -339,6 +655,73 @@ describe('TaskforceCore ai profiles', () => {
339
655
  });
340
656
  expect(second.profile.surfaceType).toBe('coding_tool');
341
657
  });
658
+ it('supports cli and maps legacy other surfaceType values to agent', () => {
659
+ const projectRoot = createProjectRoot();
660
+ createdRoots.push(projectRoot);
661
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
662
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
663
+ const cliProfile = core.resolveAiProfile({
664
+ workspaceId: 'workspace-1',
665
+ name: 'Codex',
666
+ surfaceType: 'cli'
667
+ });
668
+ expect(cliProfile.profile.surfaceType).toBe('cli');
669
+ expect(cliProfile.profile.role).toBe('CLI Coding Agent');
670
+ const legacyOtherProfile = core.resolveAiProfile({
671
+ workspaceId: 'workspace-1',
672
+ name: 'Legacy Agent',
673
+ surfaceType: 'other'
674
+ });
675
+ expect(legacyOtherProfile.profile.surfaceType).toBe('agent');
676
+ });
677
+ it('keeps a manually updated ai profile surfaceType from being overwritten by future registration', () => {
678
+ const projectRoot = createProjectRoot();
679
+ createdRoots.push(projectRoot);
680
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
681
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
682
+ const first = core.resolveAiProfile({
683
+ workspaceId: 'workspace-1',
684
+ name: 'ChatGPT',
685
+ surfaceType: 'chat_app'
686
+ });
687
+ const updated = core.updateAiProfileSurfaceType({
688
+ workspaceId: 'workspace-1',
689
+ profileId: first.profile.id,
690
+ surfaceType: 'coding_tool'
691
+ });
692
+ expect(updated.surfaceType).toBe('coding_tool');
693
+ const replayedRegistration = core.resolveAiProfile({
694
+ workspaceId: 'workspace-1',
695
+ name: 'ChatGPT',
696
+ surfaceType: 'chat_app',
697
+ profileToken: first.profile.profileToken
698
+ });
699
+ expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
700
+ });
701
+ it('treats unclassified ai profile category updates as clearing the manual override', () => {
702
+ const projectRoot = createProjectRoot();
703
+ createdRoots.push(projectRoot);
704
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
705
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
706
+ const first = core.resolveAiProfile({
707
+ workspaceId: 'workspace-1',
708
+ name: 'ChatGPT',
709
+ surfaceType: 'chat_app'
710
+ });
711
+ const cleared = core.updateAiProfileSurfaceType({
712
+ workspaceId: 'workspace-1',
713
+ profileId: first.profile.id,
714
+ surfaceType: null
715
+ });
716
+ expect(cleared.surfaceType).toBeNull();
717
+ const replayedRegistration = core.resolveAiProfile({
718
+ workspaceId: 'workspace-1',
719
+ name: 'ChatGPT',
720
+ surfaceType: 'coding_tool',
721
+ profileToken: first.profile.profileToken
722
+ });
723
+ expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
724
+ });
342
725
  it('rejects invalid ai profile surfaceType values', () => {
343
726
  const projectRoot = createProjectRoot();
344
727
  createdRoots.push(projectRoot);
@@ -454,7 +837,7 @@ describe('TaskforceCore ai profiles', () => {
454
837
  name: 'Claude',
455
838
  color: '#f59e0b'
456
839
  });
457
- // Resolve by name (e.g. agent passed "Claude" as author instead of profile ID)
840
+ // Resolve by name only when the alias is uniquely attributable.
458
841
  const actorByName = core.resolveWorkspaceActor('workspace-1', 'Claude');
459
842
  expect(actorByName).not.toBeNull();
460
843
  expect(actorByName?.id).toBe(profile.id);
@@ -464,6 +847,35 @@ describe('TaskforceCore ai profiles', () => {
464
847
  const actorLower = core.resolveWorkspaceActor('workspace-1', 'claude');
465
848
  expect(actorLower?.id).toBe(profile.id);
466
849
  });
850
+ it('leaves duplicate ai profile aliases unresolved instead of picking one profile', () => {
851
+ const projectRoot = createProjectRoot();
852
+ createdRoots.push(projectRoot);
853
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
854
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
855
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
856
+ const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
857
+ const db = core.db;
858
+ db.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
859
+ .run('default', second.profile.id);
860
+ const actor = core.resolveWorkspaceActor('workspace-1', 'Claude');
861
+ expect(actor?.id).toBe('Claude');
862
+ expect(actor?.kind).toBe('ai');
863
+ expect(actor?.meta?.unresolved).toBe(true);
864
+ const task = core.createTask({
865
+ title: 'Ambiguous legacy comment author',
866
+ comments: [{
867
+ id: 'comment-1',
868
+ author: 'Claude',
869
+ text: 'Keep the original ambiguous alias.',
870
+ timestamp: '2026-03-22T10:00:00.000Z'
871
+ }]
872
+ }, { workspaceId: 'workspace-1' });
873
+ const fetched = core.getTask(task.id, 'workspace-1');
874
+ expect(fetched?.comments?.[0]?.author).toBe('Claude');
875
+ expect(fetched?.comments?.[0]?.actor?.id).toBe('Claude');
876
+ expect(fetched?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
877
+ expect(first.profile.id).not.toBe(second.profile.id);
878
+ });
467
879
  it('classifies well-known AI model names as ai-kind even without a registered profile', () => {
468
880
  const projectRoot = createProjectRoot();
469
881
  createdRoots.push(projectRoot);
@@ -492,82 +904,398 @@ describe('TaskforceCore ai profiles', () => {
492
904
  expect(second.profile.id).toBe(first.profile.id);
493
905
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
494
906
  });
495
- it('reuses Codex by exact name without leaking across workspaces', () => {
907
+ it('reuses a stateless ai profile by name and provider while treating model changes as metadata updates', () => {
496
908
  const projectRoot = createProjectRoot();
497
909
  createdRoots.push(projectRoot);
498
910
  const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
499
911
  core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
500
- core.createWorkspace({ workspaceId: 'workspace-2', name: 'Workspace 2' });
501
- const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
502
- const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
503
- const otherWorkspace = core.resolveAiProfile({ workspaceId: 'workspace-2', name: 'Codex' });
504
- expect(first.created).toBe(true);
912
+ const first = core.resolveAiProfile({
913
+ workspaceId: 'workspace-1',
914
+ name: 'Claude',
915
+ provider: 'Anthropic',
916
+ model: 'claude-sonnet-4-5',
917
+ surfaceType: 'chat_app'
918
+ });
919
+ const second = core.resolveAiProfile({
920
+ workspaceId: 'workspace-1',
921
+ name: 'claude',
922
+ provider: 'anthropic',
923
+ model: 'claude-sonnet-4-6',
924
+ surfaceType: 'chat_app'
925
+ });
505
926
  expect(second.created).toBe(false);
506
927
  expect(second.profile.id).toBe(first.profile.id);
507
- expect(otherWorkspace.created).toBe(true);
508
- expect(otherWorkspace.profile.id).not.toBe(first.profile.id);
928
+ expect(second.profile.model).toBe('claude-sonnet-4-6');
509
929
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
510
- expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
511
930
  });
512
- it('allows token-based re-resolve even when the ai profile limit is full', () => {
931
+ it('reuses a provider-bearing stateless ai profile when a later resolve omits provider and model', () => {
513
932
  const projectRoot = createProjectRoot();
514
933
  createdRoots.push(projectRoot);
515
934
  const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
516
935
  core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
517
- enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
518
- const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
936
+ const first = core.resolveAiProfile({
937
+ workspaceId: 'workspace-1',
938
+ name: 'Claude',
939
+ provider: 'Anthropic',
940
+ model: 'claude-sonnet-4-5',
941
+ surfaceType: 'chat_app'
942
+ });
519
943
  const second = core.resolveAiProfile({
520
944
  workspaceId: 'workspace-1',
521
- name: 'Codex Prime',
522
- icon: 'Sparkles',
523
- profileToken: first.profile.profileToken
945
+ name: 'claude'
524
946
  });
525
947
  expect(second.created).toBe(false);
526
948
  expect(second.profile.id).toBe(first.profile.id);
527
- expect(second.profile.name).toBe('Codex Prime');
949
+ expect(second.profile.provider).toBe('Anthropic');
950
+ expect(second.profile.model).toBe('claude-sonnet-4-5');
528
951
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
529
952
  });
530
- it('reuses an exact-match ai profile even when the ai profile limit is full', () => {
953
+ it('reuses same-scope ai profiles across provider drift', () => {
531
954
  const projectRoot = createProjectRoot();
532
955
  createdRoots.push(projectRoot);
533
956
  const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
534
957
  core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
535
- enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
536
- const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' });
537
- const second = core.resolveAiProfile({
958
+ const original = core.resolveAiProfile({
538
959
  workspaceId: 'workspace-1',
539
- name: 'Automation Codex',
540
- role: 'Assistant'
960
+ name: 'Felix Leiter',
961
+ provider: 'OpenAI',
962
+ model: 'openai-codex/gpt-5.4',
963
+ surfaceType: 'agent'
541
964
  });
542
- expect(second.created).toBe(false);
543
- expect(second.profile.id).toBe(first.profile.id);
544
- expect(second.profile.role).toBe('Assistant');
965
+ const drifted = core.resolveAiProfile({
966
+ workspaceId: 'workspace-1',
967
+ name: 'Felix Leiter',
968
+ provider: 'OpenClaw',
969
+ model: 'gpt-5.4',
970
+ surfaceType: 'agent'
971
+ });
972
+ expect(drifted.created).toBe(false);
973
+ expect(drifted.profile.id).toBe(original.profile.id);
974
+ expect(drifted.profile.provider).toBe('OpenClaw');
975
+ expect(drifted.profile.model).toBe('gpt-5.4');
545
976
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
546
977
  });
547
- it('blocks net-new ai profile creation when the ai profile limit is full', () => {
978
+ it('reuses same-scope ai profiles across surface type drift', () => {
548
979
  const projectRoot = createProjectRoot();
549
980
  createdRoots.push(projectRoot);
550
981
  const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
551
982
  core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
552
- enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
553
- const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
554
- expect(first.created).toBe(true);
555
- try {
556
- core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude' });
557
- throw new Error('Expected AI profile creation to be blocked');
558
- }
559
- catch (error) {
560
- expect(error.message).toContain('No AI seats are available for this workspace');
561
- expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
562
- expect(error.statusCode).toBe(403);
563
- expect(error.details).toEqual({
564
- workspaceId: 'workspace-1',
565
- limit: 1,
566
- used: 1,
983
+ const chat = core.resolveAiProfile({
984
+ workspaceId: 'workspace-1',
985
+ name: 'Claude',
986
+ provider: 'Anthropic',
987
+ model: 'claude-sonnet-4-6',
988
+ surfaceType: 'chat_app'
989
+ });
990
+ const codingTool = core.resolveAiProfile({
991
+ workspaceId: 'workspace-1',
992
+ name: 'Claude',
993
+ provider: 'Anthropic',
994
+ model: 'claude-sonnet-4-6',
995
+ surfaceType: 'coding_tool'
996
+ });
997
+ expect(codingTool.created).toBe(false);
998
+ expect(codingTool.profile.id).toBe(chat.profile.id);
999
+ expect(codingTool.profile.surfaceType).toBe('coding_tool');
1000
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1001
+ });
1002
+ it('reuses same-scope ai profiles when reconnecting with a stale token', () => {
1003
+ const projectRoot = createProjectRoot();
1004
+ createdRoots.push(projectRoot);
1005
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1006
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1007
+ const original = core.resolveAiProfile({
1008
+ workspaceId: 'workspace-1',
1009
+ name: 'Tiffany Case',
1010
+ provider: 'openai-codex',
1011
+ model: 'gpt-5.4',
1012
+ surfaceType: 'agent'
1013
+ }, { trustedSeatScope: 'cloud_metered' });
1014
+ const reconnected = core.resolveAiProfile({
1015
+ workspaceId: 'workspace-1',
1016
+ name: 'Tiffany Case',
1017
+ profileToken: 'tfp_stale_tiffany_case_token',
1018
+ provider: 'OpenAI Codex',
1019
+ model: 'openai-codex/gpt-5.4',
1020
+ surfaceType: 'agent'
1021
+ }, { trustedSeatScope: 'cloud_metered' });
1022
+ expect(reconnected.created).toBe(false);
1023
+ expect(reconnected.profile.id).toBe(original.profile.id);
1024
+ expect(reconnected.profile.provider).toBe('OpenAI Codex');
1025
+ expect(reconnected.profile.model).toBe('openai-codex/gpt-5.4');
1026
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1027
+ });
1028
+ it('prefers the most recently active matching ai profile when stateless candidates are duplicated', () => {
1029
+ const projectRoot = createProjectRoot();
1030
+ createdRoots.push(projectRoot);
1031
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1032
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1033
+ const older = core.resolveAiProfile({
1034
+ workspaceId: 'workspace-1',
1035
+ name: 'Claude',
1036
+ provider: 'Anthropic',
1037
+ model: 'claude-sonnet-4-5',
1038
+ surfaceType: 'chat_app'
1039
+ });
1040
+ const newer = core.resolveAiProfile({
1041
+ workspaceId: 'workspace-1',
1042
+ name: 'Claude Duplicate',
1043
+ provider: 'Anthropic',
1044
+ model: 'claude-sonnet-4-6',
1045
+ surfaceType: 'chat_app'
1046
+ });
1047
+ core.db.prepare(`
1048
+ UPDATE ai_profiles
1049
+ SET name = 'Claude',
1050
+ username = 'claude',
1051
+ last_active_at = CASE id
1052
+ WHEN ? THEN '2026-04-01T00:00:00.000Z'
1053
+ ELSE '2026-04-02T00:00:00.000Z'
1054
+ END,
1055
+ updated_at = CASE id
1056
+ WHEN ? THEN '2026-04-01T00:00:00.000Z'
1057
+ ELSE '2026-04-02T00:00:00.000Z'
1058
+ END
1059
+ WHERE id IN (?, ?)
1060
+ `).run(older.profile.id, older.profile.id, older.profile.id, newer.profile.id);
1061
+ const resolved = core.resolveAiProfile({
1062
+ workspaceId: 'workspace-1',
1063
+ name: 'Claude',
1064
+ provider: 'Anthropic',
1065
+ model: 'claude-sonnet-4-7',
1066
+ surfaceType: 'chat_app'
1067
+ });
1068
+ expect(resolved.created).toBe(false);
1069
+ expect(resolved.profile.id).toBe(newer.profile.id);
1070
+ expect(resolved.duplicateWarning?.existingProfileId).toBe(newer.profile.id);
1071
+ });
1072
+ it('reuses Codex by exact name without leaking across workspaces', () => {
1073
+ const projectRoot = createProjectRoot();
1074
+ createdRoots.push(projectRoot);
1075
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1076
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1077
+ core.createWorkspace({ workspaceId: 'workspace-2', name: 'Workspace 2' });
1078
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
1079
+ const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
1080
+ const otherWorkspace = core.resolveAiProfile({ workspaceId: 'workspace-2', name: 'Codex' });
1081
+ expect(first.created).toBe(true);
1082
+ expect(second.created).toBe(false);
1083
+ expect(second.profile.id).toBe(first.profile.id);
1084
+ expect(otherWorkspace.created).toBe(true);
1085
+ expect(otherWorkspace.profile.id).not.toBe(first.profile.id);
1086
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1087
+ expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
1088
+ });
1089
+ it('allows token-based re-resolve even when the ai profile limit is full', () => {
1090
+ const projectRoot = createProjectRoot();
1091
+ createdRoots.push(projectRoot);
1092
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1093
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1094
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
1095
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
1096
+ const second = core.resolveAiProfile({
1097
+ workspaceId: 'workspace-1',
1098
+ name: 'Codex Prime',
1099
+ icon: 'Sparkles',
1100
+ profileToken: first.profile.profileToken
1101
+ });
1102
+ expect(second.created).toBe(false);
1103
+ expect(second.profile.id).toBe(first.profile.id);
1104
+ expect(second.profile.name).toBe('Codex Prime');
1105
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1106
+ });
1107
+ it('reuses an exact-match ai profile even when the ai profile limit is full', () => {
1108
+ const projectRoot = createProjectRoot();
1109
+ createdRoots.push(projectRoot);
1110
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1111
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1112
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
1113
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' }, { trustedSeatScope: 'cloud_metered' });
1114
+ const second = core.resolveAiProfile({
1115
+ workspaceId: 'workspace-1',
1116
+ name: 'Automation Codex',
1117
+ role: 'Assistant',
1118
+ }, { trustedSeatScope: 'cloud_metered' });
1119
+ expect(second.created).toBe(false);
1120
+ expect(second.profile.id).toBe(first.profile.id);
1121
+ expect(second.profile.role).toBe('Assistant');
1122
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1123
+ });
1124
+ it('blocks net-new ai profile creation when the ai profile limit is full', () => {
1125
+ const projectRoot = createProjectRoot();
1126
+ createdRoots.push(projectRoot);
1127
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1128
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1129
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
1130
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' }, { trustedSeatScope: 'cloud_metered' });
1131
+ expect(first.created).toBe(true);
1132
+ try {
1133
+ core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude' }, { trustedSeatScope: 'cloud_metered' });
1134
+ throw new Error('Expected AI profile creation to be blocked');
1135
+ }
1136
+ catch (error) {
1137
+ expect(error.message).toContain('No AI seats are available for this workspace');
1138
+ expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
1139
+ expect(error.statusCode).toBe(403);
1140
+ expect(error.details).toEqual({
1141
+ workspaceId: 'workspace-1',
1142
+ limit: 1,
1143
+ used: 1,
567
1144
  remaining: 0
568
1145
  });
569
1146
  }
570
1147
  });
1148
+ it('counts only cloud-metered ai profiles toward the seat limit', () => {
1149
+ const projectRoot = createProjectRoot();
1150
+ createdRoots.push(projectRoot);
1151
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1152
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1153
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
1154
+ const local = core.resolveAiProfile({
1155
+ workspaceId: 'workspace-1',
1156
+ name: 'Local Codex',
1157
+ }, { trustedSeatScope: 'local_unmetered' });
1158
+ const cloud = core.resolveAiProfile({
1159
+ workspaceId: 'workspace-1',
1160
+ name: 'Cloud Codex',
1161
+ }, { trustedSeatScope: 'cloud_metered' });
1162
+ const extraLocal = core.resolveAiProfile({
1163
+ workspaceId: 'workspace-1',
1164
+ name: 'Another Local Agent',
1165
+ }, { trustedSeatScope: 'local_unmetered' });
1166
+ expect(local.profile.seatScope).toBe('local_unmetered');
1167
+ expect(cloud.profile.seatScope).toBe('cloud_metered');
1168
+ expect(extraLocal.profile.seatScope).toBe('local_unmetered');
1169
+ expect(core.countAiProfileUsage('workspace-1')).toEqual({
1170
+ used: 1,
1171
+ limit: 1,
1172
+ remaining: 0
1173
+ });
1174
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(3);
1175
+ });
1176
+ it('honors configured ai profile limits even when legacy plan data stores the feature as enabled', () => {
1177
+ const projectRoot = createProjectRoot();
1178
+ createdRoots.push(projectRoot);
1179
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1180
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1181
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
1182
+ core.ensureBootstrapUserAccess({
1183
+ userId: 'owner-u1',
1184
+ workspaceId: 'workspace-1',
1185
+ role: 'owner',
1186
+ email: 'owner-u1@example.com'
1187
+ });
1188
+ core.updatePlanVersionFeatures({
1189
+ planVersionId: 'free-v1',
1190
+ features: [
1191
+ { featureKey: 'collaboration.ai_profiles', access: 'enabled', config: { maxAiProfiles: 20 } }
1192
+ ]
1193
+ });
1194
+ core.db.prepare(`
1195
+ UPDATE plan_feature_matrix
1196
+ SET access = 'enabled',
1197
+ config_json = ?
1198
+ WHERE tenant_id = ? AND plan_version_id = ? AND feature_key = ?
1199
+ `).run(JSON.stringify({ maxAiProfiles: 20 }), 'default', 'free-v1', 'collaboration.ai_profiles');
1200
+ core.updateAccountEntitlement({
1201
+ accountId: 'owner-u1',
1202
+ planVersionId: 'free-v1',
1203
+ entitlementState: 'active',
1204
+ actorUserId: 'system-admin',
1205
+ actorRole: 'system_admin'
1206
+ });
1207
+ core.resolveAiProfile({
1208
+ workspaceId: 'workspace-1',
1209
+ name: 'Cloud Codex',
1210
+ }, { trustedSeatScope: 'cloud_metered' });
1211
+ expect(core.countAiProfileUsage('workspace-1')).toEqual({
1212
+ used: 1,
1213
+ limit: 20,
1214
+ remaining: 19
1215
+ });
1216
+ });
1217
+ it('blocks cloud promotion when a local ai profile would exceed the seat limit', () => {
1218
+ const projectRoot = createProjectRoot();
1219
+ createdRoots.push(projectRoot);
1220
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1221
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1222
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
1223
+ core.resolveAiProfile({
1224
+ workspaceId: 'workspace-1',
1225
+ name: 'Cloud Bot',
1226
+ }, { trustedSeatScope: 'cloud_metered' });
1227
+ const local = core.resolveAiProfile({
1228
+ workspaceId: 'workspace-1',
1229
+ name: 'Hybrid Bot',
1230
+ }, { trustedSeatScope: 'local_unmetered' });
1231
+ try {
1232
+ core.resolveAiProfile({
1233
+ workspaceId: 'workspace-1',
1234
+ name: 'Hybrid Bot',
1235
+ }, { trustedSeatScope: 'cloud_metered' });
1236
+ throw new Error('Expected cloud promotion to be blocked');
1237
+ }
1238
+ catch (error) {
1239
+ expect(error.message).toContain('No AI seats are available for this workspace');
1240
+ expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
1241
+ }
1242
+ const refreshed = core.getAiProfileByToken({
1243
+ workspaceId: 'workspace-1',
1244
+ profileToken: local.profile.profileToken
1245
+ });
1246
+ expect(refreshed?.seatScope).toBe('local_unmetered');
1247
+ });
1248
+ it('keeps local and cloud MCP profiles separate when they share the same name', () => {
1249
+ const projectRoot = createProjectRoot();
1250
+ createdRoots.push(projectRoot);
1251
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1252
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1253
+ const cloud = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1254
+ const local = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
1255
+ expect(local.created).toBe(true);
1256
+ expect(local.profile.id).not.toBe(cloud.profile.id);
1257
+ expect(local.profile.seatScope).toBe('local_unmetered');
1258
+ expect(cloud.profile.seatScope).toBe('cloud_metered');
1259
+ expect(core.countAiProfileUsage('workspace-1')).toEqual({
1260
+ used: 1,
1261
+ limit: null,
1262
+ remaining: null
1263
+ });
1264
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(2);
1265
+ });
1266
+ it('claims a legacy unscoped profile for the trusted runtime instead of creating a duplicate', () => {
1267
+ const projectRoot = createProjectRoot();
1268
+ createdRoots.push(projectRoot);
1269
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1270
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1271
+ const legacy = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Legacy Codex' });
1272
+ expect(legacy.profile.seatScope ?? null).toBeNull();
1273
+ const claimed = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Legacy Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
1274
+ expect(claimed.created).toBe(false);
1275
+ expect(claimed.profile.id).toBe(legacy.profile.id);
1276
+ expect(claimed.profile.seatScope).toBe('local_unmetered');
1277
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1278
+ });
1279
+ it('reactivates a retired legacy unscoped profile for the trusted runtime instead of recreating it', () => {
1280
+ const projectRoot = createProjectRoot();
1281
+ createdRoots.push(projectRoot);
1282
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1283
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1284
+ const legacy = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Retired Legacy Codex' });
1285
+ expect(legacy.profile.seatScope ?? null).toBeNull();
1286
+ core.archiveAiProfile({
1287
+ workspaceId: 'workspace-1',
1288
+ profileId: legacy.profile.id,
1289
+ archivedBy: 'owner-1',
1290
+ reason: 'manual_archive'
1291
+ });
1292
+ const reactivated = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Retired Legacy Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1293
+ expect(reactivated.created).toBe(false);
1294
+ expect(reactivated.profile.id).toBe(legacy.profile.id);
1295
+ expect(reactivated.profile.archivedAt).toBeNull();
1296
+ expect(reactivated.profile.seatScope).toBe('cloud_metered');
1297
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1298
+ });
571
1299
  it('merges two duplicate profiles, reassigning task assignees and comment authors', () => {
572
1300
  const projectRoot = createProjectRoot();
573
1301
  createdRoots.push(projectRoot);
@@ -593,16 +1321,324 @@ describe('TaskforceCore ai profiles', () => {
593
1321
  mergeId: second.profile.id
594
1322
  });
595
1323
  expect(merged.id).toBe(first.profile.id);
596
- // Duplicate profile should be gone
1324
+ // Duplicate profile should leave the active roster but remain archived for history and sync.
597
1325
  const profiles = core.listAiProfiles('workspace-1');
598
1326
  expect(profiles.some((p) => p.id === second.profile.id)).toBe(false);
599
1327
  expect(profiles.some((p) => p.id === first.profile.id)).toBe(true);
1328
+ const archivedProfiles = core.listAiProfiles('workspace-1', { includeArchived: true });
1329
+ const archivedDuplicate = archivedProfiles.find((p) => p.id === second.profile.id);
1330
+ expect(archivedDuplicate).toMatchObject({
1331
+ id: second.profile.id,
1332
+ archivedReason: 'merged_duplicate',
1333
+ mergedIntoProfileId: first.profile.id,
1334
+ archivedBy: first.profile.id
1335
+ });
1336
+ expect(archivedDuplicate?.archivedAt).toBeTruthy();
600
1337
  // Task assignee should point to the kept profile
601
1338
  const updatedTask = core.getTask(task.id, 'workspace-1');
602
1339
  expect(updatedTask?.assignee).toBe(first.profile.id);
603
1340
  // Comment author should point to the kept profile
604
1341
  expect(updatedTask?.comments?.[0]?.author).toBe(first.profile.id);
605
1342
  });
1343
+ it('does not merge separate local and cloud AI profile registrations', () => {
1344
+ const projectRoot = createProjectRoot();
1345
+ createdRoots.push(projectRoot);
1346
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1347
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1348
+ const cloud = core.resolveAiProfile({
1349
+ workspaceId: 'workspace-1',
1350
+ name: 'Codex',
1351
+ surfaceType: 'coding_tool'
1352
+ }, { trustedSeatScope: 'cloud_metered' });
1353
+ const local = core.resolveAiProfile({
1354
+ workspaceId: 'workspace-1',
1355
+ name: 'Codex',
1356
+ surfaceType: 'coding_tool'
1357
+ }, { trustedSeatScope: 'local_unmetered' });
1358
+ expect(() => core.mergeAiProfiles({
1359
+ workspaceId: 'workspace-1',
1360
+ keepId: local.profile.id,
1361
+ mergeId: cloud.profile.id
1362
+ })).toThrow(/separate registrations/);
1363
+ expect(core.listAiProfiles('workspace-1').map((profile) => profile.id).sort()).toEqual([cloud.profile.id, local.profile.id].sort());
1364
+ });
1365
+ it('archives AI profiles out of the active roster while preserving history and freeing seats', () => {
1366
+ const projectRoot = createProjectRoot();
1367
+ createdRoots.push(projectRoot);
1368
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1369
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1370
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-1', 1);
1371
+ const first = core.resolveAiProfile({
1372
+ workspaceId: 'workspace-1',
1373
+ name: 'Codex',
1374
+ surfaceType: 'coding_tool'
1375
+ });
1376
+ const archived = core.archiveAiProfile({
1377
+ workspaceId: 'workspace-1',
1378
+ profileId: first.profile.id,
1379
+ archivedBy: 'owner-1',
1380
+ reason: 'manual_archive'
1381
+ });
1382
+ expect(archived.id).toBe(first.profile.id);
1383
+ expect(archived.archivedReason).toBe('manual_archive');
1384
+ expect(archived.archivedBy).toBe('owner-1');
1385
+ expect(archived.archivedAt).toBeTruthy();
1386
+ expect(core.listAiProfiles('workspace-1')).toEqual([]);
1387
+ expect(core.listAssignableActors('workspace-1').some((actor) => actor.id === first.profile.id)).toBe(false);
1388
+ const historicalActor = core.resolveWorkspaceActor('workspace-1', first.profile.id);
1389
+ expect(historicalActor?.id).toBe(first.profile.id);
1390
+ expect(historicalActor?.label).toBe('Codex');
1391
+ expect(historicalActor?.meta).toEqual(expect.objectContaining({
1392
+ archivedReason: 'manual_archive'
1393
+ }));
1394
+ const second = core.resolveAiProfile({
1395
+ workspaceId: 'workspace-1',
1396
+ name: 'Claude',
1397
+ surfaceType: 'coding_tool'
1398
+ });
1399
+ expect(second.created).toBe(true);
1400
+ const archivedProfiles = core.listAiProfiles('workspace-1', { includeArchived: true });
1401
+ expect(archivedProfiles.map((profile) => profile.name)).toEqual(['Claude', 'Codex']);
1402
+ const archivedEntry = archivedProfiles.find((profile) => profile.id === first.profile.id);
1403
+ expect(archivedEntry?.archivedReason).toBe('manual_archive');
1404
+ expect(archivedEntry?.rosterStateUpdatedAt).toBe(archivedEntry?.archivedAt);
1405
+ });
1406
+ it('keeps an archived AI profile archived when sync replays an equal-watermark active copy', () => {
1407
+ const projectRoot = createProjectRoot();
1408
+ createdRoots.push(projectRoot);
1409
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1410
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1411
+ const resolved = core.resolveAiProfile({
1412
+ workspaceId: 'workspace-1',
1413
+ name: 'Codex T-714 Verifier',
1414
+ surfaceType: 'coding_tool'
1415
+ });
1416
+ const archived = core.archiveAiProfile({
1417
+ workspaceId: 'workspace-1',
1418
+ profileId: resolved.profile.id,
1419
+ archivedBy: 'owner-1',
1420
+ reason: 'manual_archive'
1421
+ });
1422
+ const replayedActive = core.upsertAiProfileForSync({
1423
+ ...archived,
1424
+ archivedAt: null,
1425
+ archivedBy: null,
1426
+ archivedReason: null,
1427
+ mergedIntoProfileId: null,
1428
+ updatedAt: archived.updatedAt
1429
+ });
1430
+ expect(replayedActive.archivedAt).toBeTruthy();
1431
+ expect(core.listAiProfiles('workspace-1')).toEqual([]);
1432
+ const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
1433
+ .find((profile) => profile.id === archived.id);
1434
+ expect(stored?.archivedAt).toBe(archived.archivedAt);
1435
+ expect(stored?.archivedReason).toBe('manual_archive');
1436
+ });
1437
+ it('accepts newer synced seat scope for the same AI profile id', () => {
1438
+ const projectRoot = createProjectRoot();
1439
+ createdRoots.push(projectRoot);
1440
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1441
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1442
+ const contaminated = core.resolveAiProfile({
1443
+ workspaceId: 'workspace-1',
1444
+ name: 'Codex',
1445
+ surfaceType: 'coding_tool'
1446
+ }, { trustedSeatScope: 'cloud_metered' });
1447
+ const repairedAt = new Date(Date.parse(contaminated.profile.updatedAt) + 1000).toISOString();
1448
+ const repaired = core.upsertAiProfileForSync({
1449
+ ...contaminated.profile,
1450
+ seatScope: 'local_unmetered',
1451
+ updatedAt: repairedAt,
1452
+ lastActiveAt: repairedAt
1453
+ });
1454
+ expect(repaired.id).toBe(contaminated.profile.id);
1455
+ expect(repaired.seatScope).toBe('local_unmetered');
1456
+ const staleReplay = core.upsertAiProfileForSync({
1457
+ ...contaminated.profile,
1458
+ seatScope: 'cloud_metered',
1459
+ updatedAt: contaminated.profile.updatedAt
1460
+ });
1461
+ expect(staleReplay.seatScope).toBe('local_unmetered');
1462
+ });
1463
+ it('keeps an archived AI profile archived when sync replays a newer metadata update with an older lifecycle timestamp', () => {
1464
+ const projectRoot = createProjectRoot();
1465
+ createdRoots.push(projectRoot);
1466
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1467
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1468
+ const resolved = core.resolveAiProfile({
1469
+ workspaceId: 'workspace-1',
1470
+ name: 'Antigravity/jCodeMunch',
1471
+ surfaceType: 'coding_tool'
1472
+ });
1473
+ const archived = core.archiveAiProfile({
1474
+ workspaceId: 'workspace-1',
1475
+ profileId: resolved.profile.id,
1476
+ archivedBy: 'owner-1',
1477
+ reason: 'manual_archive'
1478
+ });
1479
+ const replayedActive = core.upsertAiProfileForSync({
1480
+ ...archived,
1481
+ description: 'Cloud-side metadata changed after the archive write was missed.',
1482
+ archivedAt: null,
1483
+ archivedBy: null,
1484
+ archivedReason: null,
1485
+ mergedIntoProfileId: null,
1486
+ rosterStateUpdatedAt: resolved.profile.rosterStateUpdatedAt,
1487
+ updatedAt: '2099-03-22T10:30:00.000Z'
1488
+ });
1489
+ expect(replayedActive.archivedAt).toBeTruthy();
1490
+ const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
1491
+ .find((profile) => profile.id === archived.id);
1492
+ expect(stored?.archivedAt).toBe(archived.archivedAt);
1493
+ expect(stored?.description).toBe('Cloud-side metadata changed after the archive write was missed.');
1494
+ });
1495
+ it('repairs stale archived roster lifecycle timestamps before applying active sync snapshots', () => {
1496
+ const projectRoot = createProjectRoot();
1497
+ createdRoots.push(projectRoot);
1498
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1499
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1500
+ const resolved = core.resolveAiProfile({
1501
+ workspaceId: 'workspace-1',
1502
+ name: 'Codex',
1503
+ surfaceType: 'coding_tool'
1504
+ });
1505
+ const archived = core.archiveAiProfile({
1506
+ workspaceId: 'workspace-1',
1507
+ profileId: resolved.profile.id,
1508
+ archivedBy: 'owner-1',
1509
+ reason: 'manual_archive'
1510
+ });
1511
+ core.db.prepare(`
1512
+ UPDATE ai_profiles
1513
+ SET roster_state_updated_at = ?
1514
+ WHERE tenant_id = ? AND id = ?
1515
+ `).run(resolved.profile.createdAt, 'default', archived.id);
1516
+ const replayedActive = core.upsertAiProfileForSync({
1517
+ ...archived,
1518
+ description: 'Cloud active snapshot with newer metadata but no lifecycle marker.',
1519
+ archivedAt: null,
1520
+ archivedBy: null,
1521
+ archivedReason: null,
1522
+ mergedIntoProfileId: null,
1523
+ rosterStateUpdatedAt: null,
1524
+ updatedAt: '2099-03-22T10:30:00.000Z'
1525
+ });
1526
+ expect(replayedActive.archivedAt).toBe(archived.archivedAt);
1527
+ expect(replayedActive.rosterStateUpdatedAt).toBe(archived.archivedAt);
1528
+ const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
1529
+ .find((profile) => profile.id === archived.id);
1530
+ expect(stored?.archivedAt).toBe(archived.archivedAt);
1531
+ expect(stored?.description).toBe('Cloud active snapshot with newer metadata but no lifecycle marker.');
1532
+ expect(core.listAiProfiles('workspace-1')).toEqual([]);
1533
+ });
1534
+ it('allows sync to reactivate an archived AI profile when lifecycle timestamp is newer', () => {
1535
+ const projectRoot = createProjectRoot();
1536
+ createdRoots.push(projectRoot);
1537
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1538
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1539
+ const resolved = core.resolveAiProfile({
1540
+ workspaceId: 'workspace-1',
1541
+ name: 'Claude Code',
1542
+ surfaceType: 'coding_tool'
1543
+ });
1544
+ const archived = core.archiveAiProfile({
1545
+ workspaceId: 'workspace-1',
1546
+ profileId: resolved.profile.id,
1547
+ archivedBy: 'owner-1',
1548
+ reason: 'manual_archive'
1549
+ });
1550
+ const reactivated = core.upsertAiProfileForSync({
1551
+ ...archived,
1552
+ archivedAt: null,
1553
+ archivedBy: null,
1554
+ archivedReason: null,
1555
+ mergedIntoProfileId: null,
1556
+ rosterStateUpdatedAt: '2099-03-22T10:45:00.000Z',
1557
+ updatedAt: '2099-03-22T10:45:00.000Z'
1558
+ });
1559
+ expect(reactivated.archivedAt).toBeNull();
1560
+ expect(core.listAiProfiles('workspace-1').map((profile) => profile.id)).toEqual([resolved.profile.id]);
1561
+ });
1562
+ it('reactivates an archived AI profile when resolveAiProfile is called explicitly', () => {
1563
+ const projectRoot = createProjectRoot();
1564
+ createdRoots.push(projectRoot);
1565
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1566
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1567
+ const initial = core.resolveAiProfile({
1568
+ workspaceId: 'workspace-1',
1569
+ name: 'Codex',
1570
+ surfaceType: 'coding_tool',
1571
+ }, { trustedSeatScope: 'cloud_metered' });
1572
+ core.archiveAiProfile({
1573
+ workspaceId: 'workspace-1',
1574
+ profileId: initial.profile.id,
1575
+ archivedBy: 'owner-1',
1576
+ reason: 'manual_archive'
1577
+ });
1578
+ const reactivatedByName = core.resolveAiProfile({
1579
+ workspaceId: 'workspace-1',
1580
+ name: 'Codex',
1581
+ surfaceType: 'coding_tool'
1582
+ }, { trustedSeatScope: 'cloud_metered' });
1583
+ expect(reactivatedByName.created).toBe(false);
1584
+ expect(reactivatedByName.profile.id).toBe(initial.profile.id);
1585
+ expect(reactivatedByName.profile.archivedAt).toBeNull();
1586
+ expect(reactivatedByName.profile.archivedReason).toBeNull();
1587
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1588
+ core.archiveAiProfile({
1589
+ workspaceId: 'workspace-1',
1590
+ profileId: initial.profile.id,
1591
+ archivedBy: 'owner-1',
1592
+ reason: 'manual_archive'
1593
+ });
1594
+ const reactivatedByToken = core.resolveAiProfile({
1595
+ workspaceId: 'workspace-1',
1596
+ name: 'Codex',
1597
+ profileToken: initial.profile.profileToken,
1598
+ surfaceType: 'coding_tool'
1599
+ }, { trustedSeatScope: 'cloud_metered' });
1600
+ expect(reactivatedByToken.created).toBe(false);
1601
+ expect(reactivatedByToken.profile.id).toBe(initial.profile.id);
1602
+ expect(reactivatedByToken.profile.profileToken).toBe(initial.profile.profileToken);
1603
+ expect(reactivatedByToken.profile.archivedAt).toBeNull();
1604
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1605
+ });
1606
+ it('rejects archiving an AI profile that still owns open tasks', () => {
1607
+ const projectRoot = createProjectRoot();
1608
+ createdRoots.push(projectRoot);
1609
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1610
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1611
+ const profile = core.resolveAiProfile({
1612
+ workspaceId: 'workspace-1',
1613
+ name: 'Antigravity',
1614
+ surfaceType: 'agent'
1615
+ });
1616
+ core.createTask({
1617
+ title: 'Still assigned',
1618
+ status: 'in-progress',
1619
+ assignee: profile.profile.id
1620
+ }, { workspaceId: 'workspace-1' });
1621
+ try {
1622
+ core.archiveAiProfile({
1623
+ workspaceId: 'workspace-1',
1624
+ profileId: profile.profile.id,
1625
+ archivedBy: 'owner-1',
1626
+ reason: 'manual_archive'
1627
+ });
1628
+ expect.unreachable('Expected archiveAiProfile to reject open-task ownership');
1629
+ }
1630
+ catch (error) {
1631
+ expect(error.code).toBe('AI_PROFILE_HAS_OPEN_TASKS');
1632
+ expect(error.statusCode).toBe(409);
1633
+ expect(error.details).toEqual({
1634
+ workspaceId: 'workspace-1',
1635
+ profileId: profile.profile.id,
1636
+ openTaskCount: 1
1637
+ });
1638
+ }
1639
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1640
+ expect(core.listAiProfiles('workspace-1', { includeArchived: true })[0]?.archivedAt).toBeNull();
1641
+ });
606
1642
  it('canonicalizes AI names to profile ids when creating tasks and comments', () => {
607
1643
  const projectRoot = createProjectRoot();
608
1644
  createdRoots.push(projectRoot);
@@ -628,6 +1664,145 @@ describe('TaskforceCore ai profiles', () => {
628
1664
  expect(fetched?.assigneeActor?.label).toBe('ChatGPT');
629
1665
  expect(fetched?.comments?.[0]?.actor?.label).toBe('ChatGPT');
630
1666
  });
1667
+ it('keeps ambiguous legacy AI comment aliases unresolved instead of rebinding them', () => {
1668
+ const projectRoot = createProjectRoot();
1669
+ createdRoots.push(projectRoot);
1670
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1671
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1672
+ const cloud = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1673
+ const local = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
1674
+ const task = core.createTask({
1675
+ title: 'Ambiguous legacy comment alias',
1676
+ comments: [{
1677
+ id: 'legacy-codex',
1678
+ author: 'codex',
1679
+ text: 'Leave me unresolved',
1680
+ timestamp: '2026-03-22T10:00:00.000Z'
1681
+ }]
1682
+ }, { workspaceId: 'workspace-1' });
1683
+ const fetched = core.getTask(task.id, 'workspace-1');
1684
+ expect(fetched?.comments?.[0]?.author).toBe('codex');
1685
+ expect(fetched?.comments?.[0]?.actor).toEqual(expect.objectContaining({
1686
+ id: 'codex',
1687
+ kind: 'ai',
1688
+ label: 'Codex',
1689
+ meta: expect.objectContaining({ unresolved: true })
1690
+ }));
1691
+ expect(fetched?.comments?.[0]?.actor?.id).not.toBe(cloud.profile.id);
1692
+ expect(fetched?.comments?.[0]?.actor?.id).not.toBe(local.profile.id);
1693
+ });
1694
+ it('repairs legacy comments from submittedAuthor under the v2 migration even after v1 completed', () => {
1695
+ const projectRoot = createProjectRoot();
1696
+ createdRoots.push(projectRoot);
1697
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1698
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1699
+ const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1700
+ const task = firstCore.createTask({
1701
+ title: 'Repair legacy submitted author',
1702
+ comments: [{
1703
+ id: 'legacy-agent',
1704
+ author: 'agent',
1705
+ submittedAuthor: 'Claude',
1706
+ text: 'Legacy comment should repair forward',
1707
+ timestamp: '2026-03-22T10:00:00.000Z'
1708
+ }]
1709
+ }, { workspaceId: 'workspace-1' });
1710
+ const firstDb = firstCore.db;
1711
+ const originalTaskWatermark = '2026-03-22T10:00:00.000Z';
1712
+ firstDb.prepare(`
1713
+ UPDATE tasks
1714
+ SET updated_at = ?
1715
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1716
+ `).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
1717
+ const completedAt = '2026-03-22T11:00:00.000Z';
1718
+ firstDb.prepare(`
1719
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
1720
+ VALUES (?, ?, ?, ?)
1721
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
1722
+ payload = excluded.payload,
1723
+ updated_at = excluded.updated_at
1724
+ `).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
1725
+ firstDb.prepare(`
1726
+ DELETE FROM system_settings
1727
+ WHERE tenant_id = ? AND settings_key = ?
1728
+ `).run('default', 'migration.actor_refs.canonicalize_comments.v2');
1729
+ firstCore.close();
1730
+ const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1731
+ const fetched = secondCore.getTask(task.id, 'workspace-1');
1732
+ expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
1733
+ expect(fetched?.comments?.[0]?.submittedAuthor).toBe('Claude');
1734
+ expect(fetched?.comments?.[0]?.actor?.id).toBe(resolved.profile.id);
1735
+ expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
1736
+ const syncRows = secondCore.listTasksForSync('workspace-1', {
1737
+ watermark: originalTaskWatermark,
1738
+ id: task.id,
1739
+ }, 10);
1740
+ expect(syncRows).toHaveLength(1);
1741
+ expect(syncRows[0]?.task?.id).toBe(task.id);
1742
+ expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
1743
+ const secondDb = secondCore.db;
1744
+ const migrationRow = secondDb.prepare(`
1745
+ SELECT payload
1746
+ FROM system_settings
1747
+ WHERE tenant_id = ? AND settings_key = ?
1748
+ LIMIT 1
1749
+ `).get('default', 'migration.actor_refs.canonicalize_comments.v2');
1750
+ expect(JSON.parse(String(migrationRow?.payload || '{}'))).toEqual(expect.objectContaining({
1751
+ repairedCount: 1,
1752
+ ambiguousCount: 0,
1753
+ skippedCount: expect.any(Number)
1754
+ }));
1755
+ secondCore.close();
1756
+ });
1757
+ it('keeps repaired legacy comment sync watermarks monotonic when a task watermark is already ahead of local now', () => {
1758
+ const projectRoot = createProjectRoot();
1759
+ createdRoots.push(projectRoot);
1760
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1761
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1762
+ const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1763
+ const task = firstCore.createTask({
1764
+ title: 'Repair legacy submitted author with future watermark',
1765
+ comments: [{
1766
+ id: 'legacy-agent-future',
1767
+ author: 'agent',
1768
+ submittedAuthor: 'Claude',
1769
+ text: 'Legacy comment should repair without moving sync backwards',
1770
+ timestamp: '2026-03-22T10:00:00.000Z'
1771
+ }]
1772
+ }, { workspaceId: 'workspace-1' });
1773
+ const firstDb = firstCore.db;
1774
+ const originalTaskWatermark = '2099-03-22T10:00:00.000Z';
1775
+ firstDb.prepare(`
1776
+ UPDATE tasks
1777
+ SET updated_at = ?
1778
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1779
+ `).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
1780
+ const completedAt = '2026-03-22T11:00:00.000Z';
1781
+ firstDb.prepare(`
1782
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
1783
+ VALUES (?, ?, ?, ?)
1784
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
1785
+ payload = excluded.payload,
1786
+ updated_at = excluded.updated_at
1787
+ `).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
1788
+ firstDb.prepare(`
1789
+ DELETE FROM system_settings
1790
+ WHERE tenant_id = ? AND settings_key = ?
1791
+ `).run('default', 'migration.actor_refs.canonicalize_comments.v2');
1792
+ firstCore.close();
1793
+ const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1794
+ const fetched = secondCore.getTask(task.id, 'workspace-1');
1795
+ expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
1796
+ expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
1797
+ const syncRows = secondCore.listTasksForSync('workspace-1', {
1798
+ watermark: originalTaskWatermark,
1799
+ id: task.id,
1800
+ }, 10);
1801
+ expect(syncRows).toHaveLength(1);
1802
+ expect(syncRows[0]?.task?.id).toBe(task.id);
1803
+ expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
1804
+ secondCore.close();
1805
+ });
631
1806
  it('repairs legacy actor refs and merges duplicate ai profiles on startup', () => {
632
1807
  const projectRoot = createProjectRoot();
633
1808
  createdRoots.push(projectRoot);
@@ -658,11 +1833,41 @@ describe('TaskforceCore ai profiles', () => {
658
1833
  const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
659
1834
  const reloaded = reloadedCore.getTask(task.id, 'workspace-1');
660
1835
  const profiles = reloadedCore.listAiProfiles('workspace-1');
661
- expect(profiles.map((profile) => profile.name)).toEqual(['Claude', 'Antigravity']);
1836
+ expect(profiles.map((profile) => profile.name)).toEqual(['Antigravity', 'Claude']);
662
1837
  const survivingAntigravityId = profiles.find((profile) => profile.name === 'Antigravity')?.id;
663
1838
  expect(reloaded?.createdBy).toBe(claude.profile.id);
664
1839
  expect(reloaded?.assignee).toBe(survivingAntigravityId);
665
1840
  expect(reloaded?.comments?.[0]?.author).toBe(claude.profile.id);
666
1841
  expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
667
1842
  });
1843
+ it('does not repair ambiguous legacy comment authors on startup', () => {
1844
+ const projectRoot = createProjectRoot();
1845
+ createdRoots.push(projectRoot);
1846
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1847
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1848
+ const firstDb = firstCore.db;
1849
+ const first = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1850
+ const second = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
1851
+ const task = firstCore.createTask({
1852
+ title: 'Ambiguous legacy actor refs',
1853
+ comments: [{
1854
+ id: 'legacy-comment',
1855
+ author: first.profile.id,
1856
+ text: 'Leave ambiguous alias untouched.',
1857
+ timestamp: '2026-03-22T10:00:00.000Z'
1858
+ }]
1859
+ }, { workspaceId: 'workspace-1' });
1860
+ firstDb.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
1861
+ .run('default', second.profile.id);
1862
+ firstDb.prepare(`UPDATE comments SET author = 'claude', submitted_author = 'claude' WHERE tenant_id = ? AND task_id = ? AND id = ?`)
1863
+ .run('default', task.id, 'legacy-comment');
1864
+ firstDb.prepare(`DELETE FROM system_settings WHERE tenant_id = ? AND settings_key = ?`)
1865
+ .run('default', 'migration.actor_refs.canonicalize.v1');
1866
+ const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1867
+ const reloaded = reloadedCore.getTask(task.id, 'workspace-1');
1868
+ expect(reloaded?.comments?.[0]?.author).toBe('claude');
1869
+ expect(reloaded?.comments?.[0]?.submittedAuthor).toBe('claude');
1870
+ expect(reloaded?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
1871
+ expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
1872
+ });
668
1873
  });