@taskforcehq/taskforce 0.3.302 → 0.3.304

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 (192) hide show
  1. package/dist/Taskforce.module.css +113 -5
  2. package/dist/TaskforceCore.js +560 -208
  3. package/dist/TaskforceCore.test.js +1833 -91
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskKanban.test.js +17 -0
  21. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  22. package/dist/components/task/TaskStatusActions.js +8 -3
  23. package/dist/components/task/TaskStatusActions.test.js +9 -0
  24. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  25. package/dist/components/task/taskKanbanDropRules.js +187 -0
  26. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  27. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  28. package/dist/components/ui/Modal.d.ts +1 -1
  29. package/dist/components/ui/Modal.js +1 -0
  30. package/dist/components/views/AppShellHeader.js +5 -1
  31. package/dist/components/views/AppShellHeader.test.js +4 -0
  32. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  33. package/dist/components/views/PlanComparisonPage.js +4 -2
  34. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  35. package/dist/components/views/PlansPage.d.ts +7 -1
  36. package/dist/components/views/PlansPage.js +139 -53
  37. package/dist/components/views/PlansPage.test.d.ts +1 -0
  38. package/dist/components/views/PlansPage.test.js +157 -0
  39. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  40. package/dist/components/views/StandaloneLayout.js +216 -186
  41. package/dist/components/views/WidgetView.js +11 -2
  42. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  43. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  44. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  45. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  46. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  47. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  48. package/dist/core/EntitlementsPolicy.test.js +2 -2
  49. package/dist/core/GlobalSettingsService.js +78 -18
  50. package/dist/core/PlanEntitlementService.d.ts +31 -5
  51. package/dist/core/PlanEntitlementService.js +350 -222
  52. package/dist/core/SignupMonetizationSettings.test.js +208 -49
  53. package/dist/core/SystemAdmin.test.js +186 -96
  54. package/dist/core/TaskActivityService.d.ts +4 -1
  55. package/dist/core/TaskActivityService.js +45 -0
  56. package/dist/core/TaskAuditTimeline.test.js +52 -1
  57. package/dist/core/Taskforce.d.ts +65 -20
  58. package/dist/core/Taskforce.js +261 -51
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  61. package/dist/core/WorkspacePlanMode.test.js +65 -0
  62. package/dist/core/types.d.ts +11 -12
  63. package/dist/documentReviews/service.d.ts +1 -1
  64. package/dist/documentReviews/types.d.ts +1 -1
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  66. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  67. package/dist/hooks/sync/auth.d.ts +37 -0
  68. package/dist/hooks/sync/auth.js +61 -0
  69. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  70. package/dist/hooks/sync/bootstrap.js +52 -0
  71. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  72. package/dist/hooks/sync/orchestratorShared.js +209 -0
  73. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  74. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  75. package/dist/hooks/sync/realtime.d.ts +25 -0
  76. package/dist/hooks/sync/realtime.js +60 -0
  77. package/dist/hooks/sync/recovery.d.ts +69 -0
  78. package/dist/hooks/sync/recovery.js +118 -0
  79. package/dist/hooks/sync/transfers.d.ts +122 -0
  80. package/dist/hooks/sync/transfers.js +431 -0
  81. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  82. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  83. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  84. package/dist/hooks/ui/useResourceExport.js +5 -2
  85. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  86. package/dist/hooks/useRealtimeSync.js +2 -1
  87. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  88. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  89. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  90. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  91. package/dist/hooks/useSyncOrchestrator.js +315 -860
  92. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +665 -8
  93. package/dist/hooks/useTaskMutations.js +45 -6
  94. package/dist/hooks/useTaskMutations.test.js +147 -0
  95. package/dist/hooks/useTaskforce.d.ts +11 -2
  96. package/dist/hooks/useTaskforce.js +290 -812
  97. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  98. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  99. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  100. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  101. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  102. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  103. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  104. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  105. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  106. package/dist/localization/locales/en-US.js +2 -2
  107. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  108. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  109. package/dist/mcp/clientIntegrations.d.ts +12 -1
  110. package/dist/mcp/clientIntegrations.js +161 -6
  111. package/dist/mcp/clientIntegrations.test.js +76 -4
  112. package/dist/mcp/documentHelpers.d.ts +100 -0
  113. package/dist/mcp/documentHelpers.js +161 -0
  114. package/dist/mcp/runtime.js +264 -505
  115. package/dist/mcp/runtime.test.js +353 -89
  116. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  117. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  118. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  119. package/dist/mcp/taskAttachmentTypes.js +1 -0
  120. package/dist/migrations/billingSchemaParity.test.js +3 -0
  121. package/dist/migrations/taskSchemaMigrations.js +15 -0
  122. package/dist/plugins/vite.js +1 -1
  123. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  124. package/dist/runtime/appGateDefinitions.js +1 -1
  125. package/dist/server/index.cookieProxy.test.js +1 -0
  126. package/dist/server/index.d.ts +1 -0
  127. package/dist/server/index.js +48 -3
  128. package/dist/server/index.test.js +14 -2
  129. package/dist/server/mockStripe.d.ts +1 -0
  130. package/dist/server/mockStripe.js +2 -0
  131. package/dist/server/routes/admin.js +91 -40
  132. package/dist/server/routes/auth.d.ts +1 -0
  133. package/dist/server/routes/auth.js +231 -55
  134. package/dist/server/routes/billing.js +473 -39
  135. package/dist/server/routes/billing.test.js +972 -37
  136. package/dist/server/routes/shared.d.ts +30 -0
  137. package/dist/server/routes/shared.js +31 -3
  138. package/dist/server/routes/sync.integration.test.js +180 -0
  139. package/dist/server/routes/sync.js +125 -242
  140. package/dist/server/routes/tasks.js +49 -7
  141. package/dist/server/routes.js +11 -22
  142. package/dist/server/routes.test.js +530 -81
  143. package/dist/shared/taskActor.d.ts +10 -0
  144. package/dist/shared/taskActor.js +1 -0
  145. package/dist/sync/cloudSyncApi.d.ts +1 -0
  146. package/dist/sync/collaborationSyncPayload.js +4 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -1
  149. package/dist/sync/collaborationSyncState.js +19 -0
  150. package/dist/sync/contentSyncState.d.ts +10 -0
  151. package/dist/sync/contentSyncState.js +277 -14
  152. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  153. package/dist/sync/syncApplyHandlers.js +370 -8
  154. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  155. package/dist/sync/syncApplyHandlers.test.js +377 -0
  156. package/dist/sync/syncService.d.ts +9 -1
  157. package/dist/sync/syncService.js +57 -10
  158. package/dist/sync/syncService.test.js +48 -0
  159. package/dist/sync/workspacePullFeed.d.ts +11 -0
  160. package/dist/sync/workspacePullFeed.js +123 -68
  161. package/dist/sync/workspacePullFeed.test.js +180 -0
  162. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  163. package/dist/sync/workspaceSyncModel.js +65 -0
  164. package/dist/sync/workspaceSyncModel.test.js +119 -0
  165. package/dist/types.d.ts +2 -10
  166. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  167. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  168. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  169. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-BjR6iBf9.js} +1 -1
  170. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  171. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  172. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  173. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-DnFqdUME.js} +1 -1
  174. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  175. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  176. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-I7ZwG1z_.js} +1 -1
  177. package/dist/ui/index.html +4 -4
  178. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  179. package/dist/utils/accountProfileSummaryCache.js +97 -0
  180. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  181. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  182. package/dist/utils/taskActivity.js +4 -0
  183. package/dist/utils/taskActivity.test.js +25 -1
  184. package/package.json +5 -3
  185. package/scripts/playwright-auth.sh +4 -0
  186. package/dist/ui/assets/AgentsModule-JG5jc3he.js +0 -1
  187. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  188. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +0 -8
  189. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  190. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  191. package/dist/utils/billingStatusCache.d.ts +0 -21
  192. package/dist/utils/billingStatusCache.js +0 -70
