@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
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
+ import { createHash, randomBytes } from 'crypto';
5
6
  import { createTaskforceMcpServer } from './runtime';
6
7
  import { MCP_TOOL_REGISTRY } from './toolRegistry';
7
8
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce';
@@ -17,6 +18,7 @@ function createRuntime(projectRoot, runtimeMode, options) {
17
18
  ? {
18
19
  scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
19
20
  userId: options?.authUserId || 'owner-1',
21
+ tokenId: options?.authTokenId || null,
20
22
  actorType: 'oauth',
21
23
  aiProfileToken: options?.authAiProfileToken || null,
22
24
  aiProfileName: options?.authAiProfileName || null,
@@ -160,6 +162,34 @@ function parseToolJson(result) {
160
162
  expect(text).toBeTruthy();
161
163
  return JSON.parse(String(text));
162
164
  }
165
+ function createOauthGrantToken(core, userId, workspaceId) {
166
+ const redirectUri = 'https://chat.openai.com/aip/callback';
167
+ const client = core.registerMcpOAuthClient({
168
+ provider: 'chatgpt',
169
+ clientName: 'ChatGPT Connector',
170
+ redirectUris: [redirectUri],
171
+ });
172
+ const verifier = randomBytes(32).toString('base64url');
173
+ const codeChallenge = createHash('sha256').update(verifier).digest('base64url');
174
+ const issued = core.issueMcpOAuthAuthorizationCode({
175
+ provider: 'chatgpt',
176
+ clientId: client.id,
177
+ userId,
178
+ workspaceId,
179
+ scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
180
+ redirectUri,
181
+ codeChallenge,
182
+ codeChallengeMethod: 'S256',
183
+ });
184
+ const exchanged = core.exchangeMcpOAuthAuthorizationCode({
185
+ provider: 'chatgpt',
186
+ clientId: client.id,
187
+ code: issued.code,
188
+ redirectUri,
189
+ codeVerifier: verifier,
190
+ });
191
+ return exchanged.accessToken;
192
+ }
163
193
  function seedAnnotatedAttachmentFixture(projectRoot) {
164
194
  const core = new TaskforceCore({
165
195
  projectRoot,
@@ -323,6 +353,7 @@ describe('Taskforce MCP tool descriptions', () => {
323
353
  const getTask = getTool(result, 'get_task');
324
354
  const getPaths = getTool(result, 'get_paths');
325
355
  const searchImages = getTool(result, 'search_images');
356
+ const getImage = getTool(result, 'get_image');
326
357
  const getDocument = getTool(result, 'get_document');
327
358
  const searchDocuments = getTool(result, 'search_documents');
328
359
  const listAnnotatedAttachmentSessions = getTool(result, 'get_annotated_attachment_sessions');
@@ -341,10 +372,13 @@ describe('Taskforce MCP tool descriptions', () => {
341
372
  expect(getTask.description).toContain('Do not use this for image references like I-123 or document references like D-123.');
342
373
  expect(getPaths.description).toContain('Get project and Taskforce storage paths.');
343
374
  expect(searchImages.description).toContain('I-123 reference');
375
+ expect(getImage.description).toContain('placement, or visual correction feedback');
344
376
  expect(getDocument.description).toContain('D-123');
345
377
  expect(searchDocuments.description).toContain('D-123 reference');
346
378
  expect(listAnnotatedAttachmentSessions.description).toContain('saved annotation sessions');
379
+ expect(listAnnotatedAttachmentSessions.description).toContain('inspect the saved session payload next');
347
380
  expect(readAnnotatedAttachmentPayload.description).toContain('derived crop hints');
381
+ expect(readAnnotatedAttachmentPayload.description).toContain('authoritative instruction source');
348
382
  expect(createAnnotatedAttachmentSession.description).toContain('before adding markers');
349
383
  expect(deleteAnnotatedAttachmentSession.description).toContain('rather than deleting markers one by one');
350
384
  expect(reviewImage.description).toContain('first-pass visual review');
@@ -381,6 +415,14 @@ describe('Taskforce MCP tool descriptions', () => {
381
415
  const getTaskAttachments = getTool(result, 'get_task_attachments');
382
416
  const readTaskAttachment = getTool(result, 'read_task_attachment');
383
417
  const deleteTaskAttachment = getTool(result, 'delete_task_attachment');
418
+ const getWorkstreamAttachments = getTool(result, 'get_workstream_attachments');
419
+ const readWorkstreamAttachment = getTool(result, 'read_workstream_attachment');
420
+ const saveWorkstreamAttachment = getTool(result, 'save_workstream_attachment');
421
+ const deleteWorkstreamAttachment = getTool(result, 'delete_workstream_attachment');
422
+ const getInitiativeAttachments = getTool(result, 'get_initiative_attachments');
423
+ const readInitiativeAttachment = getTool(result, 'read_initiative_attachment');
424
+ const saveInitiativeAttachment = getTool(result, 'save_initiative_attachment');
425
+ const deleteInitiativeAttachment = getTool(result, 'delete_initiative_attachment');
384
426
  const deleteAnnotatedAttachmentSession = getTool(result, 'delete_annotated_attachment_session');
385
427
  const archiveTask = getTool(result, 'archive_task');
386
428
  const startTask = getTool(result, 'start_task');
@@ -399,9 +441,14 @@ describe('Taskforce MCP tool descriptions', () => {
399
441
  expect(getTask.description).toContain('Do not fetch attachment path URLs directly');
400
442
  expect(getTask.description).toContain('Do not use this for image references like I-123 or document references like D-123.');
401
443
  expect(searchImages.description).toContain('get_image');
444
+ expect(getImage.inputSchema?.properties?.id).toBeDefined();
445
+ expect(getImage.inputSchema?.properties?.imageId).toBeDefined();
402
446
  expect(getImage.description).toContain('Use this first when the user references an image');
447
+ expect(getImage.description).toContain('read the saved annotation payload before making changes');
403
448
  expect(searchDocuments.description).toContain('get_document');
404
449
  expect(searchDocuments.description).toContain('read_document_content');
450
+ expect(getDocument.inputSchema?.properties?.id).toBeDefined();
451
+ expect(getDocument.inputSchema?.properties?.documentId).toBeDefined();
405
452
  expect(getDocument.description).toContain('Use this first when the user references a document');
406
453
  expect(getDocument.description).toContain('read_document_content');
407
454
  expect(getCurrentProfile.description).toContain('current profile id');
@@ -417,9 +464,24 @@ describe('Taskforce MCP tool descriptions', () => {
417
464
  expect(deleteAnnotatedAttachmentSession.description).toContain('whole session was created by mistake');
418
465
  expect(getTaskAttachments.description).toContain('use read_task_attachment for file contents');
419
466
  expect(getTaskAttachments.description).toContain('prefer get_document and read_document_content');
467
+ expect(getTaskAttachments.inputSchema?.properties?.taskId).toBeDefined();
468
+ expect(saveTaskAttachment.description).toContain('staged remote uploads');
469
+ expect(saveTaskAttachment.inputSchema?.properties?.uploadedPath).toBeDefined();
470
+ expect(saveTaskAttachment.inputSchema?.properties?.uploadDraftId).toBeDefined();
471
+ expect(saveTaskAttachment.inputSchema?.properties?.mimeType).toBeDefined();
472
+ expect(saveTaskAttachment.inputSchema?.properties?.sizeBytes).toBeDefined();
473
+ expect(getTaskAttachments.inputSchema?.properties?.id).toBeDefined();
420
474
  expect(readTaskAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
421
475
  expect(readTaskAttachment.description).toContain('canonical document');
422
476
  expect(deleteTaskAttachment.description).toContain('Use get_task_attachments first');
477
+ expect(getWorkstreamAttachments.description).toContain('use read_workstream_attachment for file contents');
478
+ expect(readWorkstreamAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
479
+ expect(saveWorkstreamAttachment.description).toContain('attach it to a workstream');
480
+ expect(deleteWorkstreamAttachment.description).toContain('Use get_workstream_attachments first');
481
+ expect(getInitiativeAttachments.description).toContain('use read_initiative_attachment for file contents');
482
+ expect(readInitiativeAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
483
+ expect(saveInitiativeAttachment.description).toContain('attach it to an initiative');
484
+ expect(deleteInitiativeAttachment.description).toContain('Use get_initiative_attachments first');
423
485
  expect(archiveTask.description).toContain('active tasks may still use status=done for final lookover');
424
486
  expect(startTask.description).toContain('preferred AI start-of-task workflow');
425
487
  expect(startTask.description).toContain('will not steal a task from a different assignee');
@@ -428,6 +490,8 @@ describe('Taskforce MCP tool descriptions', () => {
428
490
  expect(finishTask.description).toContain('outcome=\'review\' when human validation or approval is needed');
429
491
  expect(finishTask.description).toContain('either finish it with this tool or explain why it remains open');
430
492
  expect(finishTask.description).toContain('This tool never archives automatically');
493
+ expect(reviewDocument.inputSchema?.properties?.prompt).toBeDefined();
494
+ expect(reviewDocument.inputSchema?.properties?.comments).toBeDefined();
431
495
  expect(updateTask.description).toContain('Use start_task when beginning active work on a tracked task');
432
496
  expect(updateTask.description).toContain('prefer finish_task for the final completion or review handoff');
433
497
  expect(assignTask.description).toContain('For assigning 3 or more tasks at once, use bulk_assign_tasks instead.');
@@ -441,21 +505,25 @@ describe('Taskforce MCP tool descriptions', () => {
441
505
  const saveTaskAttachmentSchema = saveTaskAttachment.inputSchema;
442
506
  expect(saveTaskAttachmentSchema.properties.content.description).toContain('use this for AI-authored markdown/text attachments');
443
507
  expect(saveTaskAttachmentSchema.properties.sourcePath.description).toContain('approved agent workspace root');
508
+ expect(saveTaskAttachmentSchema.properties.profileToken.description).toContain('does not preserve session identity');
444
509
  expect(resolveProfile.description).toContain('AI collaborator profile');
510
+ expect(resolveProfile.description).toContain('private agent/client state');
511
+ expect(resolveProfile.description).toContain('Never write profileToken values');
445
512
  expect(resolveProfile.description).toContain('Update semantics');
446
513
  expect(resolveProfile.description).toContain('empty strings are ignored rather than clearing');
447
514
  expect(resolveProfile.inputSchema?.properties?.surfaceType).toBeDefined();
448
515
  expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('self-declared AI surface type');
449
516
  expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('agent for a persistent named AI collaborator in Taskforce');
450
- expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('coding_tool for the coding surface or terminal tool itself');
451
- expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('If you are unsure and you are registering a named collaborator');
517
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('cli for terminal-native coding agents');
518
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('coding_tool for coding surfaces that are not specifically CLI or IDE-native');
519
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('If you are unsure or none of those fit');
452
520
  const surfaceTypeSchema = resolveProfile.inputSchema?.properties?.surfaceType;
453
521
  expect(surfaceTypeSchema?.anyOf?.[0]?.enum).toEqual([
454
522
  'chat_app',
455
523
  'ide',
524
+ 'cli',
456
525
  'coding_tool',
457
- 'agent',
458
- 'other'
526
+ 'agent'
459
527
  ]);
460
528
  }
461
529
  finally {
@@ -636,6 +704,32 @@ describe('Taskforce MCP tool descriptions', () => {
636
704
  })
637
705
  }));
638
706
  });
707
+ it('returns structured transient MCP timeout details for recoverable read failures', async () => {
708
+ const { buildRecoverableToolErrorResult } = await import('./runtime');
709
+ const result = buildRecoverableToolErrorResult(new Error('Postgres worker timed out while reading workstream attachments'), 'req-timeout-1');
710
+ const payload = parseToolJson(result);
711
+ expect(payload.success).toBe(false);
712
+ expect(payload.code).toBe('MCP_BACKEND_TIMEOUT');
713
+ expect(payload.kind).toBe('backend_timeout');
714
+ expect(payload.retryable).toBe(true);
715
+ expect(payload.requestId).toBe('req-timeout-1');
716
+ expect(payload.retryHint).toContain('backend');
717
+ expect(payload.details).toEqual(expect.objectContaining({
718
+ kind: 'backend_timeout',
719
+ retryable: true
720
+ }));
721
+ });
722
+ it('returns structured transient MCP transport details for recoverable transport failures', async () => {
723
+ const { buildRecoverableToolErrorResult } = await import('./runtime');
724
+ const result = buildRecoverableToolErrorResult(new Error('fetch failed'), 'req-fetch-1');
725
+ const payload = parseToolJson(result);
726
+ expect(payload.success).toBe(false);
727
+ expect(payload.code).toBe('MCP_TRANSPORT_FAILURE');
728
+ expect(payload.kind).toBe('transport_failure');
729
+ expect(payload.retryable).toBe(true);
730
+ expect(payload.requestId).toBe('req-fetch-1');
731
+ expect(payload.retryHint).toContain('transport');
732
+ });
639
733
  it('blocks mutating MCP tools until resolve_profile is called', async () => {
640
734
  const projectRoot = createProjectRoot();
641
735
  try {
@@ -733,7 +827,7 @@ describe('Taskforce MCP tool descriptions', () => {
733
827
  fs.rmSync(projectRoot, { recursive: true, force: true });
734
828
  }
735
829
  });
736
- it('updates and persists metadata when cloud auto-bind reuses an existing exact-match profile', async () => {
830
+ it('creates a separate cloud profile and persists metadata when a same-name local profile already exists', async () => {
737
831
  const projectRoot = createProjectRoot();
738
832
  try {
739
833
  const initial = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
@@ -754,29 +848,36 @@ describe('Taskforce MCP tool descriptions', () => {
754
848
  authAiProfileSurfaceType: 'coding_tool'
755
849
  }));
756
850
  expect(currentProfile.profile).toEqual(expect.objectContaining({
757
- id: initial.profile.id,
758
851
  name: 'Automation Codex',
759
852
  description: 'OpenAI Codex coding agent connected through MCP.',
760
853
  role: 'Coding Agent',
761
854
  provider: 'OpenAI',
762
855
  model: 'Codex',
763
- surfaceType: 'coding_tool'
856
+ surfaceType: 'coding_tool',
857
+ seatScope: 'cloud_metered'
764
858
  }));
859
+ expect(currentProfile.profile.id).not.toBe(initial.profile.id);
765
860
  const reloadedCore = new TaskforceCore({
766
861
  projectRoot,
767
862
  storagePath: path.join(projectRoot, '.taskforce')
768
863
  });
769
- expect(reloadedCore.listAiProfiles('workspace-1')).toEqual([
864
+ expect(reloadedCore.listAiProfiles('workspace-1')).toEqual(expect.arrayContaining([
770
865
  expect.objectContaining({
771
866
  id: initial.profile.id,
772
867
  name: 'Automation Codex',
868
+ seatScope: 'local_unmetered'
869
+ }),
870
+ expect.objectContaining({
871
+ id: currentProfile.profile.id,
872
+ name: 'Automation Codex',
773
873
  description: 'OpenAI Codex coding agent connected through MCP.',
774
874
  role: 'Coding Agent',
775
875
  provider: 'OpenAI',
776
876
  model: 'Codex',
777
- surfaceType: 'coding_tool'
877
+ surfaceType: 'coding_tool',
878
+ seatScope: 'cloud_metered'
778
879
  })
779
- ]);
880
+ ]));
780
881
  reloadedCore.close();
781
882
  }
782
883
  finally {
@@ -801,17 +902,140 @@ describe('Taskforce MCP tool descriptions', () => {
801
902
  id: resolved.profile.id,
802
903
  username: resolved.profile.username
803
904
  }));
905
+ expect(created.createdByActor.meta).not.toHaveProperty('profileToken');
804
906
  }
805
907
  finally {
806
908
  fs.rmSync(projectRoot, { recursive: true, force: true });
807
909
  }
808
910
  });
809
- it('allows mutating cloud MCP tools when the connection is configured with an AI profile token', async () => {
911
+ it('persists resolved OAuth connector identity for later stateless writes', async () => {
912
+ const projectRoot = createProjectRoot();
913
+ try {
914
+ const seedCore = new TaskforceCore({
915
+ projectRoot,
916
+ storagePath: path.join(projectRoot, '.taskforce')
917
+ });
918
+ Object.defineProperty(seedCore, 'getAccountAccessStatus', {
919
+ configurable: true,
920
+ value: () => ({
921
+ gate: 'ok',
922
+ hasEntitlement: true,
923
+ canAccessApp: true,
924
+ canAccessPlans: true,
925
+ planSelectionRequired: false,
926
+ message: null,
927
+ entitlement: null
928
+ })
929
+ });
930
+ const task = seedCore.createTask({ title: 'OAuth connector identity task' }, { workspaceId: 'workspace-1' });
931
+ const accessToken = createOauthGrantToken(seedCore, 'owner-1', 'workspace-1');
932
+ const initialAuth = seedCore.authenticateMcpOAuthAccessToken(accessToken);
933
+ expect(initialAuth?.grantId).toBeTruthy();
934
+ expect(initialAuth?.aiProfileToken).toBeFalsy();
935
+ seedCore.close();
936
+ const resolveRuntime = createRuntime(projectRoot, 'cloud', {
937
+ authTokenId: initialAuth.grantId,
938
+ });
939
+ const resolved = parseToolJson(await callToolWithRuntime(resolveRuntime, 'resolve_profile', {
940
+ name: 'Codex Connector',
941
+ icon: 'Code',
942
+ color: '#0ea5e9',
943
+ }, { resolveProfile: false }));
944
+ const authCore = new TaskforceCore({
945
+ projectRoot,
946
+ storagePath: path.join(projectRoot, '.taskforce')
947
+ });
948
+ Object.defineProperty(authCore, 'getAccountAccessStatus', {
949
+ configurable: true,
950
+ value: () => ({
951
+ gate: 'ok',
952
+ hasEntitlement: true,
953
+ canAccessApp: true,
954
+ canAccessPlans: true,
955
+ planSelectionRequired: false,
956
+ message: null,
957
+ entitlement: null
958
+ })
959
+ });
960
+ const persistedAuth = authCore.authenticateMcpOAuthAccessToken(accessToken);
961
+ expect(persistedAuth).toEqual(expect.objectContaining({
962
+ grantId: initialAuth.grantId,
963
+ aiProfileId: resolved.profile.id,
964
+ aiProfileToken: resolved.profile.profileToken,
965
+ aiProfileName: 'Codex Connector',
966
+ }));
967
+ authCore.close();
968
+ const writeRuntime = createRuntime(projectRoot, 'cloud', {
969
+ authTokenId: persistedAuth.grantId,
970
+ authAiProfileToken: persistedAuth.aiProfileToken || undefined,
971
+ authAiProfileName: persistedAuth.aiProfileName || undefined,
972
+ });
973
+ const result = await callToolWithRuntime(writeRuntime, 'add_comment', {
974
+ id: task.id,
975
+ text: 'Stateless connector write after profile resolution',
976
+ }, { resolveProfile: false });
977
+ expect(result.isError).toBeFalsy();
978
+ expect(String(result.content?.[0]?.text || '')).toContain('Comment added');
979
+ }
980
+ finally {
981
+ fs.rmSync(projectRoot, { recursive: true, force: true });
982
+ }
983
+ });
984
+ it('allows save_task_attachment in a fresh runtime when profileToken is passed', async () => {
810
985
  const projectRoot = createProjectRoot();
811
986
  try {
987
+ const core = new TaskforceCore({
988
+ projectRoot,
989
+ storagePath: path.join(projectRoot, '.taskforce')
990
+ });
991
+ const task = core.createTask({
992
+ title: 'Stateless attachment token write'
993
+ }, {
994
+ workspaceId: 'workspace-1'
995
+ });
996
+ core.close();
812
997
  const resolved = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
813
- name: 'Automation Codex'
998
+ name: 'Codex',
999
+ icon: 'Bot',
1000
+ color: '#ef6c00'
814
1001
  }, { resolveProfile: false }));
1002
+ const result = await callTool(projectRoot, 'local', 'save_task_attachment', {
1003
+ id: task.referenceLabel,
1004
+ filename: 'stateless-note.txt',
1005
+ content: 'hello from a stateless attachment write',
1006
+ profileToken: resolved.profile.profileToken,
1007
+ }, { resolveProfile: false });
1008
+ expect(result.isError).not.toBe(true);
1009
+ const after = new TaskforceCore({
1010
+ projectRoot,
1011
+ storagePath: path.join(projectRoot, '.taskforce')
1012
+ });
1013
+ try {
1014
+ const updated = after.getTask(task.id, 'workspace-1');
1015
+ expect(updated?.attachments).toHaveLength(1);
1016
+ const attachment = updated?.attachments?.[0];
1017
+ expect(attachment?.assetId).toMatch(/^asset-/);
1018
+ const store = new WorkspaceAssetStore(after.getDatabaseAdapter(), after.getTenantId());
1019
+ const asset = store.get(String(attachment?.assetId || ''), 'workspace-1');
1020
+ expect(asset?.createdByUserId).toBe(resolved.profile.id);
1021
+ }
1022
+ finally {
1023
+ after.close();
1024
+ }
1025
+ }
1026
+ finally {
1027
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1028
+ }
1029
+ });
1030
+ it('allows mutating cloud MCP tools when the connection is configured with an AI profile token', async () => {
1031
+ const projectRoot = createProjectRoot();
1032
+ try {
1033
+ const resolved = parseToolJson(await callTool(projectRoot, 'cloud', 'resolve_profile', {
1034
+ name: 'Automation Codex'
1035
+ }, {
1036
+ workspaceId: 'workspace-1',
1037
+ resolveProfile: false
1038
+ }));
815
1039
  const created = parseToolJson(await callTool(projectRoot, 'cloud', 'create_task', {
816
1040
  title: 'Configured token write'
817
1041
  }, {
@@ -866,6 +1090,100 @@ describe('Taskforce MCP tool descriptions', () => {
866
1090
  fs.rmSync(projectRoot, { recursive: true, force: true });
867
1091
  }
868
1092
  });
1093
+ it('blocks cloud auto-bind when the configured AI profile has been retired by name until resolve_profile explicitly reactivates it', async () => {
1094
+ const projectRoot = createProjectRoot();
1095
+ try {
1096
+ const core = new TaskforceCore({
1097
+ projectRoot,
1098
+ storagePath: path.join(projectRoot, '.taskforce')
1099
+ });
1100
+ const resolved = core.resolveAiProfile({
1101
+ workspaceId: 'workspace-1',
1102
+ name: 'Automation Codex',
1103
+ }, { trustedSeatScope: 'cloud_metered' });
1104
+ core.archiveAiProfile({
1105
+ workspaceId: 'workspace-1',
1106
+ profileId: resolved.profile.id,
1107
+ archivedBy: 'owner-1',
1108
+ reason: 'manual_archive'
1109
+ });
1110
+ core.close();
1111
+ const blocked = await callTool(projectRoot, 'cloud', 'create_task', {
1112
+ title: 'Should stay retired'
1113
+ }, {
1114
+ workspaceId: 'workspace-1',
1115
+ resolveProfile: false,
1116
+ authAiProfileName: 'Automation Codex'
1117
+ });
1118
+ expect(blocked.isError).toBe(true);
1119
+ const blockedPayload = parseToolJson(blocked);
1120
+ expect(blockedPayload.code).toBe('AI_PROFILE_RETIRED');
1121
+ expect(blockedPayload.error).toContain('has been retired from this workspace');
1122
+ expect(blockedPayload.nextTool).toBe('resolve_profile');
1123
+ const reactivated = parseToolJson(await callTool(projectRoot, 'cloud', 'resolve_profile', {
1124
+ name: 'Automation Codex',
1125
+ surfaceType: 'agent'
1126
+ }, {
1127
+ workspaceId: 'workspace-1',
1128
+ resolveProfile: false,
1129
+ authAiProfileName: 'Automation Codex'
1130
+ }));
1131
+ expect(reactivated.created).toBe(false);
1132
+ expect(reactivated.profile.id).toBe(resolved.profile.id);
1133
+ expect(reactivated.profile.archivedAt ?? null).toBeNull();
1134
+ const created = parseToolJson(await callTool(projectRoot, 'cloud', 'create_task', {
1135
+ title: 'Reactivated automation write'
1136
+ }, {
1137
+ workspaceId: 'workspace-1',
1138
+ resolveProfile: false,
1139
+ authAiProfileName: 'Automation Codex'
1140
+ }));
1141
+ expect(created.title).toBe('Reactivated automation write');
1142
+ expect(created.createdBy).toBe(resolved.profile.id);
1143
+ }
1144
+ finally {
1145
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1146
+ }
1147
+ });
1148
+ it('blocks cloud auto-bind when the configured AI profile token belongs to a retired profile', async () => {
1149
+ const projectRoot = createProjectRoot();
1150
+ try {
1151
+ const core = new TaskforceCore({
1152
+ projectRoot,
1153
+ storagePath: path.join(projectRoot, '.taskforce')
1154
+ });
1155
+ const resolved = core.resolveAiProfile({
1156
+ workspaceId: 'workspace-1',
1157
+ name: 'Automation Codex',
1158
+ }, { trustedSeatScope: 'cloud_metered' });
1159
+ core.archiveAiProfile({
1160
+ workspaceId: 'workspace-1',
1161
+ profileId: resolved.profile.id,
1162
+ archivedBy: 'owner-1',
1163
+ reason: 'manual_archive'
1164
+ });
1165
+ core.close();
1166
+ const blocked = await callTool(projectRoot, 'cloud', 'create_task', {
1167
+ title: 'Token should stay retired'
1168
+ }, {
1169
+ workspaceId: 'workspace-1',
1170
+ resolveProfile: false,
1171
+ authAiProfileToken: resolved.profile.profileToken,
1172
+ authAiProfileName: 'Automation Codex'
1173
+ });
1174
+ expect(blocked.isError).toBe(true);
1175
+ const blockedPayload = parseToolJson(blocked);
1176
+ expect(blockedPayload.code).toBe('AI_PROFILE_RETIRED');
1177
+ expect(blockedPayload.details).toEqual(expect.objectContaining({
1178
+ profileId: resolved.profile.id,
1179
+ profileToken: resolved.profile.profileToken,
1180
+ archivedReason: 'manual_archive'
1181
+ }));
1182
+ }
1183
+ finally {
1184
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1185
+ }
1186
+ });
869
1187
  it('blocks cloud auto-bind when the workspace has no remaining ai profile seats', async () => {
870
1188
  const projectRoot = createProjectRoot();
871
1189
  try {
@@ -895,8 +1213,8 @@ describe('Taskforce MCP tool descriptions', () => {
895
1213
  });
896
1214
  core.resolveAiProfile({
897
1215
  workspaceId: 'workspace-1',
898
- name: 'Existing Automation Bot'
899
- });
1216
+ name: 'Existing Automation Bot',
1217
+ }, { trustedSeatScope: 'cloud_metered' });
900
1218
  core.close();
901
1219
  const result = await callTool(projectRoot, 'cloud', 'create_task', {
902
1220
  title: 'Blocked automation write'
@@ -1009,8 +1327,8 @@ describe('Taskforce MCP tool descriptions', () => {
1009
1327
  });
1010
1328
  core.resolveAiProfile({
1011
1329
  workspaceId: 'workspace-1',
1012
- name: 'Existing Automation Bot'
1013
- });
1330
+ name: 'Existing Automation Bot',
1331
+ }, { trustedSeatScope: 'cloud_metered' });
1014
1332
  core.close();
1015
1333
  const result = await callTool(projectRoot, 'cloud', 'create_task', {
1016
1334
  title: 'Recovered per-call token write',
@@ -1035,112 +1353,435 @@ describe('Taskforce MCP tool descriptions', () => {
1035
1353
  fs.rmSync(projectRoot, { recursive: true, force: true });
1036
1354
  }
1037
1355
  });
1038
- it('claims unassigned tasks and marks them in progress through start_task', async () => {
1356
+ it('keeps local MCP profile resolution unmetered even when cloud seats are full', async () => {
1039
1357
  const projectRoot = createProjectRoot();
1040
1358
  try {
1041
1359
  const core = new TaskforceCore({
1042
1360
  projectRoot,
1043
1361
  storagePath: path.join(projectRoot, '.taskforce')
1044
1362
  });
1045
- const seeded = core.createTask({
1046
- title: 'Start helper unassigned path',
1047
- status: 'task',
1048
- assignee: 'unassigned'
1049
- }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1363
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
1364
+ core.ensureBootstrapUserAccess({
1365
+ userId: 'owner-u1',
1366
+ workspaceId: 'workspace-1',
1367
+ role: 'owner',
1368
+ email: 'owner@example.com'
1369
+ });
1370
+ core.updatePlanVersionFeatures({
1371
+ planVersionId: 'free-v1',
1372
+ features: [
1373
+ { featureKey: 'collaboration.ai_profiles', access: 'limited', config: { maxAiProfiles: 1 } }
1374
+ ]
1375
+ });
1376
+ core.updateAccountEntitlement({
1377
+ accountId: 'owner-u1',
1378
+ planVersionId: 'free-v1',
1379
+ entitlementState: 'active',
1380
+ actorUserId: 'system-admin',
1381
+ actorRole: 'system_admin'
1382
+ });
1383
+ core.resolveAiProfile({
1384
+ workspaceId: 'workspace-1',
1385
+ name: 'Existing Automation Bot',
1386
+ }, { trustedSeatScope: 'cloud_metered' });
1050
1387
  core.close();
1051
- const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1052
- id: seeded.referenceLabel
1388
+ const payload = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
1389
+ name: 'Local Codex'
1390
+ }, {
1391
+ workspaceId: 'workspace-1',
1392
+ resolveProfile: false
1053
1393
  }));
1054
- expect(result.started).toBe(true);
1055
- expect(result.claimed).toBe(true);
1056
- expect(result.statusChanged).toBe(true);
1057
- expect(result.previousAssignee).toBe('unassigned');
1058
- expect(result.previousStatus).toBe('task');
1059
- expect(result.task.status).toBe('in-progress');
1060
- expect(result.task.assignee).toMatch(/^ai-profile-/);
1394
+ expect(payload.created).toBe(true);
1395
+ expect(payload.profile.name).toBe('Local Codex');
1396
+ expect(payload.profile.seatScope).toBe('local_unmetered');
1061
1397
  }
1062
1398
  finally {
1063
1399
  fs.rmSync(projectRoot, { recursive: true, force: true });
1064
1400
  }
1065
1401
  });
1066
- it('sets tasks already assigned to the current AI in progress without stealing ownership', async () => {
1402
+ it('derives profile seat scope from the MCP runtime instead of auth metadata', async () => {
1067
1403
  const projectRoot = createProjectRoot();
1068
1404
  try {
1069
- const profile = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
1405
+ const localRuntime = createTaskforceMcpServer({
1406
+ projectRoot,
1407
+ runtimeMode: 'local',
1408
+ workspaceId: 'workspace-1',
1409
+ logStartup: false,
1410
+ auth: {
1411
+ aiProfileSeatScope: 'cloud_metered',
1412
+ aiProfileName: 'Codex',
1413
+ aiProfileRole: 'Implementation',
1414
+ aiProfileProvider: 'OpenAI',
1415
+ aiProfileModel: 'gpt-5',
1416
+ aiProfileSurfaceType: 'coding_tool',
1417
+ }
1418
+ });
1419
+ const localPayload = parseToolJson(await callToolWithRuntime(localRuntime, 'resolve_profile', {
1070
1420
  name: 'Codex',
1071
- icon: 'Bot',
1072
- color: '#ef6c00'
1421
+ surfaceType: 'coding_tool',
1422
+ }, {
1423
+ resolveProfile: false
1073
1424
  }));
1074
- const core = new TaskforceCore({
1425
+ expect(localPayload.profile.seatScope).toBe('local_unmetered');
1426
+ const cloudRuntime = createTaskforceMcpServer({
1075
1427
  projectRoot,
1076
- storagePath: path.join(projectRoot, '.taskforce')
1428
+ runtimeMode: 'cloud',
1429
+ workspaceId: 'workspace-1',
1430
+ logStartup: false,
1431
+ auth: {
1432
+ scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
1433
+ userId: 'owner-1',
1434
+ actorType: 'oauth',
1435
+ aiProfileSeatScope: 'local_unmetered',
1436
+ aiProfileName: 'Cloud Codex',
1437
+ aiProfileSurfaceType: 'coding_tool',
1438
+ }
1077
1439
  });
1078
- const seeded = core.createTask({
1079
- title: 'Start helper owned path',
1080
- status: 'task',
1081
- assignee: profile.profile.id
1082
- }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1083
- core.close();
1084
- const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1085
- id: seeded.referenceLabel
1440
+ const cloudPayload = parseToolJson(await callToolWithRuntime(cloudRuntime, 'resolve_profile', {
1441
+ name: 'Cloud Codex',
1442
+ surfaceType: 'coding_tool',
1443
+ }, {
1444
+ resolveProfile: false
1086
1445
  }));
1087
- expect(result.started).toBe(true);
1088
- expect(result.claimed).toBe(false);
1089
- expect(result.statusChanged).toBe(true);
1090
- expect(result.previousAssignee).toBe(profile.profile.id);
1091
- expect(result.task.assignee).toBe(profile.profile.id);
1092
- expect(result.task.status).toBe('in-progress');
1446
+ expect(cloudPayload.profile.seatScope).toBe('cloud_metered');
1093
1447
  }
1094
1448
  finally {
1095
1449
  fs.rmSync(projectRoot, { recursive: true, force: true });
1096
1450
  }
1097
1451
  });
1098
- it('refuses to steal tasks already assigned to someone else through start_task', async () => {
1452
+ it('creates a separate local MCP profile when a cloud profile with the same name already exists', async () => {
1099
1453
  const projectRoot = createProjectRoot();
1100
1454
  try {
1101
1455
  const core = new TaskforceCore({
1102
1456
  projectRoot,
1103
1457
  storagePath: path.join(projectRoot, '.taskforce')
1104
1458
  });
1105
- const seeded = core.createTask({
1106
- title: 'Start helper assignee refusal',
1107
- status: 'task',
1108
- assignee: 'owner-1'
1109
- }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1459
+ const cloud = core.resolveAiProfile({
1460
+ workspaceId: 'workspace-1',
1461
+ name: 'Codex',
1462
+ surfaceType: 'coding_tool'
1463
+ }, { trustedSeatScope: 'cloud_metered' });
1110
1464
  core.close();
1111
- const result = await callTool(projectRoot, 'local', 'start_task', {
1112
- id: seeded.referenceLabel
1113
- });
1114
- expect(result.isError).toBe(true);
1115
- expect(result.content?.[0]?.text).toContain('will not steal work from another assignee');
1465
+ const payload = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
1466
+ name: 'Codex',
1467
+ surfaceType: 'coding_tool'
1468
+ }, {
1469
+ workspaceId: 'workspace-1',
1470
+ resolveProfile: false
1471
+ }));
1472
+ expect(payload.created).toBe(true);
1473
+ expect(payload.profile.id).not.toBe(cloud.profile.id);
1474
+ expect(payload.profile.seatScope).toBe('local_unmetered');
1116
1475
  }
1117
1476
  finally {
1118
1477
  fs.rmSync(projectRoot, { recursive: true, force: true });
1119
1478
  }
1120
1479
  });
1121
- it('rejects terminal tasks through start_task', async () => {
1480
+ it('attributes system-authored MCP comments to the current AI profile', async () => {
1122
1481
  const projectRoot = createProjectRoot();
1123
1482
  try {
1124
- const core = new TaskforceCore({
1483
+ const runtime = createTaskforceMcpServer({
1125
1484
  projectRoot,
1126
- storagePath: path.join(projectRoot, '.taskforce')
1127
- });
1128
- const seeded = core.createTask({
1129
- title: 'Start helper terminal refusal',
1130
- status: 'done',
1131
- assignee: 'unassigned'
1132
- }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1133
- core.close();
1134
- const result = await callTool(projectRoot, 'local', 'start_task', {
1135
- id: seeded.referenceLabel
1485
+ runtimeMode: 'local',
1486
+ workspaceId: 'workspace-1',
1487
+ logStartup: false,
1136
1488
  });
1137
- expect(result.isError).toBe(true);
1138
- expect(result.content?.[0]?.text).toContain('must be reopened explicitly');
1139
- }
1140
- finally {
1141
- fs.rmSync(projectRoot, { recursive: true, force: true });
1142
- }
1143
- });
1489
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1490
+ name: 'Codex',
1491
+ icon: 'Bot',
1492
+ surfaceType: 'coding_tool',
1493
+ }, {
1494
+ resolveProfile: false,
1495
+ }));
1496
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1497
+ title: 'Comment attribution task',
1498
+ }));
1499
+ await callToolWithRuntime(runtime, 'add_comment', {
1500
+ id: created.referenceLabel,
1501
+ text: 'Completed the work.',
1502
+ author: 'system',
1503
+ });
1504
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1505
+ id: created.referenceLabel,
1506
+ }));
1507
+ const comment = task.comments.find((entry) => entry.text === 'Completed the work.');
1508
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1509
+ expect(comment.author).toBe(profilePayload.profile.id);
1510
+ expect(comment.submittedAuthor).toBe(profilePayload.profile.id);
1511
+ expect(comment.actor.label).toBe('Codex');
1512
+ expect(event.actor).toBe(profilePayload.profile.id);
1513
+ expect(event.actorType).toBe('ai');
1514
+ expect(event.actorProfile.label).toBe('Codex');
1515
+ }
1516
+ finally {
1517
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1518
+ }
1519
+ });
1520
+ it('canonicalizes ambiguous AI display-name comment authors back to the bound profile', async () => {
1521
+ const projectRoot = createProjectRoot();
1522
+ try {
1523
+ const seedCore = new TaskforceCore({
1524
+ projectRoot,
1525
+ storagePath: path.join(projectRoot, '.taskforce')
1526
+ });
1527
+ seedCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1528
+ seedCore.close();
1529
+ const runtime = createTaskforceMcpServer({
1530
+ projectRoot,
1531
+ runtimeMode: 'local',
1532
+ workspaceId: 'workspace-1',
1533
+ logStartup: false,
1534
+ });
1535
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1536
+ name: 'Codex',
1537
+ icon: 'Bot',
1538
+ surfaceType: 'coding_tool',
1539
+ }, {
1540
+ resolveProfile: false,
1541
+ }));
1542
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1543
+ title: 'Ambiguous comment attribution task',
1544
+ }));
1545
+ await callToolWithRuntime(runtime, 'add_comment', {
1546
+ id: created.referenceLabel,
1547
+ text: 'Bound actor should win.',
1548
+ author: 'Codex',
1549
+ });
1550
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1551
+ id: created.referenceLabel,
1552
+ }));
1553
+ const comment = task.comments.find((entry) => entry.text === 'Bound actor should win.');
1554
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1555
+ expect(comment.author).toBe(profilePayload.profile.id);
1556
+ expect(comment.submittedAuthor).toBe(profilePayload.profile.id);
1557
+ expect(comment.actor.id).toBe(profilePayload.profile.id);
1558
+ expect(event.actor).toBe(profilePayload.profile.id);
1559
+ expect(event.actorType).toBe('ai');
1560
+ }
1561
+ finally {
1562
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1563
+ }
1564
+ });
1565
+ it('preserves explicit human comment overrides for MCP writes', async () => {
1566
+ const projectRoot = createProjectRoot();
1567
+ try {
1568
+ const runtime = createTaskforceMcpServer({
1569
+ projectRoot,
1570
+ runtimeMode: 'local',
1571
+ workspaceId: 'workspace-1',
1572
+ logStartup: false,
1573
+ });
1574
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1575
+ title: 'Human comment override task',
1576
+ }));
1577
+ await callToolWithRuntime(runtime, 'add_comment', {
1578
+ id: created.referenceLabel,
1579
+ text: 'Human-authored note.',
1580
+ author: 'user',
1581
+ });
1582
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1583
+ id: created.referenceLabel,
1584
+ }));
1585
+ const comment = task.comments.find((entry) => entry.text === 'Human-authored note.');
1586
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1587
+ expect(comment.author).toBe('user');
1588
+ expect(comment.submittedAuthor).toBe('user');
1589
+ expect(comment.actor.id).toBe('user');
1590
+ expect(event.actor).toBe('user');
1591
+ expect(event.actorType).toBe('human');
1592
+ }
1593
+ finally {
1594
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1595
+ }
1596
+ });
1597
+ it('ignores mutable MCP comment author aliases when an AI profile is bound', async () => {
1598
+ const projectRoot = createProjectRoot();
1599
+ try {
1600
+ const runtime = createTaskforceMcpServer({
1601
+ projectRoot,
1602
+ runtimeMode: 'local',
1603
+ workspaceId: 'workspace-1',
1604
+ logStartup: false,
1605
+ });
1606
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1607
+ name: 'Codex',
1608
+ icon: 'Bot',
1609
+ surfaceType: 'coding_tool',
1610
+ }, {
1611
+ resolveProfile: false,
1612
+ }));
1613
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1614
+ title: 'Strict comment attribution task',
1615
+ }));
1616
+ await callToolWithRuntime(runtime, 'add_comment', {
1617
+ id: created.referenceLabel,
1618
+ text: 'Alias should not win over the bound profile.',
1619
+ author: 'Claude',
1620
+ });
1621
+ await callToolWithRuntime(runtime, 'add_comment', {
1622
+ id: created.referenceLabel,
1623
+ text: 'Explicit human override stays human.',
1624
+ author: 'user',
1625
+ });
1626
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1627
+ id: created.referenceLabel,
1628
+ }));
1629
+ const aliasComment = task.comments.find((entry) => entry.text === 'Alias should not win over the bound profile.');
1630
+ const humanComment = task.comments.find((entry) => entry.text === 'Explicit human override stays human.');
1631
+ expect(aliasComment.author).toBe(profilePayload.profile.id);
1632
+ expect(aliasComment.submittedAuthor).toBe(profilePayload.profile.id);
1633
+ expect(aliasComment.actor.label).toBe('Codex');
1634
+ expect(humanComment.author).toBe('user');
1635
+ expect(humanComment.submittedAuthor).toBe('user');
1636
+ expect(humanComment.actor.kind).toBe('human');
1637
+ }
1638
+ finally {
1639
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1640
+ }
1641
+ });
1642
+ it('claims unassigned tasks and marks them in progress through start_task', async () => {
1643
+ const projectRoot = createProjectRoot();
1644
+ try {
1645
+ const core = new TaskforceCore({
1646
+ projectRoot,
1647
+ storagePath: path.join(projectRoot, '.taskforce')
1648
+ });
1649
+ const seeded = core.createTask({
1650
+ title: 'Start helper unassigned path',
1651
+ status: 'task',
1652
+ assignee: 'unassigned'
1653
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1654
+ core.close();
1655
+ const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1656
+ id: seeded.referenceLabel
1657
+ }));
1658
+ expect(result.started).toBe(true);
1659
+ expect(result.claimed).toBe(true);
1660
+ expect(result.statusChanged).toBe(true);
1661
+ expect(result.previousAssignee).toBe('unassigned');
1662
+ expect(result.previousStatus).toBe('task');
1663
+ expect(result.task.status).toBe('in-progress');
1664
+ expect(result.task.assignee).toMatch(/^ai-profile-/);
1665
+ }
1666
+ finally {
1667
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1668
+ }
1669
+ });
1670
+ it('sets tasks already assigned to the current AI in progress without stealing ownership', async () => {
1671
+ const projectRoot = createProjectRoot();
1672
+ try {
1673
+ const profile = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
1674
+ name: 'Codex',
1675
+ icon: 'Bot',
1676
+ color: '#ef6c00'
1677
+ }));
1678
+ const core = new TaskforceCore({
1679
+ projectRoot,
1680
+ storagePath: path.join(projectRoot, '.taskforce')
1681
+ });
1682
+ const seeded = core.createTask({
1683
+ title: 'Start helper owned path',
1684
+ status: 'task',
1685
+ assignee: profile.profile.id
1686
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1687
+ core.close();
1688
+ const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1689
+ id: seeded.referenceLabel
1690
+ }));
1691
+ expect(result.started).toBe(true);
1692
+ expect(result.claimed).toBe(false);
1693
+ expect(result.statusChanged).toBe(true);
1694
+ expect(result.previousAssignee).toBe(profile.profile.id);
1695
+ expect(result.task.assignee).toBe(profile.profile.id);
1696
+ expect(result.task.status).toBe('in-progress');
1697
+ }
1698
+ finally {
1699
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1700
+ }
1701
+ });
1702
+ it('allows review tasks to resume through start_task', async () => {
1703
+ const projectRoot = createProjectRoot();
1704
+ try {
1705
+ const core = new TaskforceCore({
1706
+ projectRoot,
1707
+ storagePath: path.join(projectRoot, '.taskforce')
1708
+ });
1709
+ const seeded = core.createTask({
1710
+ title: 'Start helper review resume',
1711
+ status: 'review',
1712
+ assignee: 'unassigned'
1713
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1714
+ core.close();
1715
+ const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1716
+ id: seeded.referenceLabel
1717
+ }));
1718
+ expect(result.started).toBe(true);
1719
+ expect(result.claimed).toBe(true);
1720
+ expect(result.statusChanged).toBe(true);
1721
+ expect(result.previousStatus).toBe('review');
1722
+ expect(result.task.status).toBe('in-progress');
1723
+ expect(result.task.assignee).toMatch(/^ai-profile-/);
1724
+ }
1725
+ finally {
1726
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1727
+ }
1728
+ });
1729
+ it('refuses to steal tasks already assigned to someone else through start_task', async () => {
1730
+ const projectRoot = createProjectRoot();
1731
+ try {
1732
+ const core = new TaskforceCore({
1733
+ projectRoot,
1734
+ storagePath: path.join(projectRoot, '.taskforce')
1735
+ });
1736
+ const seeded = core.createTask({
1737
+ title: 'Start helper assignee refusal',
1738
+ status: 'task',
1739
+ assignee: 'owner-1'
1740
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1741
+ core.close();
1742
+ const result = await callTool(projectRoot, 'local', 'start_task', {
1743
+ id: seeded.referenceLabel
1744
+ });
1745
+ expect(result.isError).toBe(true);
1746
+ expect(result.content?.[0]?.text).toContain('will not steal work from another assignee');
1747
+ }
1748
+ finally {
1749
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1750
+ }
1751
+ });
1752
+ it('rejects done and cancelled tasks through start_task', async () => {
1753
+ const projectRoot = createProjectRoot();
1754
+ try {
1755
+ const core = new TaskforceCore({
1756
+ projectRoot,
1757
+ storagePath: path.join(projectRoot, '.taskforce')
1758
+ });
1759
+ const doneTask = core.createTask({
1760
+ title: 'Start helper done refusal',
1761
+ status: 'done',
1762
+ assignee: 'unassigned'
1763
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1764
+ const cancelledTask = core.createTask({
1765
+ title: 'Start helper cancelled refusal',
1766
+ status: 'cancelled',
1767
+ assignee: 'unassigned'
1768
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1769
+ core.close();
1770
+ const doneResult = await callTool(projectRoot, 'local', 'start_task', {
1771
+ id: doneTask.referenceLabel
1772
+ });
1773
+ const cancelledResult = await callTool(projectRoot, 'local', 'start_task', {
1774
+ id: cancelledTask.referenceLabel
1775
+ });
1776
+ expect(doneResult.isError).toBe(true);
1777
+ expect(doneResult.content?.[0]?.text).toContain('must be reopened explicitly');
1778
+ expect(cancelledResult.isError).toBe(true);
1779
+ expect(cancelledResult.content?.[0]?.text).toContain('must be reopened explicitly');
1780
+ }
1781
+ finally {
1782
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1783
+ }
1784
+ });
1144
1785
  it('requires AI identity for start_task when bootstrap is disabled', async () => {
1145
1786
  const projectRoot = createProjectRoot();
1146
1787
  try {
@@ -1372,6 +2013,7 @@ describe('Taskforce MCP tool descriptions', () => {
1372
2013
  isCurrentProfile: true
1373
2014
  })
1374
2015
  ]));
2016
+ expect(JSON.stringify(payload.assignees)).not.toContain(resolved.profile.profileToken);
1375
2017
  }
1376
2018
  finally {
1377
2019
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -1483,6 +2125,50 @@ describe('Taskforce MCP tool descriptions', () => {
1483
2125
  fs.rmSync(projectRoot, { recursive: true, force: true });
1484
2126
  }
1485
2127
  });
2128
+ it('auto-binds a configured cloud AI name to an existing profile when the token is stale', async () => {
2129
+ const projectRoot = createProjectRoot();
2130
+ try {
2131
+ const seedCore = new TaskforceCore({
2132
+ projectRoot,
2133
+ storagePath: path.join(projectRoot, '.taskforce')
2134
+ });
2135
+ const existing = seedCore.resolveAiProfile({
2136
+ workspaceId: 'workspace-1',
2137
+ name: 'Felix Leiter',
2138
+ provider: 'OpenAI',
2139
+ model: 'openai-codex/gpt-5.4',
2140
+ surfaceType: 'agent',
2141
+ }, { trustedSeatScope: 'cloud_metered' });
2142
+ seedCore.close();
2143
+ const payload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_current_profile', {}, {
2144
+ workspaceId: 'workspace-1',
2145
+ resolveProfile: false,
2146
+ authAiProfileToken: 'tfp_stale_felix_leiter_token',
2147
+ authAiProfileName: 'Felix Leiter',
2148
+ authAiProfileProvider: 'OpenClaw',
2149
+ authAiProfileModel: 'gpt-5.4',
2150
+ authAiProfileSurfaceType: 'agent'
2151
+ }));
2152
+ expect(payload.identityBound).toBe(true);
2153
+ expect(payload.bindingSource).toBe('connection_bootstrap');
2154
+ expect(payload.profile).toEqual(expect.objectContaining({
2155
+ id: existing.profile.id,
2156
+ name: 'Felix Leiter',
2157
+ provider: 'OpenClaw',
2158
+ model: 'gpt-5.4',
2159
+ seatScope: 'cloud_metered',
2160
+ }));
2161
+ const verifyCore = new TaskforceCore({
2162
+ projectRoot,
2163
+ storagePath: path.join(projectRoot, '.taskforce')
2164
+ });
2165
+ expect(verifyCore.listAiProfiles('workspace-1')).toHaveLength(1);
2166
+ verifyCore.close();
2167
+ }
2168
+ finally {
2169
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2170
+ }
2171
+ });
1486
2172
  it('searches tasks by T-XXX reference label via reference_number', async () => {
1487
2173
  const projectRoot = createProjectRoot();
1488
2174
  try {
@@ -1603,68 +2289,274 @@ describe('Taskforce MCP tool descriptions', () => {
1603
2289
  fs.rmSync(projectRoot, { recursive: true, force: true });
1604
2290
  }
1605
2291
  });
1606
- it('manages planning entities, relationships, and structured checklist tools', async () => {
2292
+ it('manages planning entities, relationships, and structured checklist tools', async () => {
2293
+ const projectRoot = createProjectRoot();
2294
+ try {
2295
+ const createdInitiative = parseToolJson(await callTool(projectRoot, 'local', 'create_initiative', {
2296
+ title: 'Launch initiative'
2297
+ }));
2298
+ expect(createdInitiative.initiative.referenceLabel).toMatch(/^IN-/);
2299
+ const createdWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2300
+ title: 'Launch workstream',
2301
+ initiativeId: createdInitiative.initiative.referenceLabel
2302
+ }));
2303
+ expect(createdWorkstream.workstream.referenceLabel).toMatch(/^WS-/);
2304
+ expect(createdWorkstream.workstream.initiativeId).toBe(createdInitiative.initiative.id);
2305
+ const createdTask = parseToolJson(await callTool(projectRoot, 'local', 'create_task', {
2306
+ title: 'Task for MCP planning'
2307
+ }));
2308
+ const taskLinked = parseToolJson(await callTool(projectRoot, 'local', 'set_task_workstream', {
2309
+ taskId: createdTask.referenceLabel,
2310
+ workstreamId: createdWorkstream.workstream.referenceLabel
2311
+ }));
2312
+ expect(taskLinked.workstreamId).toBe(createdWorkstream.workstream.id);
2313
+ const checklistReplaced = parseToolJson(await callTool(projectRoot, 'local', 'replace_task_checklist', {
2314
+ taskId: createdTask.referenceLabel,
2315
+ items: [
2316
+ { title: 'First checklist item', isCompleted: false },
2317
+ { title: 'Second checklist item', isCompleted: true }
2318
+ ]
2319
+ }));
2320
+ expect(checklistReplaced.items).toHaveLength(2);
2321
+ const checklistRead = parseToolJson(await callTool(projectRoot, 'local', 'get_task_checklist', {
2322
+ taskId: createdTask.referenceLabel
2323
+ }));
2324
+ expect(checklistRead.items.map((item) => item.title)).toEqual(['First checklist item', 'Second checklist item']);
2325
+ const workstreamCleared = parseToolJson(await callTool(projectRoot, 'local', 'clear_workstream_initiative', {
2326
+ workstreamId: createdWorkstream.workstream.referenceLabel
2327
+ }));
2328
+ expect(workstreamCleared.workstream.initiativeId).toBeNull();
2329
+ const workstreamReset = parseToolJson(await callTool(projectRoot, 'local', 'set_workstream_initiative', {
2330
+ workstreamId: createdWorkstream.workstream.referenceLabel,
2331
+ initiativeId: createdInitiative.initiative.referenceLabel
2332
+ }));
2333
+ expect(workstreamReset.workstream.initiativeId).toBe(createdInitiative.initiative.id);
2334
+ const archivedWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'archive_workstream', {
2335
+ id: createdWorkstream.workstream.referenceLabel
2336
+ }));
2337
+ expect(archivedWorkstream.workstream.isArchived).toBe(true);
2338
+ const unarchivedWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'unarchive_workstream', {
2339
+ id: createdWorkstream.workstream.referenceLabel
2340
+ }));
2341
+ expect(unarchivedWorkstream.workstream.isArchived).toBe(false);
2342
+ const archivedInitiative = parseToolJson(await callTool(projectRoot, 'local', 'archive_initiative', {
2343
+ id: createdInitiative.initiative.referenceLabel
2344
+ }));
2345
+ expect(archivedInitiative.initiative.isArchived).toBe(true);
2346
+ const unarchivedInitiative = parseToolJson(await callTool(projectRoot, 'local', 'unarchive_initiative', {
2347
+ id: createdInitiative.initiative.referenceLabel
2348
+ }));
2349
+ expect(unarchivedInitiative.initiative.isArchived).toBe(false);
2350
+ const taskCleared = parseToolJson(await callTool(projectRoot, 'local', 'clear_task_workstream', {
2351
+ taskId: createdTask.referenceLabel
2352
+ }));
2353
+ expect(taskCleared.workstreamId).toBeNull();
2354
+ }
2355
+ finally {
2356
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2357
+ }
2358
+ });
2359
+ it('keeps the MCP runtime usable after linking a task to a workstream', async () => {
2360
+ const projectRoot = createProjectRoot();
2361
+ const runtime = createRuntime(projectRoot, 'local');
2362
+ try {
2363
+ const createdWorkstream = parseToolJson(await callToolWithRuntime(runtime, 'create_workstream', {
2364
+ title: 'Transport-safe workstream'
2365
+ }));
2366
+ const createdTask = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
2367
+ title: 'Transport-safe task'
2368
+ }));
2369
+ const taskLinked = parseToolJson(await callToolWithRuntime(runtime, 'set_task_workstream', {
2370
+ taskId: createdTask.referenceLabel,
2371
+ workstreamId: createdWorkstream.workstream.referenceLabel
2372
+ }));
2373
+ expect(taskLinked.workstreamId).toBe(createdWorkstream.workstream.id);
2374
+ const workstreamRead = parseToolJson(await callToolWithRuntime(runtime, 'get_workstream', {
2375
+ id: createdWorkstream.workstream.referenceLabel
2376
+ }));
2377
+ expect(workstreamRead.workstream.id).toBe(createdWorkstream.workstream.id);
2378
+ expect(workstreamRead.tasks).toEqual(expect.arrayContaining([
2379
+ expect.objectContaining({
2380
+ id: createdTask.id,
2381
+ workstreamId: createdWorkstream.workstream.id
2382
+ })
2383
+ ]));
2384
+ }
2385
+ finally {
2386
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2387
+ }
2388
+ });
2389
+ it('retries transient planning read failures before returning get_workstream results', async () => {
2390
+ const projectRoot = createProjectRoot();
2391
+ const runtime = createRuntime(projectRoot, 'cloud');
2392
+ try {
2393
+ const workstream = runtime.core.createWorkstream({ title: 'Retryable read workstream' }, 'workspace-1');
2394
+ const originalListWorkstreams = runtime.core.listWorkstreams.bind(runtime.core);
2395
+ let attempts = 0;
2396
+ const listWorkstreamsSpy = vi.spyOn(runtime.core, 'listWorkstreams').mockImplementation((workspaceId) => {
2397
+ attempts += 1;
2398
+ if (attempts === 1) {
2399
+ throw new Error('Postgres worker timed out while reading workstreams');
2400
+ }
2401
+ return originalListWorkstreams(workspaceId);
2402
+ });
2403
+ const workstreamRead = parseToolJson(await callToolWithRuntime(runtime, 'get_workstream', {
2404
+ id: workstream.referenceLabel
2405
+ }));
2406
+ expect(attempts).toBe(2);
2407
+ expect(workstreamRead.workstream.id).toBe(workstream.id);
2408
+ listWorkstreamsSpy.mockRestore();
2409
+ }
2410
+ finally {
2411
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2412
+ }
2413
+ });
2414
+ it('returns the created workstream when post-write workstream hydration times out', async () => {
2415
+ const projectRoot = createProjectRoot();
2416
+ const originalGetWorkstream = TaskforceCore.prototype.getWorkstream;
2417
+ let injected = false;
2418
+ const getWorkstreamSpy = vi.spyOn(TaskforceCore.prototype, 'getWorkstream').mockImplementation(function (id, workspaceId) {
2419
+ const result = originalGetWorkstream.call(this, id, workspaceId);
2420
+ if (!injected && result?.title === 'Hydration fallback workstream') {
2421
+ injected = true;
2422
+ throw new Error('Postgres worker timed out while reading workstreams');
2423
+ }
2424
+ return result;
2425
+ });
2426
+ try {
2427
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2428
+ title: 'Hydration fallback workstream'
2429
+ }));
2430
+ expect(created.workstream.referenceLabel).toMatch(/^WS-/);
2431
+ expect(created.workstream.title).toBe('Hydration fallback workstream');
2432
+ }
2433
+ finally {
2434
+ getWorkstreamSpy.mockRestore();
2435
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2436
+ }
2437
+ });
2438
+ it('returns the created workstream when post-write response hydration times out', async () => {
2439
+ const projectRoot = createProjectRoot();
2440
+ const originalListTasks = TaskforceCore.prototype.listTasks;
2441
+ let injected = false;
2442
+ const listTasksSpy = vi.spyOn(TaskforceCore.prototype, 'listTasks').mockImplementation(function (workspaceId) {
2443
+ if (!injected) {
2444
+ injected = true;
2445
+ throw new Error('Postgres worker timed out while hydrating workstream response');
2446
+ }
2447
+ return originalListTasks.call(this, workspaceId);
2448
+ });
2449
+ try {
2450
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2451
+ title: 'Response hydration fallback workstream'
2452
+ }));
2453
+ expect(created.workstream.referenceLabel).toMatch(/^WS-/);
2454
+ expect(created.workstream.title).toBe('Response hydration fallback workstream');
2455
+ expect(created.responseHydration).toEqual(expect.objectContaining({
2456
+ fallback: true,
2457
+ code: 'MCP_BACKEND_TIMEOUT',
2458
+ nextTool: 'get_workstream'
2459
+ }));
2460
+ }
2461
+ finally {
2462
+ listTasksSpy.mockRestore();
2463
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2464
+ }
2465
+ });
2466
+ it('retries transient get_initiative read failures before returning results', async () => {
2467
+ const projectRoot = createProjectRoot();
2468
+ const runtime = createRuntime(projectRoot, 'cloud');
2469
+ try {
2470
+ const initiative = runtime.core.createInitiative({ title: 'Retryable initiative read' }, 'workspace-1');
2471
+ const originalListInitiatives = runtime.core.listInitiatives.bind(runtime.core);
2472
+ let attempts = 0;
2473
+ const listInitiativesSpy = vi.spyOn(runtime.core, 'listInitiatives').mockImplementation((workspaceId) => {
2474
+ attempts += 1;
2475
+ if (attempts === 1) {
2476
+ throw new Error('Postgres worker timed out while reading initiatives');
2477
+ }
2478
+ return originalListInitiatives(workspaceId);
2479
+ });
2480
+ const initiativeRead = parseToolJson(await callToolWithRuntime(runtime, 'get_initiative', {
2481
+ id: initiative.referenceLabel
2482
+ }));
2483
+ expect(attempts).toBe(2);
2484
+ expect(initiativeRead.initiative.id).toBe(initiative.id);
2485
+ listInitiativesSpy.mockRestore();
2486
+ }
2487
+ finally {
2488
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2489
+ }
2490
+ });
2491
+ it('keeps save_initiative_attachment successful when post-write initiative hydration times out', async () => {
2492
+ const projectRoot = createProjectRoot();
2493
+ try {
2494
+ const createdInitiative = parseToolJson(await callTool(projectRoot, 'local', 'create_initiative', {
2495
+ title: 'Hydration fallback initiative'
2496
+ }));
2497
+ const originalGetInitiative = TaskforceCore.prototype.getInitiative;
2498
+ let injected = false;
2499
+ const getInitiativeSpy = vi.spyOn(TaskforceCore.prototype, 'getInitiative').mockImplementation(function (id, workspaceId) {
2500
+ const result = originalGetInitiative.call(this, id, workspaceId);
2501
+ if (!injected
2502
+ && result
2503
+ && result.id === createdInitiative.initiative.id
2504
+ && Array.isArray(result.attachments)
2505
+ && result.attachments.some((item) => item && typeof item === 'object' && item.displayName === 'hydration-note.md')) {
2506
+ injected = true;
2507
+ throw new Error('Postgres worker timed out while reading initiatives');
2508
+ }
2509
+ return result;
2510
+ });
2511
+ try {
2512
+ const saveResult = await callTool(projectRoot, 'local', 'save_initiative_attachment', {
2513
+ id: createdInitiative.initiative.referenceLabel,
2514
+ filename: 'hydration-note.md',
2515
+ content: '# Hydration fallback\n'
2516
+ });
2517
+ expect(String(saveResult.content?.[0]?.text || '')).toContain('Initiative attachment saved and attached');
2518
+ }
2519
+ finally {
2520
+ getInitiativeSpy.mockRestore();
2521
+ }
2522
+ const attachments = parseToolJson(await callTool(projectRoot, 'local', 'get_initiative_attachments', {
2523
+ id: createdInitiative.initiative.referenceLabel
2524
+ }));
2525
+ expect(attachments.attachments).toEqual(expect.arrayContaining([
2526
+ expect.objectContaining({
2527
+ displayName: 'hydration-note.md'
2528
+ })
2529
+ ]));
2530
+ }
2531
+ finally {
2532
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2533
+ }
2534
+ });
2535
+ it('returns the archived workstream when the post-archive refresh times out', async () => {
1607
2536
  const projectRoot = createProjectRoot();
1608
2537
  try {
1609
- const createdInitiative = parseToolJson(await callTool(projectRoot, 'local', 'create_initiative', {
1610
- title: 'Launch initiative'
1611
- }));
1612
- expect(createdInitiative.initiative.referenceLabel).toMatch(/^IN-/);
1613
- const createdWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
1614
- title: 'Launch workstream',
1615
- initiativeId: createdInitiative.initiative.referenceLabel
1616
- }));
1617
- expect(createdWorkstream.workstream.referenceLabel).toMatch(/^WS-/);
1618
- expect(createdWorkstream.workstream.initiativeId).toBe(createdInitiative.initiative.id);
1619
- const createdTask = parseToolJson(await callTool(projectRoot, 'local', 'create_task', {
1620
- title: 'Task for MCP planning'
1621
- }));
1622
- const taskLinked = parseToolJson(await callTool(projectRoot, 'local', 'set_task_workstream', {
1623
- taskId: createdTask.referenceLabel,
1624
- workstreamId: createdWorkstream.workstream.referenceLabel
1625
- }));
1626
- expect(taskLinked.workstreamId).toBe(createdWorkstream.workstream.id);
1627
- const checklistReplaced = parseToolJson(await callTool(projectRoot, 'local', 'replace_task_checklist', {
1628
- taskId: createdTask.referenceLabel,
1629
- items: [
1630
- { title: 'First checklist item', isCompleted: false },
1631
- { title: 'Second checklist item', isCompleted: true }
1632
- ]
1633
- }));
1634
- expect(checklistReplaced.items).toHaveLength(2);
1635
- const checklistRead = parseToolJson(await callTool(projectRoot, 'local', 'get_task_checklist', {
1636
- taskId: createdTask.referenceLabel
1637
- }));
1638
- expect(checklistRead.items.map((item) => item.title)).toEqual(['First checklist item', 'Second checklist item']);
1639
- const workstreamCleared = parseToolJson(await callTool(projectRoot, 'local', 'clear_workstream_initiative', {
1640
- workstreamId: createdWorkstream.workstream.referenceLabel
1641
- }));
1642
- expect(workstreamCleared.workstream.initiativeId).toBeNull();
1643
- const workstreamReset = parseToolJson(await callTool(projectRoot, 'local', 'set_workstream_initiative', {
1644
- workstreamId: createdWorkstream.workstream.referenceLabel,
1645
- initiativeId: createdInitiative.initiative.referenceLabel
1646
- }));
1647
- expect(workstreamReset.workstream.initiativeId).toBe(createdInitiative.initiative.id);
1648
- const archivedWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'archive_workstream', {
1649
- id: createdWorkstream.workstream.referenceLabel
1650
- }));
1651
- expect(archivedWorkstream.workstream.isArchived).toBe(true);
1652
- const unarchivedWorkstream = parseToolJson(await callTool(projectRoot, 'local', 'unarchive_workstream', {
1653
- id: createdWorkstream.workstream.referenceLabel
1654
- }));
1655
- expect(unarchivedWorkstream.workstream.isArchived).toBe(false);
1656
- const archivedInitiative = parseToolJson(await callTool(projectRoot, 'local', 'archive_initiative', {
1657
- id: createdInitiative.initiative.referenceLabel
1658
- }));
1659
- expect(archivedInitiative.initiative.isArchived).toBe(true);
1660
- const unarchivedInitiative = parseToolJson(await callTool(projectRoot, 'local', 'unarchive_initiative', {
1661
- id: createdInitiative.initiative.referenceLabel
1662
- }));
1663
- expect(unarchivedInitiative.initiative.isArchived).toBe(false);
1664
- const taskCleared = parseToolJson(await callTool(projectRoot, 'local', 'clear_task_workstream', {
1665
- taskId: createdTask.referenceLabel
2538
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2539
+ title: 'Archivable hydration fallback workstream'
1666
2540
  }));
