@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
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1
+ 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';
@@ -7,61 +7,8 @@ import { MCP_TOOL_REGISTRY } from './toolRegistry';
7
7
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce';
8
8
  import { WorkspaceAssetStore } from '../storage/workspaceAssetStore';
9
9
  import { ANNOTATED_ATTACHMENTS_WORKSPACE_FEATURE_KEY, APP_GATE_DEFINITIONS, } from '../runtime/appGates';
10
- function createProjectRoot() {
11
- const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-mcp-runtime-'));
12
- const core = new TaskforceCore({
13
- projectRoot,
14
- storagePath: path.join(projectRoot, '.taskforce')
15
- });
16
- try {
17
- Object.defineProperty(core, 'getAccountAccessStatus', {
18
- configurable: true,
19
- value: () => ({
20
- gate: 'ok',
21
- hasEntitlement: true,
22
- canAccessApp: true,
23
- canAccessPlans: true,
24
- signupMonetizationStrategy: 'none',
25
- planSelectionRequired: false,
26
- message: null,
27
- entitlement: null
28
- })
29
- });
30
- core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
31
- core.ensureBootstrapUserAccess({
32
- userId: 'owner-1',
33
- workspaceId: 'workspace-1',
34
- role: 'owner',
35
- email: 'owner-1@example.com'
36
- });
37
- }
38
- finally {
39
- core.close();
40
- }
41
- return projectRoot;
42
- }
43
- async function listTools(projectRoot, runtimeMode) {
44
- const runtime = createTaskforceMcpServer({
45
- projectRoot,
46
- runtimeMode,
47
- logStartup: false,
48
- auth: runtimeMode === 'cloud'
49
- ? { scopes: ['tasks:read', 'tasks:write', 'workspace:read'] }
50
- : undefined
51
- });
52
- const handler = runtime.server._requestHandlers.get('tools/list');
53
- if (!handler) {
54
- throw new Error('tools/list handler not registered');
55
- }
56
- return await handler({
57
- jsonrpc: '2.0',
58
- id: 'test-tools-list',
59
- method: 'tools/list',
60
- params: {}
61
- }, {});
62
- }
63
- async function callTool(projectRoot, runtimeMode, name, args, options) {
64
- const runtime = createTaskforceMcpServer({
10
+ function createRuntime(projectRoot, runtimeMode, options) {
11
+ return createTaskforceMcpServer({
65
12
  projectRoot,
66
13
  runtimeMode,
67
14
  workspaceId: options?.workspaceId,
@@ -83,6 +30,8 @@ async function callTool(projectRoot, runtimeMode, name, args, options) {
83
30
  }
84
31
  : undefined
85
32
  });
33
+ }
34
+ async function callToolWithRuntime(runtime, name, args, options) {
86
35
  const handler = runtime.server._requestHandlers.get('tools/call');
87
36
  if (!handler) {
88
37
  throw new Error('tools/call handler not registered');
@@ -112,20 +61,71 @@ async function callTool(projectRoot, runtimeMode, name, args, options) {
112
61
  }
113
62
  }, {});
114
63
  }
115
- async function readResource(projectRoot, runtimeMode, uri, options) {
116
- const runtime = createTaskforceMcpServer({
64
+ async function subscribeResource(runtime, uri) {
65
+ const handler = runtime.server._requestHandlers.get('resources/subscribe');
66
+ if (!handler) {
67
+ throw new Error('resources/subscribe handler not registered');
68
+ }
69
+ await handler({
70
+ jsonrpc: '2.0',
71
+ id: `test-resources-subscribe-${uri}`,
72
+ method: 'resources/subscribe',
73
+ params: { uri }
74
+ }, {});
75
+ }
76
+ function createProjectRoot() {
77
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-mcp-runtime-'));
78
+ const core = new TaskforceCore({
117
79
  projectRoot,
118
- runtimeMode,
119
- workspaceId: options?.workspaceId,
120
- logStartup: false,
121
- auth: runtimeMode === 'cloud'
122
- ? {
123
- scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
124
- userId: options?.authUserId || 'owner-1',
125
- actorType: 'oauth'
126
- }
127
- : undefined
80
+ storagePath: path.join(projectRoot, '.taskforce')
81
+ });
82
+ try {
83
+ Object.defineProperty(core, 'getAccountAccessStatus', {
84
+ configurable: true,
85
+ value: () => ({
86
+ gate: 'ok',
87
+ hasEntitlement: true,
88
+ canAccessApp: true,
89
+ canAccessPlans: true,
90
+ planSelectionRequired: false,
91
+ message: null,
92
+ entitlement: null
93
+ })
94
+ });
95
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
96
+ core.ensureBootstrapUserAccess({
97
+ userId: 'owner-1',
98
+ workspaceId: 'workspace-1',
99
+ role: 'owner',
100
+ email: 'owner-1@example.com'
101
+ });
102
+ }
103
+ finally {
104
+ core.close();
105
+ }
106
+ return projectRoot;
107
+ }
108
+ async function listTools(projectRoot, runtimeMode) {
109
+ const runtime = createRuntime(projectRoot, runtimeMode);
110
+ const handler = runtime.server._requestHandlers.get('tools/list');
111
+ if (!handler) {
112
+ throw new Error('tools/list handler not registered');
113
+ }
114
+ return await handler({
115
+ jsonrpc: '2.0',
116
+ id: 'test-tools-list',
117
+ method: 'tools/list',
118
+ params: {}
119
+ }, {});
120
+ }
121
+ async function callTool(projectRoot, runtimeMode, name, args, options) {
122
+ const runtime = createRuntime(projectRoot, runtimeMode, options);
123
+ return await callToolWithRuntime(runtime, name, args, {
124
+ resolveProfile: options?.resolveProfile,
128
125
  });
126
+ }
127
+ async function readResource(projectRoot, runtimeMode, uri, options) {
128
+ const runtime = createRuntime(projectRoot, runtimeMode, options);
129
129
  const handler = runtime.server._requestHandlers.get('resources/read');
130
130
  if (!handler) {
131
131
  throw new Error('resources/read handler not registered');
@@ -138,19 +138,7 @@ async function readResource(projectRoot, runtimeMode, uri, options) {
138
138
  }, {});
139
139
  }
140
140
  async function listResources(projectRoot, runtimeMode, options) {
141
- const runtime = createTaskforceMcpServer({
142
- projectRoot,
143
- runtimeMode,
144
- workspaceId: options?.workspaceId,
145
- logStartup: false,
146
- auth: runtimeMode === 'cloud'
147
- ? {
148
- scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
149
- userId: options?.authUserId || 'owner-1',
150
- actorType: 'oauth'
151
- }
152
- : undefined
153
- });
141
+ const runtime = createRuntime(projectRoot, runtimeMode, options);
154
142
  const handler = runtime.server._requestHandlers.get('resources/list');
155
143
  if (!handler) {
156
144
  throw new Error('resources/list handler not registered');
@@ -304,6 +292,29 @@ describe('Taskforce MCP tool descriptions', () => {
304
292
  fs.rmSync(projectRoot, { recursive: true, force: true });
305
293
  }
306
294
  });
295
+ it('does not let TASKFORCE_TOOLS prune the runtime tool catalog', async () => {
296
+ const projectRoot = createProjectRoot();
297
+ const previousValue = process.env.TASKFORCE_TOOLS;
298
+ process.env.TASKFORCE_TOOLS = 'get_task';
299
+ try {
300
+ const result = await listTools(projectRoot, 'cloud');
301
+ const runtimeToolNames = result.tools.map((tool) => tool.name).sort();
302
+ const registryToolNames = Object.entries(MCP_TOOL_REGISTRY)
303
+ .filter(([, entry]) => entry.permission.kind !== 'local-only' && entry.permission.kind !== 'session-only')
304
+ .map(([name]) => name)
305
+ .sort();
306
+ expect(runtimeToolNames).toEqual(registryToolNames);
307
+ }
308
+ finally {
309
+ if (previousValue === undefined) {
310
+ delete process.env.TASKFORCE_TOOLS;
311
+ }
312
+ else {
313
+ process.env.TASKFORCE_TOOLS = previousValue;
314
+ }
315
+ fs.rmSync(projectRoot, { recursive: true, force: true });
316
+ }
317
+ });
307
318
  it('includes local-only guidance in local runtime tool descriptions', async () => {
308
319
  const projectRoot = createProjectRoot();
309
320
  try {
@@ -425,11 +436,11 @@ describe('Taskforce MCP tool descriptions', () => {
425
436
  expect(linkTaskContext.description).toContain('external http(s) URL');
426
437
  expect(saveTaskAttachment.description).toContain('generated content');
427
438
  expect(saveTaskAttachment.description).toContain('overwrite=true');
428
- expect(saveTaskAttachment.description).toContain('AI-authored markdown/text should usually be passed inline via content');
439
+ expect(saveTaskAttachment.description).toContain('use inline content for AI-authored markdown/text');
429
440
  expect(saveTaskAttachment.inputSchema).toBeDefined();
430
441
  const saveTaskAttachmentSchema = saveTaskAttachment.inputSchema;
431
442
  expect(saveTaskAttachmentSchema.properties.content.description).toContain('use this for AI-authored markdown/text attachments');
432
- expect(saveTaskAttachmentSchema.properties.sourcePath.description).toContain('local runtime only today');
443
+ expect(saveTaskAttachmentSchema.properties.sourcePath.description).toContain('approved agent workspace root');
433
444
  expect(resolveProfile.description).toContain('AI collaborator profile');
434
445
  expect(resolveProfile.description).toContain('Update semantics');
435
446
  expect(resolveProfile.description).toContain('empty strings are ignored rather than clearing');
@@ -1500,6 +1511,150 @@ describe('Taskforce MCP tool descriptions', () => {
1500
1511
  fs.rmSync(projectRoot, { recursive: true, force: true });
1501
1512
  }
1502
1513
  });
1514
+ it('builds workstream listings from one historical task snapshot and returns task summaries', async () => {
1515
+ const projectRoot = createProjectRoot();
1516
+ const core = new TaskforceCore({
1517
+ projectRoot,
1518
+ storagePath: path.join(projectRoot, '.taskforce')
1519
+ });
1520
+ try {
1521
+ const category = core.getCategories()[0]?.value;
1522
+ const type = core.getTypes()[0]?.value;
1523
+ const firstWorkstream = core.createWorkstream({ title: 'Workstream one' }, 'workspace-1');
1524
+ const secondWorkstream = core.createWorkstream({ title: 'Workstream two' }, 'workspace-1');
1525
+ core.createTask({
1526
+ title: 'Linked task one',
1527
+ category,
1528
+ type,
1529
+ workstreamId: firstWorkstream.id,
1530
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1531
+ core.createTask({
1532
+ title: 'Linked task two',
1533
+ category,
1534
+ type,
1535
+ workstreamId: secondWorkstream.id,
1536
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1537
+ const archivedTask = core.createTask({
1538
+ title: 'Archived linked task',
1539
+ category,
1540
+ type,
1541
+ workstreamId: firstWorkstream.id,
1542
+ status: 'done',
1543
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1544
+ core.archiveTask(archivedTask.id, false, undefined, 'workspace-1', { actorRef: 'owner-1' });
1545
+ }
1546
+ finally {
1547
+ core.close();
1548
+ }
1549
+ const listTasksSpy = vi.spyOn(TaskforceCore.prototype, 'listTasks');
1550
+ const listArchiveSpy = vi.spyOn(TaskforceCore.prototype, 'listArchive');
1551
+ try {
1552
+ const beforeListTasksCalls = listTasksSpy.mock.calls.length;
1553
+ const beforeListArchiveCalls = listArchiveSpy.mock.calls.length;
1554
+ const payload = parseToolJson(await callTool(projectRoot, 'local', 'list_workstreams', {}));
1555
+ const firstWorkstreamPayload = payload.workstreams.find((entry) => entry.workstream.title === 'Workstream one');
1556
+ const linkedTask = firstWorkstreamPayload?.tasks.find((task) => task.title === 'Archived linked task');
1557
+ expect(listTasksSpy.mock.calls.length - beforeListTasksCalls).toBe(1);
1558
+ expect(listArchiveSpy.mock.calls.length - beforeListArchiveCalls).toBe(1);
1559
+ expect(linkedTask).toMatchObject({
1560
+ title: 'Archived linked task',
1561
+ workstreamId: firstWorkstreamPayload?.workstream.id,
1562
+ status: 'done',
1563
+ });
1564
+ expect(linkedTask.description).toBeUndefined();
1565
+ expect(linkedTask.comments).toBeUndefined();
1566
+ expect(linkedTask.activity).toBeUndefined();
1567
+ expect(linkedTask.attachments).toBeUndefined();
1568
+ expect(linkedTask.checklistItems).toBeUndefined();
1569
+ }
1570
+ finally {
1571
+ listTasksSpy.mockRestore();
1572
+ listArchiveSpy.mockRestore();
1573
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1574
+ }
1575
+ });
1576
+ it('queues bulk resource invalidations instead of awaiting them in the write path', async () => {
1577
+ const projectRoot = createProjectRoot();
1578
+ const runtime = createRuntime(projectRoot, 'local');
1579
+ const resourceUri = `taskforce://${path.basename(projectRoot)}/tasks`;
1580
+ const sendResourceUpdatedSpy = vi
1581
+ .spyOn(runtime.server, 'sendResourceUpdated')
1582
+ .mockImplementation(async () => {
1583
+ await new Promise((resolve) => setTimeout(resolve, 25));
1584
+ });
1585
+ try {
1586
+ await subscribeResource(runtime, resourceUri);
1587
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1588
+ title: 'Queued invalidation task'
1589
+ }));
1590
+ expect(created.title).toBe('Queued invalidation task');
1591
+ expect(sendResourceUpdatedSpy).not.toHaveBeenCalled();
1592
+ await new Promise((resolve) => setTimeout(resolve, 40));
1593
+ expect(sendResourceUpdatedSpy).toHaveBeenCalledTimes(1);
1594
+ expect(sendResourceUpdatedSpy).toHaveBeenCalledWith({ uri: resourceUri });
1595
+ }
1596
+ finally {
1597
+ sendResourceUpdatedSpy.mockRestore();
1598
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1599
+ }
1600
+ });
1601
+ it('reads the bulk tasks resource as summary-only rows', async () => {
1602
+ const projectRoot = createProjectRoot();
1603
+ try {
1604
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_task', {
1605
+ title: 'Verbose task',
1606
+ description: 'This should stay off the bulk tasks resource'
1607
+ }));
1608
+ await callTool(projectRoot, 'local', 'add_comment', {
1609
+ id: created.referenceLabel,
1610
+ text: 'This comment should stay off the bulk tasks resource',
1611
+ author: 'ai-profile-019d15c5-6ad3-7bf0-9b9e-fa9c366c3ff4'
1612
+ });
1613
+ const taskResource = await readResource(projectRoot, 'cloud', `taskforce://${path.basename(projectRoot)}/tasks`, {
1614
+ workspaceId: 'workspace-1',
1615
+ authUserId: 'owner-1'
1616
+ });
1617
+ const payload = JSON.parse(String(taskResource.contents[0]?.text || '{}'));
1618
+ const resourceTask = payload.tasks.find((task) => task.referenceLabel === created.referenceLabel);
1619
+ expect(resourceTask).toMatchObject({
1620
+ referenceLabel: created.referenceLabel,
1621
+ title: 'Verbose task',
1622
+ });
1623
+ expect(resourceTask.description).toBeUndefined();
1624
+ expect(resourceTask.comments).toBeUndefined();
1625
+ expect(resourceTask.activity).toBeUndefined();
1626
+ expect(resourceTask.attachments).toBeUndefined();
1627
+ expect(resourceTask.checklistItems).toBeUndefined();
1628
+ }
1629
+ finally {
1630
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1631
+ }
1632
+ });
1633
+ it('reads initiative listings with summary initiative and workstream records only', async () => {
1634
+ const projectRoot = createProjectRoot();
1635
+ try {
1636
+ const initiative = parseToolJson(await callTool(projectRoot, 'local', 'create_initiative', {
1637
+ title: 'Summary initiative',
1638
+ description: 'Verbose initiative details should stay off the bulk listing'
1639
+ }));
1640
+ await callTool(projectRoot, 'local', 'create_workstream', {
1641
+ title: 'Summary workstream',
1642
+ description: 'Verbose workstream details should stay off the bulk listing',
1643
+ initiativeId: initiative.initiative.referenceLabel
1644
+ });
1645
+ const initiatives = parseToolJson(await callTool(projectRoot, 'local', 'list_initiatives', {}));
1646
+ const listedInitiative = initiatives.initiatives.find((entry) => entry.initiative.referenceLabel === initiative.initiative.referenceLabel);
1647
+ const listedWorkstream = listedInitiative?.workstreams.find((entry) => entry.title === 'Summary workstream');
1648
+ expect(listedInitiative).toBeTruthy();
1649
+ expect(listedInitiative.initiative.description).toBeUndefined();
1650
+ expect(listedWorkstream).toBeTruthy();
1651
+ expect(listedWorkstream.description).toBeUndefined();
1652
+ expect(listedWorkstream.taskIds).toBeUndefined();
1653
+ }
1654
+ finally {
1655
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1656
+ }
1657
+ });
1503
1658
  it('creates MCP tasks as unassigned when no explicit assignee is provided', async () => {
1504
1659
  const projectRoot = createProjectRoot();
1505
1660
  try {
@@ -1512,6 +1667,40 @@ describe('Taskforce MCP tool descriptions', () => {
1512
1667
  fs.rmSync(projectRoot, { recursive: true, force: true });
1513
1668
  }
1514
1669
  });
1670
+ it('returns summary-only rows from list_tasks json output', async () => {
1671
+ const projectRoot = createProjectRoot();
1672
+ try {
1673
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_task', {
1674
+ title: 'List summary task',
1675
+ description: 'Verbose description that should not appear in list rows'
1676
+ }));
1677
+ await callTool(projectRoot, 'local', 'replace_task_checklist', {
1678
+ taskId: created.referenceLabel,
1679
+ items: [
1680
+ { title: 'Checklist item one', isCompleted: false }
1681
+ ]
1682
+ });
1683
+ await callTool(projectRoot, 'local', 'add_comment', {
1684
+ id: created.referenceLabel,
1685
+ text: 'Verbose comment that should not appear in list rows',
1686
+ author: 'ai-profile-019d15c5-6ad3-7bf0-9b9e-fa9c366c3ff4'
1687
+ });
1688
+ const listed = parseToolJson(await callTool(projectRoot, 'local', 'list_tasks', {
1689
+ format: 'json'
1690
+ }));
1691
+ const row = listed.find((task) => task.referenceLabel === created.referenceLabel);
1692
+ expect(row).toBeTruthy();
1693
+ expect(row.description).toBeUndefined();
1694
+ expect(row.comments).toBeUndefined();
1695
+ expect(row.taskEvents).toBeUndefined();
1696
+ expect(row.activity).toBeUndefined();
1697
+ expect(row.attachments).toBeUndefined();
1698
+ expect(row.checklistItems).toBeUndefined();
1699
+ }
1700
+ finally {
1701
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1702
+ }
1703
+ });
1515
1704
  it('reads task resources by T-XXX reference and exposes version/scope in config resource', async () => {
1516
1705
  const projectRoot = createProjectRoot();
1517
1706
  try {
@@ -1541,6 +1730,42 @@ describe('Taskforce MCP tool descriptions', () => {
1541
1730
  fs.rmSync(projectRoot, { recursive: true, force: true });
1542
1731
  }
1543
1732
  });
1733
+ it('returns summary-shaped task entries from the shared tasks resource', async () => {
1734
+ const projectRoot = createProjectRoot();
1735
+ try {
1736
+ const core = new TaskforceCore({
1737
+ projectRoot,
1738
+ storagePath: path.join(projectRoot, '.taskforce')
1739
+ });
1740
+ core.createTask({
1741
+ title: 'Resource summary task',
1742
+ description: 'Verbose task description',
1743
+ comments: [{ id: 'comment-1', author: 'user', text: 'Hello', timestamp: '2026-04-13T01:00:00.000Z' }],
1744
+ attachments: [{ name: 'note.md', path: 'notes/note.md' }],
1745
+ checklistItems: [{ id: 'item-1', taskId: 'temp', title: 'Check me', isCompleted: false, order: 0, createdAt: '2026-04-13T01:00:00.000Z' }],
1746
+ }, {
1747
+ workspaceId: 'workspace-1',
1748
+ actorRef: 'owner-1'
1749
+ });
1750
+ core.close();
1751
+ const tasksResource = await readResource(projectRoot, 'cloud', `taskforce://${path.basename(projectRoot)}/tasks`, {
1752
+ workspaceId: 'workspace-1',
1753
+ authUserId: 'owner-1'
1754
+ });
1755
+ const payload = JSON.parse(String(tasksResource.contents[0]?.text || '{}'));
1756
+ const task = payload.tasks.find((entry) => entry.title === 'Resource summary task');
1757
+ expect(task).toBeDefined();
1758
+ expect(task.description).toBeUndefined();
1759
+ expect(task.comments).toBeUndefined();
1760
+ expect(task.taskEvents).toBeUndefined();
1761
+ expect(task.activity).toBeUndefined();
1762
+ expect(task.attachments).toBeUndefined();
1763
+ expect(task.checklistItems).toBeUndefined();
1764
+ }
1765
+ finally {
1766
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1767
+ }
1768
+ });
1544
1769
  it('persists save_task_attachment through canonical task attachments', async () => {
1545
1770
  const projectRoot = createProjectRoot();
1546
1771
  try {
@@ -1770,8 +1995,45 @@ describe('Taskforce MCP tool descriptions', () => {
1770
1995
  fs.rmSync(projectRoot, { recursive: true, force: true });
1771
1996
  }
1772
1997
  });
1773
- it('rejects sourcePath for save_task_attachment in cloud runtime', async () => {
1998
+ it('allows save_task_attachment sourcePath in cloud runtime for approved agent workspace roots', async () => {
1774
1999
  const projectRoot = createProjectRoot();
2000
+ const agentWorkspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-mcp-agent-workspace-'));
2001
+ const previousRoots = process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS;
2002
+ try {
2003
+ const core = new TaskforceCore({
2004
+ projectRoot,
2005
+ storagePath: path.join(projectRoot, '.taskforce')
2006
+ });
2007
+ const task = core.createTask({ title: 'Cloud sourcePath allow test' }, { workspaceId: 'workspace-1' });
2008
+ core.close();
2009
+ const sourcePath = path.join(agentWorkspaceRoot, 'implementation-plan.md');
2010
+ fs.writeFileSync(sourcePath, '# Recovered implementation plan', 'utf8');
2011
+ process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS = agentWorkspaceRoot;
2012
+ await callTool(projectRoot, 'cloud', 'save_task_attachment', {
2013
+ id: task.id,
2014
+ filename: 'implementation-plan.md',
2015
+ sourcePath
2016
+ });
2017
+ const readBack = parseToolJson(await callTool(projectRoot, 'cloud', 'read_task_attachment', {
2018
+ taskId: task.id,
2019
+ attachmentId: parseToolJson(await callTool(projectRoot, 'cloud', 'get_task_attachments', { id: task.id })).attachments[0]?.attachmentId
2020
+ }));
2021
+ expect(readBack.content).toContain('# Recovered implementation plan');
2022
+ }
2023
+ finally {
2024
+ if (previousRoots === undefined) {
2025
+ delete process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS;
2026
+ }
2027
+ else {
2028
+ process.env.TASKFORCE_ALLOWED_ATTACHMENT_SOURCE_ROOTS = previousRoots;
2029
+ }
2030
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2031
+ fs.rmSync(agentWorkspaceRoot, { recursive: true, force: true });
2032
+ }
2033
+ });
2034
+ it('rejects save_task_attachment sourcePath in cloud runtime for unapproved roots', async () => {
2035
+ const projectRoot = createProjectRoot();
2036
+ const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-mcp-cloud-unapproved-'));
1775
2037
  try {
1776
2038
  const core = new TaskforceCore({
1777
2039
  projectRoot,
@@ -1779,16 +2041,18 @@ describe('Taskforce MCP tool descriptions', () => {
1779
2041
  });
1780
2042
  const task = core.createTask({ title: 'Cloud sourcePath reject test' }, { workspaceId: 'workspace-1' });
1781
2043
  core.close();
2044
+ const sourcePath = path.join(externalRoot, 'outside.md');
2045
+ fs.writeFileSync(sourcePath, '# Outside approved roots', 'utf8');
1782
2046
  const result = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
1783
2047
  id: task.id,
1784
- filename: 'copy.txt',
1785
- sourcePath: 'docs/source.txt'
2048
+ filename: 'outside.md',
2049
+ sourcePath
1786
2050
  });
1787
- expect(result.content?.[0]?.text).toContain('does not support sourcePath for save_task_attachment yet');
1788
- expect(result.content?.[0]?.text).toContain('resend the attachment using inline content instead');
2051
+ expect(result.content?.[0]?.text).toContain('sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root');
1789
2052
  }
1790
2053
  finally {
1791
2054
  fs.rmSync(projectRoot, { recursive: true, force: true });
2055
+ fs.rmSync(externalRoot, { recursive: true, force: true });
1792
2056
  }
1793
2057
  });
1794
2058
  it('accepts task references for save_task_attachment', async () => {
@@ -2140,7 +2404,7 @@ describe('Taskforce MCP tool descriptions', () => {
2140
2404
  filename: 'outside.md',
2141
2405
  sourcePath: externalSourcePath
2142
2406
  });
2143
- expect(result.content?.[0]?.text).toContain('sourcePath must be within project root or a Taskforce-managed attachment source');
2407
+ expect(result.content?.[0]?.text).toContain('sourcePath must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root');
2144
2408
  }
2145
2409
  finally {
2146
2410
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -0,0 +1,41 @@
1
+ import type { WorkspaceAssetEntry, WorkspaceAssetKind, WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
2
+ import type { McpTaskAttachmentDescriptor, TaskAttachmentRecord } from './taskAttachmentTypes.js';
3
+ export interface TaskAttachmentHelpersDeps {
4
+ activeWorkspaceId: string;
5
+ workspaceAssetStore: WorkspaceAssetStore | null;
6
+ getImageReferenceLabel: (asset: WorkspaceAssetEntry | null | undefined) => string | null;
7
+ getDocumentReferenceLabel: (asset: WorkspaceAssetEntry | null | undefined) => string | null;
8
+ inferMimeTypeFromPath: (filePath: string) => string;
9
+ inferAssetKind: (filePath: string, mimeType: string) => WorkspaceAssetKind;
10
+ isTextReadableMimeType: (mimeTypeRaw: string) => boolean;
11
+ isValidCanonicalDocumentAsset: (params: Pick<WorkspaceAssetEntry, 'kind' | 'storageKey' | 'originalFilename' | 'mimeType'>) => boolean;
12
+ canonicalAssetHelpers: {
13
+ getAttachmentAssetRecord: (attachment: TaskAttachmentRecord) => WorkspaceAssetEntry | null;
14
+ normalizeAttachmentStorageKey: (pathValue: unknown, fsPathValue: unknown) => string | null;
15
+ buildSyntheticAttachmentId: (taskId: string, attachment: TaskAttachmentRecord) => string;
16
+ toTaskAttachmentRecord: (attachment: string | TaskAttachmentRecord) => TaskAttachmentRecord;
17
+ };
18
+ documentHelpers: {
19
+ buildCanonicalDocumentAttachmentHint: (asset: {
20
+ assetId: string;
21
+ documentId: string | null;
22
+ referenceNumber?: number | null;
23
+ mimeType?: string | null;
24
+ }) => {
25
+ documentId: string | null;
26
+ preferredNextTool: string;
27
+ preferredWorkflow: string;
28
+ suggestedArgs: {
29
+ documentId: string;
30
+ };
31
+ guidance: string;
32
+ } | null;
33
+ };
34
+ }
35
+ export declare function createTaskAttachmentHelpers(deps: TaskAttachmentHelpersDeps): {
36
+ describeTaskAttachment: (taskId: string, attachmentInput: string | TaskAttachmentRecord) => McpTaskAttachmentDescriptor;
37
+ listTaskAttachmentDescriptors: (task: {
38
+ id: string;
39
+ attachments?: Array<string | TaskAttachmentRecord> | null;
40
+ }) => McpTaskAttachmentDescriptor[];
41
+ };
@@ -0,0 +1,60 @@
1
+ export function createTaskAttachmentHelpers(deps) {
2
+ const isCanonicalDocumentAssetRecord = (asset) => deps.isValidCanonicalDocumentAsset(asset);
3
+ const describeTaskAttachment = (taskId, attachmentInput) => {
4
+ const attachment = deps.canonicalAssetHelpers.toTaskAttachmentRecord(attachmentInput);
5
+ const asset = deps.canonicalAssetHelpers.getAttachmentAssetRecord(attachment);
6
+ const storageKey = deps.canonicalAssetHelpers.normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
7
+ const assetId = String(attachment.assetId || asset?.assetId || '').trim() || undefined;
8
+ const source = (asset?.storageProvider === 'external' || /^https?:\/\//i.test(String(attachment.path || '').trim()))
9
+ ? 'external'
10
+ : 'canonical';
11
+ const mimeType = String(asset?.mimeType
12
+ || (storageKey && !/^https?:\/\//i.test(storageKey) ? deps.inferMimeTypeFromPath(storageKey) : '')
13
+ || deps.inferMimeTypeFromPath(String(attachment.originalFilename || attachment.displayName || attachment.caption || attachment.path || ''))).trim() || 'application/octet-stream';
14
+ const kind = asset?.kind || deps.inferAssetKind(storageKey || String(attachment.path || ''), mimeType);
15
+ const isCanonicalDocument = Boolean(asset && isCanonicalDocumentAssetRecord(asset)) || (!asset && kind === 'document');
16
+ const referenceNumber = typeof attachment.referenceNumber === 'number'
17
+ ? attachment.referenceNumber
18
+ : (typeof asset?.referenceNumber === 'number' ? asset.referenceNumber : null);
19
+ const referenceLabel = String(attachment.referenceLabel
20
+ || (kind === 'image' ? deps.getImageReferenceLabel(asset) : '')
21
+ || (isCanonicalDocument ? deps.getDocumentReferenceLabel(asset) : '')).trim() || null;
22
+ const readableAsText = source === 'canonical' && deps.isTextReadableMimeType(mimeType);
23
+ const canonicalDocumentHint = isCanonicalDocument && source === 'canonical' && asset
24
+ ? deps.documentHelpers.buildCanonicalDocumentAttachmentHint(asset)
25
+ : null;
26
+ return {
27
+ attachmentId: assetId || deps.canonicalAssetHelpers.buildSyntheticAttachmentId(taskId, attachment),
28
+ ...(assetId ? { assetId } : {}),
29
+ ...(referenceNumber ? { referenceNumber } : {}),
30
+ ...(referenceLabel ? { referenceLabel } : {}),
31
+ ...(canonicalDocumentHint?.documentId ? { documentId: canonicalDocumentHint.documentId } : {}),
32
+ path: String(attachment.path || '').trim(),
33
+ ...(storageKey && !/^https?:\/\//i.test(storageKey) ? { fsPath: storageKey } : (attachment.fsPath ? { fsPath: String(attachment.fsPath).trim() } : {})),
34
+ ...(attachment.caption ? { caption: String(attachment.caption).trim() } : {}),
35
+ ...(attachment.displayName ? { displayName: String(attachment.displayName).trim() } : {}),
36
+ ...(attachment.originalFilename ? { originalFilename: String(attachment.originalFilename).trim() } : {}),
37
+ kind: isCanonicalDocument ? 'document' : kind,
38
+ mimeType,
39
+ sizeBytes: asset ? Number(asset.sizeBytes || 0) : null,
40
+ ...(attachment.timestamp ? { timestamp: String(attachment.timestamp).trim() } : {}),
41
+ source,
42
+ readableAsText,
43
+ ...(canonicalDocumentHint ? {
44
+ preferredNextTool: canonicalDocumentHint.preferredNextTool,
45
+ preferredWorkflow: canonicalDocumentHint.preferredWorkflow,
46
+ suggestedArgs: canonicalDocumentHint.suggestedArgs,
47
+ guidance: canonicalDocumentHint.guidance,
48
+ } : {}),
49
+ };
50
+ };
51
+ const listTaskAttachmentDescriptors = (task) => {
52
+ if (!task || !Array.isArray(task.attachments))
53
+ return [];
54
+ return task.attachments.map((attachment) => describeTaskAttachment(task.id, attachment));
55
+ };
56
+ return {
57
+ describeTaskAttachment,
58
+ listTaskAttachmentDescriptors,
59
+ };
60
+ }