@@ -3,10 +3,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
- import { createHash, randomUUID } from 'node:crypto';
6
+ import { randomUUID } from 'node:crypto';
7
+ import * as os from 'node:os';
7
8
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce.js'; // Note .js extension for ESM
8
9
  import { COMPLEXITY_OPTIONS, resolveNumberOption, resolveStringOption, STATUS_OPTIONS } from './optionResolution.js';
9
10
  import { buildResolveAiProfileInputFromBootstrap } from './aiProfileBootstrap.js';
11
+ import { createCanonicalAssetHelpers } from './canonicalAssetHelpers.js';
12
+ import { createDocumentHelpers } from './documentHelpers.js';
13
+ import { createTaskAttachmentHelpers } from './taskAttachmentHelpers.js';
10
14
  import { validateStructuredDocForAttach } from './structuredDocValidation.js';
11
15
  import { validateStructuredCommentForAdd } from './structuredCommentValidation.js';
12
16
  import { resolveDatabaseConfigFromEnv } from '../storage/providerConfig.js';
@@ -282,300 +286,38 @@ export function createTaskforceMcpServer(options = {}) {
282
286
  ? new ObjectStorageClient(objectStorageConfig)
283
287
  : null;
284
288
  const { basePath } = core.getPaths();
285
- const normalizeAttachmentStorageKey = (pathValue, fsPathValue) => {
286
- const fsPathRaw = String(fsPathValue || '').trim().replace(/\\/g, '/');
287
- if (fsPathRaw) {
288
- return fsPathRaw.replace(/^\.taskforce\//, '').trim() || null;
289
- }
290
- const pathRaw = String(pathValue || '').trim();
291
- if (!pathRaw)
292
- return null;
293
- if (/^https?:\/\//i.test(pathRaw))
294
- return pathRaw;
295
- if (pathRaw.startsWith('/api/taskforce/context/')) {
296
- return decodeURIComponent(pathRaw.slice('/api/taskforce/context/'.length)).trim() || null;
297
- }
298
- return null;
299
- };
300
- const buildSyntheticAttachmentId = (taskId, attachment) => {
301
- const stableSource = [
302
- taskId,
303
- String(attachment.path || '').trim(),
304
- String(attachment.fsPath || '').trim(),
305
- String(attachment.caption || '').trim(),
306
- ].join('::');
307
- return `attachment-${createHash('sha256').update(stableSource).digest('hex').slice(0, 24)}`;
308
- };
309
- const getAttachmentAssetRecord = (attachment) => {
310
- if (!workspaceAssetStore)
311
- return null;
312
- const assetId = String(attachment.assetId || '').trim();
313
- if (assetId) {
314
- const byId = workspaceAssetStore.get(assetId, ACTIVE_WORKSPACE_ID);
315
- if (byId && !byId.deletedAt)
316
- return byId;
317
- }
318
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
319
- if (!storageKey || /^https?:\/\//i.test(storageKey))
320
- return null;
321
- const byStorageKey = workspaceAssetStore.getByStorageKey(storageKey, ACTIVE_WORKSPACE_ID);
322
- if (byStorageKey && !byStorageKey.deletedAt)
323
- return byStorageKey;
324
- return null;
325
- };
326
- const isCanonicalDocumentAssetRecord = (asset) => isValidCanonicalDocumentAsset(asset);
327
- const toTaskAttachmentRecord = (attachment) => {
328
- if (typeof attachment === 'string') {
329
- return { path: attachment };
330
- }
331
- return attachment;
332
- };
333
- const describeTaskAttachment = (taskId, attachmentInput) => {
334
- const attachment = toTaskAttachmentRecord(attachmentInput);
335
- const asset = getAttachmentAssetRecord(attachment);
336
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
337
- const assetId = String(attachment.assetId || asset?.assetId || '').trim() || undefined;
338
- const source = (asset?.storageProvider === 'external' || /^https?:\/\//i.test(String(attachment.path || '').trim()))
339
- ? 'external'
340
- : 'canonical';
341
- const mimeType = String(asset?.mimeType
342
- || (storageKey && !/^https?:\/\//i.test(storageKey) ? inferMimeTypeFromPath(storageKey) : '')
343
- || inferMimeTypeFromPath(String(attachment.originalFilename || attachment.displayName || attachment.caption || attachment.path || ''))).trim() || 'application/octet-stream';
344
- const kind = asset?.kind || inferAssetKind(storageKey || String(attachment.path || ''), mimeType);
345
- const isCanonicalDocument = Boolean(asset && isCanonicalDocumentAssetRecord(asset)) || (!asset && kind === 'document');
346
- const referenceNumber = typeof attachment.referenceNumber === 'number'
347
- ? attachment.referenceNumber
348
- : (typeof asset?.referenceNumber === 'number' ? asset.referenceNumber : null);
349
- const referenceLabel = String(attachment.referenceLabel
350
- || (kind === 'image' ? getImageReferenceLabel(asset) : '')
351
- || (isCanonicalDocument ? getDocumentReferenceLabel(asset) : '')).trim() || null;
352
- const readableAsText = source === 'canonical' && isTextReadableMimeType(mimeType);
353
- const canonicalDocumentHint = isCanonicalDocument && source === 'canonical' && asset
354
- ? buildCanonicalDocumentAttachmentHint(asset)
355
- : null;
356
- return {
357
- attachmentId: assetId || buildSyntheticAttachmentId(taskId, attachment),
358
- ...(assetId ? { assetId } : {}),
359
- ...(referenceNumber ? { referenceNumber } : {}),
360
- ...(referenceLabel ? { referenceLabel } : {}),
361
- ...(canonicalDocumentHint?.documentId ? { documentId: canonicalDocumentHint.documentId } : {}),
362
- path: String(attachment.path || '').trim(),
363
- ...(storageKey && !/^https?:\/\//i.test(storageKey) ? { fsPath: storageKey } : (attachment.fsPath ? { fsPath: String(attachment.fsPath).trim() } : {})),
364
- ...(attachment.caption ? { caption: String(attachment.caption).trim() } : {}),
365
- ...(attachment.displayName ? { displayName: String(attachment.displayName).trim() } : {}),
366
- ...(attachment.originalFilename ? { originalFilename: String(attachment.originalFilename).trim() } : {}),
367
- kind: isCanonicalDocument ? 'document' : kind,
368
- mimeType,
369
- sizeBytes: asset ? Number(asset.sizeBytes || 0) : null,
370
- ...(attachment.timestamp ? { timestamp: String(attachment.timestamp).trim() } : {}),
371
- source,
372
- readableAsText,
373
- ...(canonicalDocumentHint ? {
374
- preferredNextTool: canonicalDocumentHint.preferredNextTool,
375
- preferredWorkflow: canonicalDocumentHint.preferredWorkflow,
376
- suggestedArgs: canonicalDocumentHint.suggestedArgs,
377
- guidance: canonicalDocumentHint.guidance,
378
- } : {}),
379
- };
380
- };
381
- const listTaskAttachmentDescriptors = (task) => {
382
- if (!task || !Array.isArray(task.attachments))
383
- return [];
384
- return task.attachments.map((attachment) => describeTaskAttachment(task.id, attachment));
385
- };
386
- const buildAttachmentDownloadUrl = (attachment) => {
387
- if (attachment.source === 'external')
388
- return attachment.path;
389
- const safeName = encodeURIComponent(String(attachment.caption || attachment.displayName || attachment.originalFilename || path.basename(attachment.fsPath || attachment.path) || 'download').trim());
390
- const joiner = attachment.path.includes('?') ? '&' : '?';
391
- return `${attachment.path}${joiner}download=1&filename=${safeName}`;
392
- };
393
- const describeAttachmentStorageContext = (attachment, storageKey) => {
394
- const parts = [
395
- `attachmentId=${attachment.attachmentId}`,
396
- `assetId=${String(attachment.assetId || 'unknown')}`,
397
- `storageKey=${storageKey || 'unknown'}`
398
- ];
399
- return parts.join(', ');
400
- };
401
- const readCanonicalAttachmentBuffer = async (taskId, attachment) => {
402
- const assetId = String(attachment.assetId || '').trim();
403
- const asset = assetId && workspaceAssetStore
404
- ? workspaceAssetStore.get(assetId, ACTIVE_WORKSPACE_ID)
405
- : null;
406
- if (!asset || asset.deletedAt) {
407
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
408
- }
409
- if (asset.scanStatus === 'infected') {
410
- throw new Error('Attachment is quarantined');
411
- }
412
- if (asset.scanStatus === 'pending') {
413
- throw new Error('Attachment scan pending');
414
- }
415
- if (asset.storageProvider === 'r2') {
416
- const attachmentContext = describeAttachmentStorageContext(attachment, asset.storageKey);
417
- if (!objectStorageClient) {
418
- throw new Error(`Attachment read failed from R2 because object storage is not configured in this MCP runtime (${attachmentContext})`);
419
- }
420
- let object;
421
- try {
422
- object = await objectStorageClient.getObject(asset.storageKey);
423
- }
424
- catch (error) {
425
- const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
426
- throw new Error(`Attachment read failed from R2 (${attachmentContext}): ${message || 'Unknown object storage error'}`);
427
- }
428
- if (!object) {
429
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
430
- }
431
- return object.body;
432
- }
433
- const resolvedPath = path.resolve(basePath, asset.storageKey);
434
- if (!resolvedPath.startsWith(path.resolve(basePath))) {
435
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
436
- }
437
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
438
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
439
- }
440
- return fs.readFileSync(resolvedPath);
441
- };
442
- const readCanonicalDocumentBuffer = async (asset) => {
443
- if (asset.deletedAt || asset.kind !== 'document') {
444
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
445
- }
446
- if (asset.scanStatus === 'infected') {
447
- throw new Error('Document is quarantined');
448
- }
449
- if (asset.scanStatus === 'pending') {
450
- throw new Error('Document scan pending');
451
- }
452
- if (asset.storageProvider === 'r2') {
453
- if (!objectStorageClient) {
454
- throw new Error(`Document read failed from R2 because object storage is not configured in this MCP runtime (assetId=${asset.assetId}, storageKey=${asset.storageKey || 'unknown'})`);
455
- }
456
- let object;
457
- try {
458
- object = await objectStorageClient.getObject(asset.storageKey);
459
- }
460
- catch (error) {
461
- const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
462
- throw new Error(`Document read failed from R2 (assetId=${asset.assetId}, storageKey=${asset.storageKey || 'unknown'}): ${message || 'Unknown object storage error'}`);
463
- }
464
- if (!object) {
465
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
466
- }
467
- return object.body;
468
- }
469
- const resolvedPath = path.resolve(basePath, asset.storageKey);
470
- if (!resolvedPath.startsWith(path.resolve(basePath))) {
471
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
472
- }
473
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
474
- throw new Error(`Document ${asset.assetId} not found in workspace ${ACTIVE_WORKSPACE_ID}.`);
475
- }
476
- return fs.readFileSync(resolvedPath);
477
- };
478
- const buildDocumentDownloadUrl = (asset) => {
479
- const safeName = sanitizeFilenameForPath(asset.originalFilename
480
- || asset.logicalName
481
- || path.basename(String(asset.storageKey || '').trim())
482
- || 'document.txt', 'document.txt');
483
- return `/api/taskforce/documents/${encodeURIComponent(String(asset.assetId || '').trim())}/content?download=1&filename=${encodeURIComponent(safeName)}`;
484
- };
485
- const resolveAttachmentLogicalFilename = (attachmentInput, assetOverride) => {
486
- const attachment = toTaskAttachmentRecord(attachmentInput);
487
- const asset = assetOverride ?? getAttachmentAssetRecord(attachment);
488
- const candidate = String(attachment.originalFilename
489
- || asset?.originalFilename
490
- || attachment.displayName
491
- || asset?.logicalName
492
- || '').trim();
493
- if (candidate) {
494
- return sanitizeFilenameForPath(candidate);
495
- }
496
- const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
497
- return sanitizeFilenameForPath(storageKey ? path.basename(storageKey) : '', 'attachment.txt');
498
- };
499
- const resolveCanonicalOverwriteTarget = (task, filenameRaw) => {
500
- const requestedFilename = sanitizeFilenameForPath(filenameRaw);
501
- const existingAttachments = Array.isArray(task.attachments) ? task.attachments : [];
502
- for (let index = 0; index < existingAttachments.length; index++) {
503
- const attachment = toTaskAttachmentRecord(existingAttachments[index]);
504
- const asset = getAttachmentAssetRecord(attachment);
505
- if (!asset || asset.deletedAt || asset.storageProvider === 'external')
506
- continue;
507
- const descriptor = describeTaskAttachment(task.id, attachment);
508
- if (descriptor.source !== 'canonical')
509
- continue;
510
- const logicalFilename = resolveAttachmentLogicalFilename(attachment, asset);
511
- if (logicalFilename !== requestedFilename)
512
- continue;
513
- const relativePath = toPosixPath(path.relative(PROJECT_ROOT, path.resolve(basePath, asset.storageKey)));
514
- return {
515
- index,
516
- attachment,
517
- asset,
518
- descriptor,
519
- relativePath,
520
- absolutePath: path.resolve(basePath, asset.storageKey),
521
- logicalFilename,
522
- };
523
- }
524
- return null;
525
- };
526
- const ensureCanonicalTaskAttachment = (relativePathRaw, taskIdRaw, captionRaw, timestampRaw, role, options) => {
527
- if (!workspaceAssetStore)
528
- return null;
529
- const relativePath = String(relativePathRaw || '').trim();
530
- const taskId = String(taskIdRaw || '').trim();
531
- if (!relativePath || !taskId)
532
- return null;
533
- const absolutePath = path.resolve(PROJECT_ROOT, relativePath);
534
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile())
535
- return null;
536
- const storageKey = relativePath.replace(/^\.taskforce\//, '').trim() || relativePath;
537
- const buffer = fs.readFileSync(absolutePath);
538
- const stats = fs.statSync(absolutePath);
539
- const mimeType = inferMimeTypeFromPath(relativePath);
540
- const kind = inferAssetKind(relativePath, mimeType);
541
- const timestamp = String(timestampRaw || new Date().toISOString()).trim() || new Date().toISOString();
542
- const existing = workspaceAssetStore.getByStorageKey(storageKey, ACTIVE_WORKSPACE_ID);
543
- const originalFilename = sanitizeFilenameForPath(options?.originalFilename || existing?.originalFilename || path.basename(relativePath));
544
- const logicalName = sanitizeDisplayName(options?.logicalName || existing?.logicalName || originalFilename, originalFilename);
545
- const asset = workspaceAssetStore.upsertAsset({
546
- assetId: options?.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`,
547
- workspaceId: ACTIVE_WORKSPACE_ID,
548
- kind,
549
- logicalName,
550
- originalFilename,
551
- mimeType,
552
- storageProvider: 'local',
553
- storageKey,
554
- contentSha256: createHash('sha256').update(buffer).digest('hex'),
555
- sizeBytes: stats.size,
556
- documentId: kind === 'document' ? (options?.documentId || existing?.documentId || `doc-${randomUUID().replace(/-/g, '')}`) : null,
557
- version: kind === 'document' ? Math.max(1, Number(existing?.version || 0) + 1) : null,
558
- isLatest: true,
559
- scanStatus: options?.scanStatus || existing?.scanStatus || 'clean',
560
- scanEngine: options?.scanEngine !== undefined ? options.scanEngine : (existing?.scanEngine || null),
561
- scanDetails: options?.scanDetails !== undefined ? options.scanDetails : (existing?.scanDetails || null),
562
- createdByUserId: options?.createdByUserId !== undefined ? options.createdByUserId : (existing?.createdByUserId || null),
563
- createdAt: options?.createdAt || existing?.createdAt || timestamp,
564
- updatedAt: timestamp,
565
- deletedAt: null,
566
- purgeAfter: null
567
- });
568
- workspaceAssetStore.upsertLink({
569
- workspaceId: ACTIVE_WORKSPACE_ID,
570
- assetId: asset.assetId,
571
- taskId,
572
- role,
573
- displayName: kind === 'document' ? null : (captionRaw || null),
574
- updatedAt: timestamp,
575
- deletedAt: null
576
- });
577
- return asset;
578
- };
289
+ const documentHelpers = createDocumentHelpers({
290
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
291
+ workspaceAssetStore,
292
+ getDocumentReferenceLabel,
293
+ parseDocumentReference,
294
+ isTextReadableMimeType,
295
+ });
296
+ const canonicalAssetHelpers = createCanonicalAssetHelpers({
297
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
298
+ projectRoot: PROJECT_ROOT,
299
+ basePath,
300
+ workspaceAssetStore,
301
+ objectStorageClient,
302
+ sanitizeFilenameForPath,
303
+ sanitizeDisplayName,
304
+ toPosixPath,
305
+ inferMimeTypeFromPath,
306
+ inferAssetKind,
307
+ buildCanonicalWorkspaceAssetRelativePath,
308
+ });
309
+ const taskAttachmentHelpers = createTaskAttachmentHelpers({
310
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
311
+ workspaceAssetStore,
312
+ getImageReferenceLabel,
313
+ getDocumentReferenceLabel,
314
+ inferMimeTypeFromPath,
315
+ inferAssetKind,
316
+ isTextReadableMimeType,
317
+ isValidCanonicalDocumentAsset,
318
+ canonicalAssetHelpers,
319
+ documentHelpers,
320
+ });
579
321
  const server = new Server({
580
322
  name: options.serverName || `taskforce-${PROJECT_NAME}`,
581
323
  version: MCP_SERVER_VERSION,
@@ -669,75 +411,9 @@ export function createTaskforceMcpServer(options = {}) {
669
411
  }
670
412
  return { id: task.id, task };
671
413
  }
672
- function buildDocumentDescriptor(asset) {
673
- return {
674
- assetId: asset.assetId,
675
- documentId: asset.documentId ?? null,
676
- referenceNumber: typeof asset.referenceNumber === 'number' ? asset.referenceNumber : null,
677
- referenceLabel: getDocumentReferenceLabel(asset),
678
- logicalName: asset.logicalName ?? null,
679
- originalFilename: asset.originalFilename ?? null,
680
- version: typeof asset.version === 'number' ? asset.version : null,
681
- storageKey: asset.storageKey ?? null,
682
- mimeType: asset.mimeType ?? null,
683
- sizeBytes: typeof asset.sizeBytes === 'number' ? asset.sizeBytes : null,
684
- updatedAt: asset.updatedAt ?? null,
685
- apiUrl: `/api/taskforce/documents/${encodeURIComponent(String(asset.assetId || '').trim())}/content`,
686
- };
687
- }
688
- function buildDocumentReadHint(asset) {
689
- const preferredIdentifier = getDocumentReferenceLabel(asset)
690
- || (typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null)
691
- || String(asset.assetId || '').trim();
692
- const readable = isTextReadableMimeType(asset.mimeType || '');
693
- if (readable) {
694
- return {
695
- available: true,
696
- nextTool: 'read_document_content',
697
- suggestedArgs: { documentId: preferredIdentifier },
698
- guidance: 'To read the document body, call read_document_content with this documentId.',
699
- reviewNext: {
700
- helperTool: 'review_document',
701
- listTool: 'get_document_review_sessions',
702
- createTool: 'create_document_review_session',
703
- commentTool: 'add_document_review_comment',
704
- guidance: 'After reading the document body, prefer review_document for a lower-friction review flow, or use get_document_review_sessions/create_document_review_session and add_document_review_comment directly.'
705
- }
706
- };
707
- }
708
- return {
709
- available: false,
710
- nextTool: 'read_document_content',
711
- suggestedArgs: { documentId: preferredIdentifier },
712
- guidance: 'This document is not inline-readable as text. Call read_document_content to get the structured download/reference response.',
713
- reviewNext: {
714
- helperTool: 'review_document',
715
- listTool: 'get_document_review_sessions',
716
- createTool: 'create_document_review_session',
717
- commentTool: 'add_document_review_comment',
718
- guidance: 'After inspecting the document access response, prefer review_document for a lower-friction review flow, or use get_document_review_sessions/create_document_review_session and add_document_review_comment directly.'
719
- },
720
- reason: 'binary_file',
721
- };
722
- }
723
- function buildCanonicalDocumentAttachmentHint(asset) {
724
- const preferredIdentifier = getDocumentReferenceLabel(asset)
725
- || (typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null)
726
- || String(asset.assetId || '').trim();
727
- if (!preferredIdentifier) {
728
- return null;
729
- }
730
- const readable = isTextReadableMimeType(asset.mimeType || '');
731
- return {
732
- documentId: typeof asset.documentId === 'string' && asset.documentId.trim().length > 0 ? asset.documentId.trim() : null,
733
- preferredNextTool: 'get_document',
734
- preferredWorkflow: 'canonical_document',
735
- suggestedArgs: { documentId: preferredIdentifier },
736
- guidance: readable
737
- ? 'This attachment is a canonical document. Prefer get_document, then read_document_content, for ongoing document work and review.'
738
- : 'This attachment is a canonical document. Prefer get_document for metadata and review context, then read_document_content for the structured content/download response.',
739
- };
740
- }
414
+ const buildDocumentDescriptor = documentHelpers.buildDocumentDescriptor;
415
+ const buildDocumentReadHint = documentHelpers.buildDocumentReadHint;
416
+ const buildCanonicalDocumentAttachmentHint = documentHelpers.buildCanonicalDocumentAttachmentHint;
741
417
  function searchImageAssets(queryRaw, options) {
742
418
  const query = typeof queryRaw === 'string' ? queryRaw.trim().toLowerCase() : '';
743
419
  if (!query) {
@@ -796,65 +472,7 @@ export function createTaskforceMcpServer(options = {}) {
796
472
  .sort((a, b) => b.score - a.score || String(b.asset.updatedAt || '').localeCompare(String(a.asset.updatedAt || '')))
797
473
  .slice(0, limit);
798
474
  }
799
- function searchDocumentAssets(queryRaw, options) {
800
- const query = typeof queryRaw === 'string' ? queryRaw.trim().toLowerCase() : '';
801
- if (!query) {
802
- throw new Error('query is required');
803
- }
804
- if (!workspaceAssetStore) {
805
- throw new Error('Workspace asset store unavailable.');
806
- }
807
- const requestedLimit = Number(options?.limit);
808
- const limit = Number.isFinite(requestedLimit)
809
- ? Math.max(1, Math.min(50, Math.trunc(requestedLimit)))
810
- : 10;
811
- const normalize = (value) => typeof value === 'string' ? value.trim().toLowerCase() : '';
812
- const tokenize = (value) => normalize(value).split(/[^a-z0-9]+/i).filter(Boolean);
813
- const allDocuments = workspaceAssetStore.listAllByWorkspace(ACTIVE_WORKSPACE_ID, { kind: 'document' });
814
- const scoredMatches = allDocuments.flatMap((asset) => {
815
- if (asset.deletedAt)
816
- return [];
817
- const fields = [
818
- { label: 'referenceLabel', value: getDocumentReferenceLabel(asset) || '' },
819
- { label: 'documentId', value: asset.documentId || '' },
820
- { label: 'logicalName', value: asset.logicalName || '' },
821
- { label: 'originalFilename', value: asset.originalFilename || '' },
822
- { label: 'assetId', value: asset.assetId || '' },
823
- ];
824
- let score = 0;
825
- const matchedOn = new Set();
826
- for (const field of fields) {
827
- const normalized = normalize(field.value);
828
- if (!normalized)
829
- continue;
830
- if (normalized === query) {
831
- score += field.label === 'referenceLabel' || field.label === 'documentId' ? 120 : 90;
832
- matchedOn.add(field.label);
833
- continue;
834
- }
835
- if (normalized.startsWith(query)) {
836
- score += field.label === 'logicalName' || field.label === 'originalFilename' ? 60 : 50;
837
- matchedOn.add(field.label);
838
- continue;
839
- }
840
- if (normalized.includes(query)) {
841
- score += 35;
842
- matchedOn.add(field.label);
843
- continue;
844
- }
845
- if (tokenize(field.value).some((token) => token.startsWith(query))) {
846
- score += 20;
847
- matchedOn.add(field.label);
848
- }
849
- }
850
- if (score <= 0)
851
- return [];
852
- return [{ asset, matchedOn: Array.from(matchedOn), score }];
853
- });
854
- return scoredMatches
855
- .sort((a, b) => b.score - a.score || String(b.asset.updatedAt || '').localeCompare(String(a.asset.updatedAt || '')))
856
- .slice(0, limit);
857
- }
475
+ const searchDocumentAssets = documentHelpers.searchDocumentAssets;
858
476
  function buildImageDescriptor(asset) {
859
477
  return {
860
478
  assetId: asset.assetId,
@@ -869,32 +487,7 @@ export function createTaskforceMcpServer(options = {}) {
869
487
  apiUrl: `/api/taskforce/context/${encodeURIComponent(String(asset.storageKey || '').trim())}`,
870
488
  };
871
489
  }
872
- function resolveDocumentAssetByIdentifierOrThrow(documentIdentifier, options) {
873
- const normalizedValue = typeof documentIdentifier === 'string' ? documentIdentifier.trim() : '';
874
- if (!normalizedValue) {
875
- throw new Error('documentId is required');
876
- }
877
- if (!workspaceAssetStore) {
878
- throw new Error('Workspace asset store unavailable.');
879
- }
880
- const matchesDocumentAsset = (asset) => Boolean(asset && asset.kind === 'document' && asset.deletedAt == null);
881
- const directAsset = workspaceAssetStore.get(normalizedValue, ACTIVE_WORKSPACE_ID);
882
- if (matchesDocumentAsset(directAsset)) {
883
- return directAsset;
884
- }
885
- const documentAssets = workspaceAssetStore.listAllByWorkspace(ACTIVE_WORKSPACE_ID, { kind: 'document' });
886
- const referenceNumber = parseDocumentReference(normalizedValue);
887
- const matchedAsset = documentAssets.find((asset) => asset.documentId === normalizedValue)
888
- || (referenceNumber
889
- ? documentAssets.find((asset) => asset.referenceNumber === referenceNumber && asset.isLatest)
890
- || documentAssets.find((asset) => asset.referenceNumber === referenceNumber)
891
- : null);
892
- if (matchesDocumentAsset(matchedAsset)) {
893
- return matchedAsset;
894
- }
895
- throw new Error(options?.notFoundMessage
896
- || `Document ${normalizedValue} not found in workspace ${ACTIVE_WORKSPACE_ID}. If you only know a keyword, filename, or rough title, call search_documents first.`);
897
- }
490
+ const resolveDocumentAssetByIdentifierOrThrow = documentHelpers.resolveDocumentAssetByIdentifierOrThrow;
898
491
  function resolveImageAssetByIdentifierOrThrow(imageIdentifier, options) {
899
492
  const normalizedValue = typeof imageIdentifier === 'string' ? imageIdentifier.trim() : '';
900
493
  if (!normalizedValue) {
@@ -1419,13 +1012,91 @@ export function createTaskforceMcpServer(options = {}) {
1419
1012
  },
1420
1013
  });
1421
1014
  }
1015
+ function logMcpStructuredEvent(level, event, details) {
1016
+ const payload = {
1017
+ event,
1018
+ runtimeMode,
1019
+ workspaceId: ACTIVE_WORKSPACE_ID,
1020
+ requestId: auditRequestId,
1021
+ ...details,
1022
+ };
1023
+ const logger = level === 'error'
1024
+ ? console.error
1025
+ : level === 'warn'
1026
+ ? console.warn
1027
+ : console.info;
1028
+ logger(`[Taskforce MCP] ${JSON.stringify(payload)}`);
1029
+ }
1030
+ function logMcpReadObservation(toolName, startedAtMs, details = {}) {
1031
+ const durationMs = Date.now() - startedAtMs;
1032
+ const shouldLog = runtimeMode === 'cloud'
1033
+ ? toolName === 'get_current_profile' || toolName === 'list_tasks' || durationMs >= 150
1034
+ : durationMs >= 250;
1035
+ if (!shouldLog)
1036
+ return;
1037
+ logMcpStructuredEvent('info', 'tool_read_complete', {
1038
+ toolName,
1039
+ durationMs,
1040
+ ...details,
1041
+ });
1042
+ }
1422
1043
  /**
1423
1044
  * Helpers
1424
1045
  */
1425
- const taskforceManagedAttachmentSourceRoots = [
1426
- path.resolve(basePath, 'tmp'),
1427
- path.resolve(basePath, 'artifacts'),
1428
- ];
1046
+ function listExistingAttachmentSourceRoots(candidateRoots) {
1047
+ const seen = new Set();
1048
+ const results = [];
1049
+ for (const candidate of candidateRoots) {
1050
+ const trimmed = String(candidate || '').trim();
1051
+ if (!trimmed)
1052
+ continue;
1053
+ const resolved = path.resolve(trimmed);
1054
+ if (!fs.existsSync(resolved))
1055
+ continue;
1056
+ let realPath;
1057
+ try {
1058
+ realPath = fs.realpathSync(resolved);
1059
+ }
1060
+ catch {
1061
+ continue;
1062
+ }
1063
+ if (!fs.statSync(realPath).isDirectory())
1064
+ continue;
1065
+ if (seen.has(realPath))
1066
+ continue;
1067
+ seen.add(realPath);
1068
+ results.push(realPath);
1069
+ }
1070
+ return results;
1071
+ }
1072
+ function listConfiguredAttachmentSourceRoots() {
1073
+ const configured = String(process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS || '').trim();
1074
+ if (!configured)
1075
+ return [];
1076
+ return listExistingAttachmentSourceRoots(configured
1077
+ .split(path.delimiter)
1078
+ .map((entry) => entry.trim())
1079
+ .filter(Boolean));
1080
+ }
1081
+ function listKnownAgentWorkspaceRoots() {
1082
+ const openClawRoot = path.join(os.homedir(), '.openclaw');
1083
+ if (!fs.existsSync(openClawRoot))
1084
+ return [];
1085
+ const entries = fs.readdirSync(openClawRoot, { withFileTypes: true });
1086
+ const workspaceRoots = entries
1087
+ .filter((entry) => entry.isDirectory() && /^workspace-/i.test(entry.name))
1088
+ .map((entry) => path.join(openClawRoot, entry.name));
1089
+ return listExistingAttachmentSourceRoots(workspaceRoots);
1090
+ }
1091
+ function getApprovedAttachmentSourceRoots(projectRootReal) {
1092
+ return listExistingAttachmentSourceRoots([
1093
+ projectRootReal,
1094
+ path.resolve(basePath, 'tmp'),
1095
+ path.resolve(basePath, 'artifacts'),
1096
+ ...listConfiguredAttachmentSourceRoots(),
1097
+ ...listKnownAgentWorkspaceRoots(),
1098
+ ]);
1099
+ }
1429
1100
  function renderTaskTable(tasks) {
1430
1101
  if (tasks.length === 0)
1431
1102
  return "No tasks found.";
@@ -1458,9 +1129,7 @@ export function createTaskforceMcpServer(options = {}) {
1458
1129
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
1459
1130
  }
1460
1131
  function isApprovedAttachmentSourcePath(filePath, projectRootReal) {
1461
- if (isWithinRoot(filePath, projectRootReal))
1462
- return true;
1463
- return taskforceManagedAttachmentSourceRoots.some((root) => isWithinRoot(filePath, root));
1132
+ return getApprovedAttachmentSourceRoots(projectRootReal).some((root) => isWithinRoot(filePath, root));
1464
1133
  }
1465
1134
  function toPosixPath(filePath) {
1466
1135
  return filePath.split(path.sep).join('/');
@@ -1664,7 +1333,7 @@ export function createTaskforceMcpServer(options = {}) {
1664
1333
  console.error(`[Taskforce MCP] Failed to send resources/updated for ${uri}: ${error.message}`);
1665
1334
  }
1666
1335
  }
1667
- async function notifyResourceMutation(options) {
1336
+ function collectResourceMutationUris(options) {
1668
1337
  const uris = new Set();
1669
1338
  if (options.includeConfig)
1670
1339
  uris.add(RESOURCE_CONFIG_URI);
@@ -1691,9 +1360,47 @@ export function createTaskforceMcpServer(options = {}) {
1691
1360
  uris.add(workstreamUriFor(workstreamId));
1692
1361
  }
1693
1362
  }
1363
+ return uris;
1364
+ }
1365
+ const pendingResourceInvalidations = new Set();
1366
+ let resourceInvalidationFlushHandle = null;
1367
+ function scheduleResourceInvalidationFlush() {
1368
+ if (resourceInvalidationFlushHandle)
1369
+ return;
1370
+ resourceInvalidationFlushHandle = setTimeout(() => {
1371
+ resourceInvalidationFlushHandle = null;
1372
+ void flushScheduledResourceInvalidations();
1373
+ }, 0);
1374
+ }
1375
+ async function flushScheduledResourceInvalidations() {
1376
+ if (pendingResourceInvalidations.size === 0)
1377
+ return;
1378
+ const startedAtMs = Date.now();
1379
+ const uris = Array.from(pendingResourceInvalidations);
1380
+ pendingResourceInvalidations.clear();
1694
1381
  for (const uri of uris) {
1695
1382
  await notifyResourceUpdatedIfSubscribed(uri);
1696
1383
  }
1384
+ const durationMs = Date.now() - startedAtMs;
1385
+ if (runtimeMode === 'cloud' || durationMs >= 100 || uris.length >= 3) {
1386
+ logMcpStructuredEvent('info', 'resource_invalidation_flush', {
1387
+ uriCount: uris.length,
1388
+ durationMs,
1389
+ uris,
1390
+ });
1391
+ }
1392
+ }
1393
+ function scheduleResourceMutation(options) {
1394
+ const uris = collectResourceMutationUris(options);
1395
+ if (uris.size === 0)
1396
+ return;
1397
+ for (const uri of uris) {
1398
+ pendingResourceInvalidations.add(uri);
1399
+ }
1400
+ scheduleResourceInvalidationFlush();
1401
+ }
1402
+ async function notifyResourceMutation(options) {
1403
+ scheduleResourceMutation(options);
1697
1404
  }
1698
1405
  function buildConfigResourcePayload() {
1699
1406
  return {
@@ -1716,13 +1423,26 @@ export function createTaskforceMcpServer(options = {}) {
1716
1423
  };
1717
1424
  }
1718
1425
  function buildTasksResourcePayload() {
1719
- const tasks = core.listTasks(ACTIVE_WORKSPACE_ID).tasks;
1426
+ const tasks = core.listTasks(ACTIVE_WORKSPACE_ID).tasks
1427
+ .map((task) => buildPlanningTaskSummary(task));
1720
1428
  return {
1721
1429
  total: tasks.length,
1722
1430
  tasks,
1723
1431
  };
1724
1432
  }
1725
- function listHistoricalWorkspaceTasks() {
1433
+ function buildPlanningTaskSummary(task) {
1434
+ const { description, comments, taskEvents, activity, createdByActor, assigneeActor, attachments, checklistItems, ...rest } = task;
1435
+ return rest;
1436
+ }
1437
+ function buildPlanningWorkstreamSummary(workstream) {
1438
+ const { description, taskIds, ...rest } = workstream;
1439
+ return rest;
1440
+ }
1441
+ function buildPlanningInitiativeSummary(initiative) {
1442
+ const { description, ...rest } = initiative;
1443
+ return rest;
1444
+ }
1445
+ function buildHistoricalWorkspaceTaskSnapshot() {
1726
1446
  const activeTasks = core.listTasks(ACTIVE_WORKSPACE_ID).tasks;
1727
1447
  const archivedTasks = core.listArchive(ACTIVE_WORKSPACE_ID).archived;
1728
1448
  const byId = new Map();
@@ -1730,40 +1450,70 @@ export function createTaskforceMcpServer(options = {}) {
1730
1450
  byId.set(task.id, task);
1731
1451
  for (const task of archivedTasks)
1732
1452
  byId.set(task.id, task);
1733
- return Array.from(byId.values());
1453
+ const tasks = Array.from(byId.values());
1454
+ const tasksByWorkstreamId = new Map();
1455
+ for (const task of tasks) {
1456
+ const normalizedWorkstreamId = String(task.workstreamId || '').trim();
1457
+ if (!normalizedWorkstreamId)
1458
+ continue;
1459
+ const current = tasksByWorkstreamId.get(normalizedWorkstreamId) || [];
1460
+ current.push(buildPlanningTaskSummary(task));
1461
+ tasksByWorkstreamId.set(normalizedWorkstreamId, current);
1462
+ }
1463
+ return {
1464
+ tasks,
1465
+ tasksByWorkstreamId,
1466
+ };
1734
1467
  }
1735
- function buildInitiativeResourcePayload(initiative) {
1736
- const workstreams = core.listWorkstreams(ACTIVE_WORKSPACE_ID)
1737
- .filter((workstream) => workstream.initiativeId === initiative.id);
1738
- const tasks = listHistoricalWorkspaceTasks()
1739
- .filter((task) => workstreams.some((workstream) => workstream.id === task.workstreamId));
1468
+ function buildInitiativeResourcePayload(initiative, options) {
1469
+ const tasksByWorkstreamId = options?.tasksByWorkstreamId || buildHistoricalWorkspaceTaskSnapshot().tasksByWorkstreamId;
1470
+ const workstreams = options?.workstreamsByInitiativeId?.get(initiative.id)
1471
+ || core.listWorkstreams(ACTIVE_WORKSPACE_ID)
1472
+ .filter((workstream) => workstream.initiativeId === initiative.id);
1473
+ const tasks = workstreams.flatMap((workstream) => tasksByWorkstreamId.get(workstream.id) || []);
1740
1474
  return {
1741
- initiative,
1475
+ initiative: buildPlanningInitiativeSummary(initiative),
1742
1476
  workstreamCount: workstreams.length,
1743
1477
  taskCount: tasks.length,
1744
- workstreams,
1478
+ workstreams: workstreams.map((workstream) => buildPlanningWorkstreamSummary(workstream)),
1745
1479
  };
1746
1480
  }
1747
- function buildWorkstreamResourcePayload(workstream) {
1748
- const tasks = listHistoricalWorkspaceTasks()
1749
- .filter((task) => task.workstreamId === workstream.id);
1481
+ function buildWorkstreamResourcePayload(workstream, options) {
1482
+ const tasksByWorkstreamId = options?.tasksByWorkstreamId || buildHistoricalWorkspaceTaskSnapshot().tasksByWorkstreamId;
1483
+ const tasks = tasksByWorkstreamId.get(workstream.id)
1484
+ || [];
1750
1485
  return {
1751
- workstream,
1486
+ workstream: buildPlanningWorkstreamSummary(workstream),
1752
1487
  taskCount: tasks.length,
1753
1488
  tasks,
1754
1489
  };
1755
1490
  }
1756
1491
  function buildInitiativesResourcePayload() {
1492
+ const workstreams = core.listWorkstreams(ACTIVE_WORKSPACE_ID);
1493
+ const workstreamsByInitiativeId = new Map();
1494
+ for (const workstream of workstreams) {
1495
+ const normalizedInitiativeId = String(workstream.initiativeId || '').trim();
1496
+ if (!normalizedInitiativeId)
1497
+ continue;
1498
+ const current = workstreamsByInitiativeId.get(normalizedInitiativeId) || [];
1499
+ current.push(workstream);
1500
+ workstreamsByInitiativeId.set(normalizedInitiativeId, current);
1501
+ }
1502
+ const { tasksByWorkstreamId } = buildHistoricalWorkspaceTaskSnapshot();
1757
1503
  const initiatives = core.listInitiatives(ACTIVE_WORKSPACE_ID)
1758
- .map((initiative) => buildInitiativeResourcePayload(initiative));
1504
+ .map((initiative) => buildInitiativeResourcePayload(initiative, {
1505
+ workstreamsByInitiativeId,
1506
+ tasksByWorkstreamId,
1507
+ }));
1759
1508
  return {
1760
1509
  total: initiatives.length,
1761
1510
  initiatives,
1762
1511
  };
1763
1512
  }
1764
1513
  function buildWorkstreamsResourcePayload() {
1514
+ const { tasksByWorkstreamId } = buildHistoricalWorkspaceTaskSnapshot();
1765
1515
  const workstreams = core.listWorkstreams(ACTIVE_WORKSPACE_ID)
1766
- .map((workstream) => buildWorkstreamResourcePayload(workstream));
1516
+ .map((workstream) => buildWorkstreamResourcePayload(workstream, { tasksByWorkstreamId }));
1767
1517
  return {
1768
1518
  total: workstreams.length,
1769
1519
  workstreams,
@@ -2987,7 +2737,7 @@ export function createTaskforceMcpServer(options = {}) {
2987
2737
  {
2988
2738
  name: "save_task_attachment",
2989
2739
  description: describeTool(runtimeMode === 'cloud'
2990
- ? "Save generated content into a task attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. For cloud MCP, AI-authored markdown/text should usually be passed inline via content rather than sourcePath. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."
2740
+ ? "Save generated content into a task attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. For cloud MCP, use inline content for AI-authored markdown/text, or use sourcePath when the file already exists inside the repo, a Taskforce-managed attachment source, or an approved agent workspace root. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."
2991
2741
  : "Save generated content or copy a file into a task-specific attachment and link it to the task. Use this when an agent creates notes, reports, or artifacts that should live with the task instead of remaining only in chat. When overwrite=true, this replaces an existing canonical attachment with the same logical filename in place. External URL attachments are never overwrite targets; if no matching canonical attachment exists, a new attachment is created."),
2992
2742
  inputSchema: {
2993
2743
  type: "object",
@@ -2995,7 +2745,7 @@ export function createTaskforceMcpServer(options = {}) {
2995
2745
  id: { type: "string", description: "Task ID" },
2996
2746
  filename: { type: "string", description: "Target filename for the task attachment." },
2997
2747
  content: { type: "string", description: runtimeMode === 'cloud' ? "Text/markdown file content to save. For cloud MCP, use this for AI-authored markdown/text attachments." : "Text/markdown file content to save. Provide either content or sourcePath." },
2998
- sourcePath: { type: "string", description: runtimeMode === 'cloud' ? "Project-relative or absolute source file path to copy (local runtime only today). For cloud MCP, prefer inline content for AI-authored markdown/text." : "Project-relative or absolute source file path to copy. Provide either sourcePath or content." },
2748
+ sourcePath: { type: "string", description: runtimeMode === 'cloud' ? "Project-relative or absolute source file path to copy. In cloud MCP this must resolve inside the repo, a Taskforce-managed attachment source, or an approved agent workspace root. Use content instead for AI-authored markdown/text." : "Project-relative or absolute source file path to copy. Provide either sourcePath or content." },
2999
2749
  caption: { type: "string", description: "Optional label shown in task context" },
3000
2750
  overwrite: { type: "boolean", description: "Overwrite the destination file when it already exists (default: false)." }
3001
2751
  },
@@ -3230,18 +2980,7 @@ export function createTaskforceMcpServer(options = {}) {
3230
2980
  }
3231
2981
  }
3232
2982
  };
3233
- }).filter((tool) => {
3234
- if (!hasPermission(TOOL_PERMISSIONS[tool.name]))
3235
- return false;
3236
- const taskforceToolsEnv = process.env.TASKFORCE_TOOLS;
3237
- if (taskforceToolsEnv) {
3238
- const enabledTools = taskforceToolsEnv.split(',').map(t => t.trim()).filter(Boolean);
3239
- if (enabledTools.length > 0 && !enabledTools.includes(tool.name)) {
3240
- return false;
3241
- }
3242
- }
3243
- return true;
3244
- });
2983
+ }).filter((tool) => hasPermission(TOOL_PERMISSIONS[tool.name]));
3245
2984
  return {
3246
2985
  tools,
3247
2986
  };
@@ -3257,6 +2996,7 @@ export function createTaskforceMcpServer(options = {}) {
3257
2996
  assertResolvedProfileForMutatingTool(name);
3258
2997
  switch (name) {
3259
2998
  case "list_tasks": {
2999
+ const startedAtMs = Date.now();
3260
3000
  const { format = "markdown", category, status, openOnly = false, assignee, priority, type, sort = "created", order = "desc", limit } = args;
3261
3001
  let data = core.listTasks(ACTIVE_WORKSPACE_ID);
3262
3002
  let tasks = data.tasks;
@@ -3299,7 +3039,21 @@ export function createTaskforceMcpServer(options = {}) {
3299
3039
  // Strip verbose detail fields — descriptions, comments, events, and derived
3300
3040
  // activity are omitted from list responses to keep context window usage
3301
3041
  // manageable. Use get_task to retrieve full details for a specific task.
3302
- const slimTasks = tasks.map(({ description, comments, taskEvents, activity, createdByActor, assigneeActor, ...rest }) => rest);
3042
+ const slimTasks = tasks.map(({ description, comments, taskEvents, activity, createdByActor, assigneeActor, attachments, checklistItems, ...rest }) => rest);
3043
+ logMcpReadObservation('list_tasks', startedAtMs, {
3044
+ format,
3045
+ totalTasks: data.tasks.length,
3046
+ returnedTasks: slimTasks.length,
3047
+ filtered: slimTasks.length !== data.tasks.length,
3048
+ openOnly,
3049
+ hasAssigneeFilter: Boolean(assignee),
3050
+ hasStatusFilter: Boolean(status),
3051
+ hasCategoryFilter: Boolean(category),
3052
+ hasTypeFilter: Boolean(type),
3053
+ hasPriorityFilter: priority !== undefined,
3054
+ sort,
3055
+ order,
3056
+ });
3303
3057
  if (format === "json") {
3304
3058
  return {
3305
3059
  content: [{ type: "text", text: JSON.stringify(slimTasks, null, 2) }],
@@ -3367,6 +3121,7 @@ export function createTaskforceMcpServer(options = {}) {
3367
3121
  };
3368
3122
  }
3369
3123
  case "get_current_profile": {
3124
+ const startedAtMs = Date.now();
3370
3125
  const { profileToken } = args;
3371
3126
  ensureResolvedProfileForSessionBootstrap();
3372
3127
  let profile = activeAiProfileId
@@ -3383,6 +3138,13 @@ export function createTaskforceMcpServer(options = {}) {
3383
3138
  }
3384
3139
  bindingSource = 'token_lookup';
3385
3140
  }
3141
+ logMcpReadObservation('get_current_profile', startedAtMs, {
3142
+ identityBound: Boolean(activeAiProfileId),
3143
+ bindingSource,
3144
+ writeReady: Boolean(profile),
3145
+ profileResolved: Boolean(profile),
3146
+ usedProfileTokenLookup: !activeAiProfileId && typeof profileToken === 'string' && profileToken.trim().length > 0,
3147
+ });
3386
3148
  return {
3387
3149
  content: [{
3388
3150
  type: "text",
@@ -3721,7 +3483,7 @@ export function createTaskforceMcpServer(options = {}) {
3721
3483
  text: JSON.stringify({
3722
3484
  taskId: task.id,
3723
3485
  referenceLabel: task.referenceLabel || getTaskReferenceLabel(task),
3724
- attachments: listTaskAttachmentDescriptors(task)
3486
+ attachments: taskAttachmentHelpers.listTaskAttachmentDescriptors(task)
3725
3487
  }, null, 2)
3726
3488
  }],
3727
3489
  };
@@ -3732,7 +3494,7 @@ export function createTaskforceMcpServer(options = {}) {
3732
3494
  if (!task) {
3733
3495
  throw new Error(`Task ${taskId} not found`);
3734
3496
  }
3735
- const attachments = listTaskAttachmentDescriptors(task);
3497
+ const attachments = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
3736
3498
  const attachment = attachments.find((entry) => entry.attachmentId === attachmentId);
3737
3499
  if (!attachment) {
3738
3500
  throw new Error(`Attachment ${attachmentId} not found on task ${taskId}`);
@@ -3757,7 +3519,7 @@ export function createTaskforceMcpServer(options = {}) {
3757
3519
  taskId: task.id,
3758
3520
  attachment,
3759
3521
  format: "download",
3760
- downloadUrl: buildAttachmentDownloadUrl(attachment),
3522
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3761
3523
  reason: attachment.source === 'external' ? 'external_reference' : 'requested_download'
3762
3524
  }, null, 2)
3763
3525
  }],
@@ -3771,14 +3533,14 @@ export function createTaskforceMcpServer(options = {}) {
3771
3533
  taskId: task.id,
3772
3534
  attachment,
3773
3535
  format: "download",
3774
- downloadUrl: buildAttachmentDownloadUrl(attachment),
3536
+ downloadUrl: canonicalAssetHelpers.buildAttachmentDownloadUrl(attachment),
3775
3537
  reason: attachment.source === 'external' ? 'external_reference' : 'binary_file'
3776
3538
  }, null, 2)
3777
3539
  }],
3778
3540
  };
3779
3541
  }
3780
3542
  const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
3781
- const buffer = await readCanonicalAttachmentBuffer(task.id, attachment);
3543
+ const buffer = await canonicalAssetHelpers.readCanonicalAttachmentBuffer(task.id, attachment);
3782
3544
  const content = buffer.toString('utf8');
3783
3545
  if (content.length > resolvedMaxChars) {
3784
3546
  throw new Error('Attachment exceeds maxChars limit for inline read');
@@ -3965,7 +3727,7 @@ export function createTaskforceMcpServer(options = {}) {
3965
3727
  text: JSON.stringify({
3966
3728
  document,
3967
3729
  format: "download",
3968
- downloadUrl: buildDocumentDownloadUrl(asset),
3730
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
3969
3731
  reason: "requested_download"
3970
3732
  }, null, 2)
3971
3733
  }],
@@ -3978,14 +3740,14 @@ export function createTaskforceMcpServer(options = {}) {
3978
3740
  text: JSON.stringify({
3979
3741
  document,
3980
3742
  format: "download",
3981
- downloadUrl: buildDocumentDownloadUrl(asset),
3743
+ downloadUrl: canonicalAssetHelpers.buildDocumentDownloadUrl(asset),
3982
3744
  reason: "binary_file"
3983
3745
  }, null, 2)
3984
3746
  }],
3985
3747
  };
3986
3748
  }
3987
3749
  const resolvedMaxChars = Math.max(1, Math.floor(Number(maxChars || 200000)));
3988
- const buffer = await readCanonicalDocumentBuffer(asset);
3750
+ const buffer = await canonicalAssetHelpers.readCanonicalDocumentBuffer(asset);
3989
3751
  const content = buffer.toString('utf8');
3990
3752
  if (content.length > resolvedMaxChars) {
3991
3753
  throw new Error('Document exceeds maxChars limit for inline read');
@@ -5220,9 +4982,6 @@ export function createTaskforceMcpServer(options = {}) {
5220
4982
  if (hasContent === hasSourcePath) {
5221
4983
  throw new Error("Provide exactly one of: content or sourcePath");
5222
4984
  }
5223
- if (runtimeMode === 'cloud' && hasSourcePath) {
5224
- throw new Error("Cloud MCP does not support sourcePath for save_task_attachment yet. For AI-authored markdown or text, resend the attachment using inline content instead.");
5225
- }
5226
4985
  const now = new Date().toISOString();
5227
4986
  const safeCaption = typeof caption === 'string' && caption.trim().length > 0
5228
4987
  ? caption.trim()
@@ -5230,11 +4989,11 @@ export function createTaskforceMcpServer(options = {}) {
5230
4989
  const safeFilename = sanitizeFilenameForPath(filename);
5231
4990
  const { projectRoot, basePath } = core.getPaths();
5232
4991
  const overwriteTarget = overwrite === true
5233
- ? resolveCanonicalOverwriteTarget(task, safeFilename)
4992
+ ? canonicalAssetHelpers.resolveCanonicalOverwriteTarget(task, safeFilename, taskAttachmentHelpers.describeTaskAttachment)
5234
4993
  : null;
5235
4994
  const canonicalAsset = overwriteTarget
5236
4995
  ? null
5237
- : buildCanonicalWorkspaceAssetRelativePath({
4996
+ : canonicalAssetHelpers.buildCanonicalWorkspaceAssetRelativePath({
5238
4997
  workspaceIdRaw: ACTIVE_WORKSPACE_ID,
5239
4998
  kind: inferAssetKind(safeFilename, inferMimeTypeFromPath(safeFilename)),
5240
4999
  filename: safeFilename
@@ -5257,7 +5016,7 @@ export function createTaskforceMcpServer(options = {}) {
5257
5016
  const sourceReal = fs.realpathSync(sourceResolved);
5258
5017
  const projectRootReal = fs.realpathSync(projectRoot);
5259
5018
  if (!isApprovedAttachmentSourcePath(sourceReal, projectRootReal)) {
5260
- throw new Error("sourcePath must be within project root or a Taskforce-managed attachment source");
5019
+ throw new Error("sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root");
5261
5020
  }
5262
5021
  if (!fs.statSync(sourceReal).isFile()) {
5263
5022
  throw new Error(`Path is not a file: ${trimmedSource}`);
@@ -5279,7 +5038,7 @@ export function createTaskforceMcpServer(options = {}) {
5279
5038
  const persistedCaption = isMarkdownDocument
5280
5039
  ? canonicalName
5281
5040
  : (safeCaption || overwriteTarget?.attachment.caption || safeFilename);
5282
- const asset = ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
5041
+ const asset = canonicalAssetHelpers.ensureCanonicalTaskAttachment(relativePath, id, persistedCaption, now, 'attachment', {
5283
5042
  originalFilename: safeFilename,
5284
5043
  logicalName: isMarkdownDocument ? canonicalName : safeFilename,
5285
5044
  ...(overwriteTarget
@@ -5295,7 +5054,7 @@ export function createTaskforceMcpServer(options = {}) {
5295
5054
  : {})
5296
5055
  });
5297
5056
  const attachment = {
5298
- path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isCanonicalDocumentAssetRecord(asset) && asset.assetId
5057
+ path: overwriteTarget?.attachment.path || (asset?.kind === 'document' && isValidCanonicalDocumentAsset(asset) && asset.assetId
5299
5058
  ? `/api/taskforce/documents/${encodeURIComponent(asset.assetId)}/content`
5300
5059
  : `/api/taskforce/context/${encodeURIComponent((canonicalAsset || { relativePath: overwriteTarget?.descriptor.fsPath || '' }).relativePath)}`),
5301
5060
  fsPath: overwriteTarget?.attachment.fsPath || canonicalAsset?.relativePath || overwriteTarget?.descriptor.fsPath,
@@ -5365,7 +5124,7 @@ export function createTaskforceMcpServer(options = {}) {
5365
5124
  throw new Error('attachmentId is required');
5366
5125
  const { id, task } = resolveTaskByIdentifierOrThrow(inputTaskId);
5367
5126
  const existing = Array.isArray(task.attachments) ? task.attachments : [];
5368
- const descriptors = listTaskAttachmentDescriptors(task);
5127
+ const descriptors = taskAttachmentHelpers.listTaskAttachmentDescriptors(task);
5369
5128
  const targetIndex = descriptors.findIndex((entry) => entry.attachmentId === attachmentId);
5370
5129
  if (targetIndex < 0) {
5371
5130
  throw new Error(`Attachment ${attachmentId} not found on task ${inputTaskId}`);