1667
- expect(taskCleared.workstreamId).toBeNull();
2541
+ const originalGetWorkstream = TaskforceCore.prototype.getWorkstream;
2542
+ let injected = false;
2543
+ const getWorkstreamSpy = vi.spyOn(TaskforceCore.prototype, 'getWorkstream').mockImplementation(function (id, workspaceId) {
2544
+ const result = originalGetWorkstream.call(this, id, workspaceId);
2545
+ if (!injected && result && result.id === created.workstream.id && result.isArchived === true) {
2546
+ injected = true;
2547
+ throw new Error('Postgres worker timed out while reading workstreams');
2548
+ }
2549
+ return result;
2550
+ });
2551
+ try {
2552
+ const archived = parseToolJson(await callTool(projectRoot, 'local', 'archive_workstream', {
2553
+ id: created.workstream.referenceLabel
2554
+ }));
2555
+ expect(archived.workstream.isArchived).toBe(true);
2556
+ }
2557
+ finally {
2558
+ getWorkstreamSpy.mockRestore();
2559
+ }
1668
2560
  }
1669
2561
  finally {
1670
2562
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -1999,6 +2891,222 @@ describe('Taskforce MCP tool descriptions', () => {
1999
2891
  fs.rmSync(projectRoot, { recursive: true, force: true });
2000
2892
  }
2001
2893
  });
2894
+ it('supports staged remote image uploads for save_task_attachment in cloud runtime', async () => {
2895
+ const projectRoot = createProjectRoot();
2896
+ const previousEnv = {
2897
+ provider: process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER,
2898
+ accountId: process.env.R2_ACCOUNT_ID,
2899
+ bucket: process.env.R2_BUCKET,
2900
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
2901
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
2902
+ endpoint: process.env.R2_ENDPOINT,
2903
+ uploadTtl: process.env.R2_SIGNED_UPLOAD_TTL_SECONDS,
2904
+ };
2905
+ try {
2906
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = 'r2';
2907
+ process.env.R2_ACCOUNT_ID = 'acct-1';
2908
+ process.env.R2_BUCKET = 'taskforce-test';
2909
+ process.env.R2_ACCESS_KEY_ID = 'access-key';
2910
+ process.env.R2_SECRET_ACCESS_KEY = 'secret-key';
2911
+ process.env.R2_ENDPOINT = 'https://example.r2.invalid';
2912
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = '600';
2913
+ const core = new TaskforceCore({
2914
+ projectRoot,
2915
+ storagePath: path.join(projectRoot, '.taskforce')
2916
+ });
2917
+ const task = core.createTask({ title: 'Cloud staged image attachment test' }, { workspaceId: 'workspace-1' });
2918
+ core.close();
2919
+ const fetchMock = vi.fn(async (input, init) => {
2920
+ const method = String(init?.method || 'GET').toUpperCase();
2921
+ if (method === 'GET') {
2922
+ return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
2923
+ status: 200,
2924
+ headers: { 'content-type': 'image/png' }
2925
+ });
2926
+ }
2927
+ return new Response('', { status: 200 });
2928
+ });
2929
+ vi.stubGlobal('fetch', fetchMock);
2930
+ const staged = parseToolJson(await callTool(projectRoot, 'cloud', 'save_task_attachment', {
2931
+ id: task.referenceLabel,
2932
+ filename: 'proof.png',
2933
+ mimeType: 'image/png',
2934
+ sizeBytes: 4
2935
+ }));
2936
+ expect(staged.mode).toBe('staged_upload');
2937
+ expect(staged.status).toBe('upload_required');
2938
+ expect(staged.filename).toBe('proof.png');
2939
+ expect(staged.upload.method).toBe('PUT');
2940
+ expect(staged.upload.headers['Content-Type']).toBe('image/png');
2941
+ expect(staged.upload.uploadUrl).toContain('X-Amz-Signature=');
2942
+ expect(staged.upload.uploadedPath).toContain('workspaces/workspace-1/assets/images/');
2943
+ expect(staged.uploadDraftId).toContain('task-attachment-draft-');
2944
+ expect(staged.finalize.tool).toBe('save_task_attachment');
2945
+ expect(staged.finalize.arguments.uploadDraftId).toBe(staged.uploadDraftId);
2946
+ expect(staged.finalize.arguments.uploadedPath).toBe(staged.upload.uploadedPath);
2947
+ await callTool(projectRoot, 'cloud', 'save_task_attachment', {
2948
+ id: task.referenceLabel,
2949
+ filename: 'proof.png',
2950
+ mimeType: 'image/png',
2951
+ uploadDraftId: staged.uploadDraftId,
2952
+ uploadedPath: staged.upload.uploadedPath
2953
+ });
2954
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_task_attachments', { id: task.referenceLabel }));
2955
+ expect(listed.attachments).toHaveLength(1);
2956
+ expect(listed.attachments[0]).toMatchObject({
2957
+ kind: 'image',
2958
+ mimeType: 'image/png',
2959
+ originalFilename: 'proof.png',
2960
+ readableAsText: false,
2961
+ });
2962
+ const verifyCore = new TaskforceCore({
2963
+ projectRoot,
2964
+ storagePath: path.join(projectRoot, '.taskforce')
2965
+ });
2966
+ try {
2967
+ const updated = verifyCore.getTask(task.id, 'workspace-1');
2968
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
2969
+ const asset = store.get(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
2970
+ expect(updated?.attachments).toHaveLength(1);
2971
+ expect(updated?.attachments?.[0]).toMatchObject({
2972
+ path: expect.stringMatching(/^\/api\/taskforce\/context\//),
2973
+ originalFilename: 'proof.png',
2974
+ });
2975
+ expect(asset).toEqual(expect.objectContaining({
2976
+ kind: 'image',
2977
+ mimeType: 'image/png',
2978
+ storageProvider: 'r2',
2979
+ storageKey: staged.upload.uploadedPath,
2980
+ }));
2981
+ }
2982
+ finally {
2983
+ verifyCore.close();
2984
+ }
2985
+ }
2986
+ finally {
2987
+ vi.unstubAllGlobals();
2988
+ if (previousEnv.provider === undefined)
2989
+ delete process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER;
2990
+ else
2991
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = previousEnv.provider;
2992
+ if (previousEnv.accountId === undefined)
2993
+ delete process.env.R2_ACCOUNT_ID;
2994
+ else
2995
+ process.env.R2_ACCOUNT_ID = previousEnv.accountId;
2996
+ if (previousEnv.bucket === undefined)
2997
+ delete process.env.R2_BUCKET;
2998
+ else
2999
+ process.env.R2_BUCKET = previousEnv.bucket;
3000
+ if (previousEnv.accessKeyId === undefined)
3001
+ delete process.env.R2_ACCESS_KEY_ID;
3002
+ else
3003
+ process.env.R2_ACCESS_KEY_ID = previousEnv.accessKeyId;
3004
+ if (previousEnv.secretAccessKey === undefined)
3005
+ delete process.env.R2_SECRET_ACCESS_KEY;
3006
+ else
3007
+ process.env.R2_SECRET_ACCESS_KEY = previousEnv.secretAccessKey;
3008
+ if (previousEnv.endpoint === undefined)
3009
+ delete process.env.R2_ENDPOINT;
3010
+ else
3011
+ process.env.R2_ENDPOINT = previousEnv.endpoint;
3012
+ if (previousEnv.uploadTtl === undefined)
3013
+ delete process.env.R2_SIGNED_UPLOAD_TTL_SECONDS;
3014
+ else
3015
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = previousEnv.uploadTtl;
3016
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3017
+ }
3018
+ });
3019
+ it('rejects forged staged upload finalization for save_task_attachment in cloud runtime', async () => {
3020
+ const projectRoot = createProjectRoot();
3021
+ const previousEnv = {
3022
+ provider: process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER,
3023
+ accountId: process.env.R2_ACCOUNT_ID,
3024
+ bucket: process.env.R2_BUCKET,
3025
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
3026
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
3027
+ endpoint: process.env.R2_ENDPOINT,
3028
+ uploadTtl: process.env.R2_SIGNED_UPLOAD_TTL_SECONDS,
3029
+ };
3030
+ try {
3031
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = 'r2';
3032
+ process.env.R2_ACCOUNT_ID = 'acct-1';
3033
+ process.env.R2_BUCKET = 'taskforce-test';
3034
+ process.env.R2_ACCESS_KEY_ID = 'access-key';
3035
+ process.env.R2_SECRET_ACCESS_KEY = 'secret-key';
3036
+ process.env.R2_ENDPOINT = 'https://example.r2.invalid';
3037
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = '600';
3038
+ const core = new TaskforceCore({
3039
+ projectRoot,
3040
+ storagePath: path.join(projectRoot, '.taskforce')
3041
+ });
3042
+ const task = core.createTask({ title: 'Cloud staged image attachment reject test' }, { workspaceId: 'workspace-1' });
3043
+ core.close();
3044
+ const fetchMock = vi.fn(async (input, init) => {
3045
+ const method = String(init?.method || 'GET').toUpperCase();
3046
+ if (method === 'GET') {
3047
+ return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
3048
+ status: 200,
3049
+ headers: { 'content-type': 'image/png' }
3050
+ });
3051
+ }
3052
+ return new Response('', { status: 200 });
3053
+ });
3054
+ vi.stubGlobal('fetch', fetchMock);
3055
+ const staged = parseToolJson(await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3056
+ id: task.referenceLabel,
3057
+ filename: 'proof.png',
3058
+ mimeType: 'image/png',
3059
+ sizeBytes: 4
3060
+ }));
3061
+ const bareResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3062
+ id: task.referenceLabel,
3063
+ filename: 'proof.png',
3064
+ mimeType: 'image/png',
3065
+ uploadedPath: staged.upload.uploadedPath
3066
+ });
3067
+ expect(bareResult.content?.[0]?.text).toContain('uploadedPath finalization requires uploadDraftId');
3068
+ const forgedResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3069
+ id: task.referenceLabel,
3070
+ filename: 'proof.png',
3071
+ mimeType: 'image/png',
3072
+ uploadDraftId: staged.uploadDraftId,
3073
+ uploadedPath: 'workspaces/workspace-1/assets/images/asset-deadbeefdeadbeefdeadbeefdeadbeef-proof.png'
3074
+ });
3075
+ expect(forgedResult.content?.[0]?.text).toContain('uploadedPath does not match the staged upload draft');
3076
+ }
3077
+ finally {
3078
+ vi.unstubAllGlobals();
3079
+ if (previousEnv.provider === undefined)
3080
+ delete process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER;
3081
+ else
3082
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = previousEnv.provider;
3083
+ if (previousEnv.accountId === undefined)
3084
+ delete process.env.R2_ACCOUNT_ID;
3085
+ else
3086
+ process.env.R2_ACCOUNT_ID = previousEnv.accountId;
3087
+ if (previousEnv.bucket === undefined)
3088
+ delete process.env.R2_BUCKET;
3089
+ else
3090
+ process.env.R2_BUCKET = previousEnv.bucket;
3091
+ if (previousEnv.accessKeyId === undefined)
3092
+ delete process.env.R2_ACCESS_KEY_ID;
3093
+ else
3094
+ process.env.R2_ACCESS_KEY_ID = previousEnv.accessKeyId;
3095
+ if (previousEnv.secretAccessKey === undefined)
3096
+ delete process.env.R2_SECRET_ACCESS_KEY;
3097
+ else
3098
+ process.env.R2_SECRET_ACCESS_KEY = previousEnv.secretAccessKey;
3099
+ if (previousEnv.endpoint === undefined)
3100
+ delete process.env.R2_ENDPOINT;
3101
+ else
3102
+ process.env.R2_ENDPOINT = previousEnv.endpoint;
3103
+ if (previousEnv.uploadTtl === undefined)
3104
+ delete process.env.R2_SIGNED_UPLOAD_TTL_SECONDS;
3105
+ else
3106
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = previousEnv.uploadTtl;
3107
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3108
+ }
3109
+ });
2002
3110
  it('allows generic markdown review attachments without requiring the legacy review template', async () => {
2003
3111
  const projectRoot = createProjectRoot();
2004
3112
  try {
@@ -2608,6 +3716,146 @@ describe('Taskforce MCP tool descriptions', () => {
2608
3716
  fs.rmSync(projectRoot, { recursive: true, force: true });
2609
3717
  }
2610
3718
  });
3719
+ it('saves, lists, and reads workstream attachments by workstream reference', async () => {
3720
+ const projectRoot = createProjectRoot();
3721
+ try {
3722
+ const core = new TaskforceCore({
3723
+ projectRoot,
3724
+ storagePath: path.join(projectRoot, '.taskforce')
3725
+ });
3726
+ const workstream = core.createWorkstream({ title: 'Attachment workstream' }, 'workspace-1');
3727
+ core.close();
3728
+ await callTool(projectRoot, 'cloud', 'save_workstream_attachment', {
3729
+ id: workstream.referenceLabel,
3730
+ filename: 'findings.md',
3731
+ content: '# Findings\nWorkstream-specific notes',
3732
+ caption: 'Security findings'
3733
+ });
3734
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream_attachments', { id: workstream.referenceLabel }));
3735
+ const readBack = parseToolJson(await callTool(projectRoot, 'cloud', 'read_workstream_attachment', {
3736
+ workstreamId: workstream.referenceLabel,
3737
+ attachmentId: listed.attachments[0]?.attachmentId
3738
+ }));
3739
+ const workstreamPayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream', { id: workstream.referenceLabel }));
3740
+ const taskToolResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3741
+ id: workstream.referenceLabel,
3742
+ filename: 'wrong-tool.md',
3743
+ content: '# Wrong tool'
3744
+ });
3745
+ const verifyCore = new TaskforceCore({
3746
+ projectRoot,
3747
+ storagePath: path.join(projectRoot, '.taskforce')
3748
+ });
3749
+ try {
3750
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
3751
+ const links = store.listLinksForAsset(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
3752
+ expect(listed.workstreamId).toBe(workstream.id);
3753
+ expect(listed.referenceLabel).toBe(workstream.referenceLabel);
3754
+ expect(listed.attachments).toHaveLength(1);
3755
+ expect(listed.attachments[0]?.kind).toBe('document');
3756
+ expect(listed.attachments[0]?.readableAsText).toBe(true);
3757
+ expect(readBack.workstreamId).toBe(workstream.id);
3758
+ expect(readBack.content).toContain('Workstream-specific notes');
3759
+ expect(workstreamPayload.workstream.attachments).toHaveLength(1);
3760
+ expect(workstreamPayload.workstream.attachments[0]).toMatchObject({
3761
+ assetId: listed.attachments[0]?.assetId,
3762
+ referenceLabel: listed.attachments[0]?.referenceLabel,
3763
+ originalFilename: 'findings.md',
3764
+ });
3765
+ expect(links).toEqual([
3766
+ expect.objectContaining({
3767
+ targetType: 'workstream',
3768
+ targetId: workstream.id,
3769
+ role: 'attachment'
3770
+ })
3771
+ ]);
3772
+ expect(taskToolResult.content?.[0]?.text).toContain(`Task ${workstream.referenceLabel} not found`);
3773
+ }
3774
+ finally {
3775
+ verifyCore.close();
3776
+ }
3777
+ }
3778
+ finally {
3779
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3780
+ }
3781
+ });
3782
+ it('saves and deletes initiative attachments by initiative reference', async () => {
3783
+ const projectRoot = createProjectRoot();
3784
+ try {
3785
+ const core = new TaskforceCore({
3786
+ projectRoot,
3787
+ storagePath: path.join(projectRoot, '.taskforce')
3788
+ });
3789
+ const initiative = core.createInitiative({ title: 'Attachment initiative' }, 'workspace-1');
3790
+ core.close();
3791
+ await callTool(projectRoot, 'cloud', 'save_initiative_attachment', {
3792
+ id: initiative.referenceLabel,
3793
+ filename: 'initiative-note.txt',
3794
+ content: 'initiative attachment body'
3795
+ });
3796
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative_attachments', { id: initiative.referenceLabel }));
3797
+ const readBack = parseToolJson(await callTool(projectRoot, 'cloud', 'read_initiative_attachment', {
3798
+ initiativeId: initiative.referenceLabel,
3799
+ attachmentId: listed.attachments[0]?.attachmentId
3800
+ }));
3801
+ const deleted = parseToolJson(await callTool(projectRoot, 'cloud', 'delete_initiative_attachment', {
3802
+ initiativeId: initiative.referenceLabel,
3803
+ attachmentId: listed.attachments[0]?.attachmentId
3804
+ }));
3805
+ const afterDelete = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative_attachments', { id: initiative.referenceLabel }));
3806
+ const verifyCore = new TaskforceCore({
3807
+ projectRoot,
3808
+ storagePath: path.join(projectRoot, '.taskforce')
3809
+ });
3810
+ try {
3811
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
3812
+ const asset = store.get(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
3813
+ expect(listed.initiativeId).toBe(initiative.id);
3814
+ expect(listed.attachments).toHaveLength(1);
3815
+ expect(listed.attachments[0]?.mimeType).toBe('text/plain');
3816
+ expect(readBack.content).toContain('initiative attachment body');
3817
+ expect(deleted.success).toBe(true);
3818
+ expect(deleted.canonicalAssetDeleted).toBe(true);
3819
+ expect(afterDelete.attachments).toEqual([]);
3820
+ expect(asset?.deletedAt).toBeTruthy();
3821
+ }
3822
+ finally {
3823
+ verifyCore.close();
3824
+ }
3825
+ }
3826
+ finally {
3827
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3828
+ }
3829
+ });
3830
+ it('persists planning attachment descriptor payloads through update tools', async () => {
3831
+ const projectRoot = createProjectRoot();
3832
+ try {
3833
+ const core = new TaskforceCore({
3834
+ projectRoot,
3835
+ storagePath: path.join(projectRoot, '.taskforce')
3836
+ });
3837
+ const initiative = core.createInitiative({ title: 'Descriptor initiative' }, 'workspace-1');
3838
+ const workstream = core.createWorkstream({ title: 'Descriptor workstream' }, 'workspace-1');
3839
+ core.close();
3840
+ const initiativeAttachment = { path: 'https://example.com/initiative-register.md', caption: 'Initiative register' };
3841
+ const workstreamAttachment = { path: 'https://example.com/workstream-register.md', caption: 'Workstream register' };
3842
+ await callTool(projectRoot, 'cloud', 'update_initiative', {
3843
+ id: initiative.referenceLabel,
3844
+ attachments: [initiativeAttachment]
3845
+ });
3846
+ await callTool(projectRoot, 'cloud', 'update_workstream', {
3847
+ id: workstream.referenceLabel,
3848
+ attachments: [workstreamAttachment]
3849
+ });
3850
+ const initiativePayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative', { id: initiative.referenceLabel }));
3851
+ const workstreamPayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream', { id: workstream.referenceLabel }));
3852
+ expect(initiativePayload.initiative.attachments).toEqual([expect.objectContaining(initiativeAttachment)]);
3853
+ expect(workstreamPayload.workstream.attachments).toEqual([expect.objectContaining(workstreamAttachment)]);
3854
+ }
3855
+ finally {
3856
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3857
+ }
3858
+ });
2611
3859
  it('accepts task references for write and batch update tools', async () => {
2612
3860
  const projectRoot = createProjectRoot();
2613
3861
  try {
@@ -2645,6 +3893,52 @@ describe('Taskforce MCP tool descriptions', () => {
2645
3893
  fs.rmSync(projectRoot, { recursive: true, force: true });
2646
3894
  }
2647
3895
  });
3896
+ it('accepts documented schedule helper arguments', async () => {
3897
+ const projectRoot = createProjectRoot();
3898
+ try {
3899
+ const core = new TaskforceCore({
3900
+ projectRoot,
3901
+ storagePath: path.join(projectRoot, '.taskforce')
3902
+ });
3903
+ const first = core.createTask({ title: 'First documented schedule helper test' }, { workspaceId: 'workspace-1' });
3904
+ const second = core.createTask({ title: 'Second documented schedule helper test' }, { workspaceId: 'workspace-1' });
3905
+ core.close();
3906
+ const board = parseToolJson(await callTool(projectRoot, 'local', 'get_schedule_board', {}));
3907
+ expect(board.week).toMatch(/^\d{4}-W\d{2}$/);
3908
+ const scheduled = parseToolJson(await callTool(projectRoot, 'local', 'schedule_task', {
3909
+ taskId: first.referenceLabel,
3910
+ date: '2099-01-05',
3911
+ order: 3
3912
+ }));
3913
+ expect(scheduled.scheduling.persistedDate).toBe('2099-01-05');
3914
+ expect(scheduled.scheduling.persistedOrderInDay).toBe(3);
3915
+ await callTool(projectRoot, 'local', 'reorder_scheduled_tasks', {
3916
+ date: '2099-01-05',
3917
+ taskIds: [second.referenceLabel, first.referenceLabel]
3918
+ });
3919
+ await callTool(projectRoot, 'local', 'unschedule_task', {
3920
+ taskId: first.referenceLabel
3921
+ });
3922
+ const after = new TaskforceCore({
3923
+ projectRoot,
3924
+ storagePath: path.join(projectRoot, '.taskforce')
3925
+ });
3926
+ try {
3927
+ const firstTask = after.getTask(first.id, 'workspace-1');
3928
+ const secondTask = after.getTask(second.id, 'workspace-1');
3929
+ expect(firstTask?.scheduledDate).toBeNull();
3930
+ expect(firstTask?.orderInDay).toBeNull();
3931
+ expect(secondTask?.scheduledDate).toBe('2099-01-05');
3932
+ expect(secondTask?.orderInDay).toBe(0);
3933
+ }
3934
+ finally {
3935
+ after.close();
3936
+ }
3937
+ }
3938
+ finally {
3939
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3940
+ }
3941
+ });
2648
3942
  it('maps legacy agent assignee writes to unassigned even when a profile is resolved', async () => {
2649
3943
  const projectRoot = createProjectRoot();
2650
3944
  try {
@@ -2731,6 +4025,7 @@ describe('Taskforce MCP tool descriptions', () => {
2731
4025
  label: 'Codex',
2732
4026
  username: resolved.profile.username
2733
4027
  }));
4028
+ expect(created.createdByActor.meta).not.toHaveProperty('profileToken');
2734
4029
  expect(created.taskEvents).toEqual(expect.arrayContaining([
2735
4030
  expect.objectContaining({
2736
4031
  action: 'task-created',
@@ -2743,6 +4038,7 @@ describe('Taskforce MCP tool descriptions', () => {
2743
4038
  })
2744
4039
  })
2745
4040
  ]));
4041
+ expect(created.taskEvents[0].actorProfile.meta).not.toHaveProperty('profileToken');
2746
4042
  }
2747
4043
  finally {
2748
4044
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -3216,6 +4512,61 @@ describe('Taskforce MCP annotated attachment tools', () => {
3216
4512
  fs.rmSync(projectRoot, { recursive: true, force: true });
3217
4513
  }
3218
4514
  });
4515
+ it('accepts exposed document and image helper argument names', async () => {
4516
+ const projectRoot = createProjectRoot();
4517
+ try {
4518
+ const { task, session } = seedAnnotatedAttachmentFixture(projectRoot);
4519
+ const { asset } = seedDocumentReviewFixture(projectRoot);
4520
+ const taskAttachments = parseToolJson(await callTool(projectRoot, 'cloud', 'get_task_attachments', {
4521
+ taskId: task.referenceLabel,
4522
+ }, { workspaceId: 'workspace-1' }));
4523
+ expect(taskAttachments.taskId).toBe(task.id);
4524
+ expect(taskAttachments.referenceLabel).toBe(task.referenceLabel);
4525
+ const document = parseToolJson(await callTool(projectRoot, 'cloud', 'get_document', {
4526
+ id: 'D-123',
4527
+ }, { workspaceId: 'workspace-1' }));
4528
+ expect(document.document).toMatchObject({
4529
+ assetId: asset.assetId,
4530
+ referenceLabel: 'D-123',
4531
+ });
4532
+ const review = parseToolJson(await callTool(projectRoot, 'cloud', 'review_document', {
4533
+ documentId: 'D-123',
4534
+ prompt: 'Capture this as a first-pass review note.',
4535
+ }, { workspaceId: 'workspace-1' }));
4536
+ expect(review.document.assetId).toBe(asset.assetId);
4537
+ expect(review.comments).toEqual(expect.arrayContaining([
4538
+ expect.objectContaining({
4539
+ body: 'Capture this as a first-pass review note.',
4540
+ }),
4541
+ ]));
4542
+ expect(review.session.comments.some((comment) => comment.body === 'Capture this as a first-pass review note.')).toBe(true);
4543
+ const image = parseToolJson(await callTool(projectRoot, 'cloud', 'get_image', {
4544
+ id: 'I-1',
4545
+ }, { workspaceId: 'workspace-1' }));
4546
+ expect(image.image).toMatchObject({
4547
+ assetId: 'asset-annotated-1',
4548
+ referenceLabel: 'I-1',
4549
+ });
4550
+ const marker = parseToolJson(await callTool(projectRoot, 'cloud', 'add_annotated_attachment_marker', {
4551
+ sessionId: session.id,
4552
+ type: 'note',
4553
+ text: 'Schema names should work end to end.',
4554
+ x: 0.4,
4555
+ y: 0.5,
4556
+ }, { workspaceId: 'workspace-1' }));
4557
+ expect(marker.marker).toMatchObject({
4558
+ kind: 'text-note',
4559
+ instruction: 'Schema names should work end to end.',
4560
+ });
4561
+ expect(marker.session.annotations.at(-1)).toMatchObject({
4562
+ kind: 'text-note',
4563
+ instruction: 'Schema names should work end to end.',
4564
+ });
4565
+ }
4566
+ finally {
4567
+ fs.rmSync(projectRoot, { recursive: true, force: true });
4568
+ }
4569
+ });
3219
4570
  it('creates image-note sessions and mutates markers through MCP', async () => {
3220
4571
  const projectRoot = createProjectRoot();
3221
4572
  try {