@taskforcehq/taskforce 0.3.301 → 0.3.302

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/Taskforce.module.css +26 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +13 -24
  4. package/dist/TaskforceCore.test.js +167 -152
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  14. package/dist/components/views/PlansPage.js +9 -8
  15. package/dist/components/views/StandaloneLayout.js +466 -204
  16. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  17. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +43 -0
  18. package/dist/components/views/standalone/modals/AccountHubModal.js +22 -0
  19. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  20. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  21. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  22. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  23. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  24. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  25. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  26. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  27. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  28. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  29. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  30. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  31. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  32. package/dist/components/views/standalone/modals/types.js +1 -0
  33. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  34. package/dist/components/views/teamManagementAccess.js +2 -2
  35. package/dist/components/views/teamManagementAccess.test.js +3 -3
  36. package/dist/config/deployment.js +2 -2
  37. package/dist/config/deployment.test.js +5 -3
  38. package/dist/core/AiProfileService.js +7 -2
  39. package/dist/core/AiProfiles.test.js +19 -0
  40. package/dist/core/SyncReconciliationService.d.ts +2 -0
  41. package/dist/core/SyncReconciliationService.js +3 -0
  42. package/dist/core/TaskAuditTimeline.test.js +40 -0
  43. package/dist/core/Taskforce.d.ts +49 -0
  44. package/dist/core/Taskforce.ids.test.js +133 -0
  45. package/dist/core/Taskforce.js +241 -78
  46. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  47. package/dist/core/UserProfileAvatarDrafts.test.js +102 -0
  48. package/dist/core/taskReferencePolicy.d.ts +28 -0
  49. package/dist/core/taskReferencePolicy.js +189 -0
  50. package/dist/core/types.d.ts +2 -0
  51. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  52. package/dist/hooks/useTaskData.js +3 -2
  53. package/dist/hooks/useTaskData.test.js +66 -0
  54. package/dist/hooks/useTaskforce.d.ts +5 -0
  55. package/dist/hooks/useTaskforce.js +63 -15
  56. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  57. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  58. package/dist/mcp/aiProfileBootstrap.js +80 -0
  59. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  60. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  61. package/dist/mcp/clientIntegrations.d.ts +2 -8
  62. package/dist/mcp/clientIntegrations.js +21 -40
  63. package/dist/mcp/clientIntegrations.test.js +28 -2
  64. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  65. package/dist/mcp/clientProfileDefaults.js +37 -0
  66. package/dist/mcp/runtime.d.ts +10 -7
  67. package/dist/mcp/runtime.js +41 -19
  68. package/dist/mcp/runtime.test.js +49 -2
  69. package/dist/migrations/taskSchemaMigrations.js +53 -1
  70. package/dist/qaOpen.test.d.ts +1 -0
  71. package/dist/qaOpen.test.js +56 -0
  72. package/dist/server/index.cookieProxy.test.js +1 -0
  73. package/dist/server/index.d.ts +1 -0
  74. package/dist/server/index.js +15 -1
  75. package/dist/server/index.test.js +14 -1
  76. package/dist/server/routes/admin.js +25 -22
  77. package/dist/server/routes/auth.d.ts +5 -0
  78. package/dist/server/routes/auth.js +350 -16
  79. package/dist/server/routes/billing.js +11 -1
  80. package/dist/server/routes/billing.test.js +32 -2
  81. package/dist/server/routes/documents.js +6 -1
  82. package/dist/server/routes/shared.d.ts +1 -1
  83. package/dist/server/routes/shared.js +11 -3
  84. package/dist/server/routes/sync.integration.test.js +130 -0
  85. package/dist/server/routes/sync.js +226 -1924
  86. package/dist/server/routes/tasks.js +18 -4
  87. package/dist/server/routes.js +26 -19
  88. package/dist/server/routes.test.js +352 -6
  89. package/dist/shared/runtimeContract.d.ts +1 -1
  90. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  91. package/dist/sync/collaborationSyncPayload.js +131 -0
  92. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  93. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  94. package/dist/sync/collaborationSyncState.d.ts +15 -0
  95. package/dist/sync/collaborationSyncState.js +60 -0
  96. package/dist/sync/contentSyncPayload.d.ts +45 -0
  97. package/dist/sync/contentSyncPayload.js +104 -0
  98. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  99. package/dist/sync/contentSyncPayload.test.js +104 -0
  100. package/dist/sync/contentSyncState.d.ts +88 -0
  101. package/dist/sync/contentSyncState.js +743 -0
  102. package/dist/sync/planningSyncPayload.d.ts +9 -0
  103. package/dist/sync/planningSyncPayload.js +82 -0
  104. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  105. package/dist/sync/planningSyncPayload.test.js +68 -0
  106. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  107. package/dist/sync/syncApplyHandlers.js +445 -0
  108. package/dist/sync/taskSyncPayload.d.ts +33 -0
  109. package/dist/sync/taskSyncPayload.js +126 -0
  110. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  111. package/dist/sync/taskSyncPayload.test.js +54 -0
  112. package/dist/sync/workspacePullFeed.d.ts +28 -0
  113. package/dist/sync/workspacePullFeed.js +301 -0
  114. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  115. package/dist/sync/workspacePullFeed.test.js +166 -0
  116. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  117. package/dist/sync/workspaceSyncModel.js +9 -2
  118. package/dist/sync/workspaceSyncModel.test.js +29 -0
  119. package/dist/test/setup.js +1 -1
  120. package/dist/types.d.ts +1 -0
  121. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-JG5jc3he.js} +1 -1
  122. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-Dvi02Y7q.js} +1 -1
  123. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-iys3zfca.js} +2 -2
  124. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-DJvLJI3A.js} +1 -1
  125. package/dist/ui/assets/PlansPage-BMPlpFpw.js +1 -0
  126. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +8 -0
  127. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  128. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-BguHeQaV.js} +1 -1
  129. package/dist/ui/assets/index-MXeWoebC.css +1 -0
  130. package/dist/ui/assets/index-YJ4k5QNa.js +6 -0
  131. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-C8EED3Oh.js} +1 -1
  132. package/dist/ui/index.html +3 -3
  133. package/dist/utils/billingStatusCache.d.ts +21 -0
  134. package/dist/utils/billingStatusCache.js +70 -0
  135. package/dist/utils/taskActivity.js +17 -4
  136. package/dist/utils/taskActivity.test.js +27 -0
  137. package/dist/utils/taskNormalization.js +10 -2
  138. package/dist/utils/taskReferences.d.ts +11 -1
  139. package/dist/utils/taskReferences.js +33 -1
  140. package/package.json +2 -1
  141. package/scripts/qa-open-lib.mjs +175 -0
  142. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  143. package/scripts/qa-open.mjs +352 -0
  144. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  145. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  146. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  147. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -8,13 +8,13 @@
8
8
  <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
9
9
  <link rel="apple-touch-icon" href="/taskforce/favicon/apple-touch-icon.png" />
10
10
  <title>Taskforce Dashboard</title>
11
- <script type="module" crossorigin src="/taskforce/assets/index-xBWLPOEb.js"></script>
11
+ <script type="module" crossorigin src="/taskforce/assets/index-YJ4k5QNa.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-react-CKJs5o3c.js">
13
- <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-icons-QZyhEwgT.js">
13
+ <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-icons-C8EED3Oh.js">
14
14
  <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-markdown-BUxTU7dS.js">
15
15
  <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-dnd-DRzYolkg.js">
16
16
  <link rel="modulepreload" crossorigin href="/taskforce/assets/vendor-router-BbWMxlnO.js">
17
- <link rel="stylesheet" crossorigin href="/taskforce/assets/index-BiiF1cLQ.css">
17
+ <link rel="stylesheet" crossorigin href="/taskforce/assets/index-MXeWoebC.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
@@ -0,0 +1,21 @@
1
+ export type BillingStatusPayload = {
2
+ stripeStatus?: string | null;
3
+ stripeCustomerId?: string | null;
4
+ stripeSubscriptionId?: string | null;
5
+ stripePriceId?: string | null;
6
+ billingInterval?: 'month' | 'year' | null;
7
+ trialEnd?: string | null;
8
+ currentPeriodEnd?: string | null;
9
+ effectiveUntil?: string | null;
10
+ entitlementState?: string;
11
+ planVersionId?: string;
12
+ planId?: string;
13
+ planName?: string | null;
14
+ gate?: string | null;
15
+ message?: string | null;
16
+ planSelectionRequired?: boolean;
17
+ };
18
+ export declare function invalidateBillingStatusCache(rawUrl: string): void;
19
+ export declare function loadBillingStatusCached(rawUrl: string, options?: {
20
+ force?: boolean;
21
+ }): Promise<BillingStatusPayload | null>;
@@ -0,0 +1,70 @@
1
+ import { logBootstrapDebug } from '../hooks/workspace/workspaceLocalState';
2
+ const BILLING_STATUS_CACHE_TTL_MS = 5_000;
3
+ const billingStatusCache = new Map();
4
+ function getTimingNow() {
5
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
6
+ }
7
+ function normalizeBillingStatusUrl(raw) {
8
+ return String(raw || '').trim();
9
+ }
10
+ export function invalidateBillingStatusCache(rawUrl) {
11
+ billingStatusCache.delete(normalizeBillingStatusUrl(rawUrl));
12
+ }
13
+ export async function loadBillingStatusCached(rawUrl, options) {
14
+ const url = normalizeBillingStatusUrl(rawUrl);
15
+ if (!url)
16
+ return null;
17
+ const force = options?.force === true;
18
+ const now = Date.now();
19
+ const cached = billingStatusCache.get(url);
20
+ if (!force && cached?.payload !== undefined && (now - cached.fetchedAt) < BILLING_STATUS_CACHE_TTL_MS) {
21
+ logBootstrapDebug('billing_status_cache_hit', {
22
+ url,
23
+ ageMs: now - cached.fetchedAt
24
+ });
25
+ return cached.payload;
26
+ }
27
+ if (!force && cached?.promise) {
28
+ logBootstrapDebug('billing_status_request_reused', {
29
+ url
30
+ });
31
+ return cached.promise;
32
+ }
33
+ const startedAt = getTimingNow();
34
+ const request = (async () => {
35
+ const res = await fetch(url, {
36
+ method: 'GET',
37
+ credentials: 'include'
38
+ });
39
+ if (!res.ok) {
40
+ throw new Error(String((await res.json().catch(() => ({})))?.error || 'Failed to load billing status.'));
41
+ }
42
+ const payload = await res.json().catch(() => ({}));
43
+ const normalized = (payload || null);
44
+ billingStatusCache.set(url, {
45
+ payload: normalized,
46
+ fetchedAt: Date.now(),
47
+ promise: null
48
+ });
49
+ logBootstrapDebug('billing_status_loaded', {
50
+ url,
51
+ durationMs: Math.round(getTimingNow() - startedAt),
52
+ fromCache: false
53
+ });
54
+ return normalized;
55
+ })().catch((error) => {
56
+ billingStatusCache.delete(url);
57
+ logBootstrapDebug('billing_status_failed', {
58
+ url,
59
+ durationMs: Math.round(getTimingNow() - startedAt),
60
+ error: error instanceof Error ? error.message : String(error || 'Unknown error')
61
+ });
62
+ throw error;
63
+ });
64
+ billingStatusCache.set(url, {
65
+ payload: cached?.payload ?? null,
66
+ fetchedAt: cached?.fetchedAt ?? 0,
67
+ promise: request
68
+ });
69
+ return request;
70
+ }
@@ -1,3 +1,12 @@
1
+ function getActivityFieldLabel(field) {
2
+ if (field === 'title')
3
+ return 'TITLE';
4
+ if (field === 'description')
5
+ return 'DESCRIPTION';
6
+ if (field === 'assignee')
7
+ return 'ASSIGNED TO';
8
+ return field;
9
+ }
1
10
  function formatValue(value, field, formatter) {
2
11
  const formatted = formatter?.(field, value);
3
12
  if (typeof formatted === 'string')
@@ -66,16 +75,20 @@ export function summarizeTaskChange(field, change, formatter) {
66
75
  if (field === 'checklistItems') {
67
76
  return summarizeChecklistChange(change);
68
77
  }
78
+ const label = getActivityFieldLabel(field);
79
+ if (field === 'title') {
80
+ return `${label}: updated`;
81
+ }
69
82
  if (field === 'description') {
70
83
  const hadBefore = formatValue(change?.from, field, formatter) !== 'none';
71
84
  const hasAfter = formatValue(change?.to, field, formatter) !== 'none';
72
85
  if (!hadBefore && hasAfter)
73
- return 'Description added';
86
+ return `${label}: added`;
74
87
  if (hadBefore && !hasAfter)
75
- return 'Description cleared';
76
- return 'Description updated';
88
+ return `${label}: cleared`;
89
+ return `${label}: updated`;
77
90
  }
78
- return `${field}: ${formatValue(change?.from, field, formatter)} -> ${formatValue(change?.to, field, formatter)}`;
91
+ return `${label}: ${formatValue(change?.from, field, formatter)} -> ${formatValue(change?.to, field, formatter)}`;
79
92
  }
80
93
  export function summarizeTaskEvent(event, formatter) {
81
94
  const changes = event.details?.changes || {};
@@ -1,6 +1,33 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { summarizeTaskChange } from './taskActivity';
3
3
  describe('summarizeTaskChange', () => {
4
+ it('summarizes title edits without dumping before and after text', () => {
5
+ expect(summarizeTaskChange('title', {
6
+ from: 'Old title',
7
+ to: 'New title',
8
+ })).toBe('TITLE: updated');
9
+ });
10
+ it('summarizes assignee changes with uppercase labels and formatted values', () => {
11
+ expect(summarizeTaskChange('assignee', { from: 'unassigned', to: 'member-1' }, (_field, value) => {
12
+ if (value === 'unassigned')
13
+ return 'Unassigned';
14
+ if (value === 'member-1')
15
+ return 'Emma Peel';
16
+ return undefined;
17
+ })).toBe('ASSIGNED TO: Unassigned -> Emma Peel');
18
+ });
19
+ it('summarizes description additions without rendering the body text', () => {
20
+ expect(summarizeTaskChange('description', {
21
+ from: '',
22
+ to: 'Line one\n\nLine two',
23
+ })).toBe('DESCRIPTION: added');
24
+ });
25
+ it('summarizes description clears without rendering the old body text', () => {
26
+ expect(summarizeTaskChange('description', {
27
+ from: 'Old body',
28
+ to: '',
29
+ })).toBe('DESCRIPTION: cleared');
30
+ });
4
31
  it('summarizes checklist completion clearly', () => {
5
32
  expect(summarizeTaskChange('checklistItems', {
6
33
  from: [{ id: 'a', title: 'First item', isCompleted: false, order: 0 }],
@@ -1,5 +1,5 @@
1
1
  import { cloneDefaultPriorityDefinitions } from '../shared/taxonomyDefaults.js';
2
- import { formatTaskReference } from './taskReferences.js';
2
+ import { formatLocalTaskReference, formatTaskReference } from './taskReferences.js';
3
3
  export const DEFAULT_PRIORITY_DEFINITIONS = cloneDefaultPriorityDefinitions();
4
4
  const PRIORITY_VALUE_BY_LABEL = {
5
5
  low: 1,
@@ -75,12 +75,20 @@ export function normalizeTaskFromApi(task) {
75
75
  : Number.isFinite(Number(task.referenceNumber))
76
76
  ? Number(task.referenceNumber)
77
77
  : null;
78
+ const localReferenceNumber = typeof task.localReferenceNumber === 'number'
79
+ ? task.localReferenceNumber
80
+ : Number.isFinite(Number(task.localReferenceNumber))
81
+ ? Number(task.localReferenceNumber)
82
+ : null;
78
83
  return {
79
84
  ...task,
80
85
  referenceNumber: referenceNumber && referenceNumber > 0 ? Math.floor(referenceNumber) : null,
86
+ localReferenceNumber: localReferenceNumber && localReferenceNumber > 0 ? Math.floor(localReferenceNumber) : null,
81
87
  referenceLabel: typeof task.referenceLabel === 'string' && task.referenceLabel.trim().length > 0
82
88
  ? task.referenceLabel.trim()
83
- : formatTaskReference(referenceNumber),
89
+ : (referenceNumber
90
+ ? formatTaskReference(referenceNumber)
91
+ : formatLocalTaskReference(localReferenceNumber)),
84
92
  assignee: (() => {
85
93
  const raw = String(task.assignee || '').trim();
86
94
  const normalized = raw.toLowerCase();
@@ -1,5 +1,15 @@
1
1
  import type { TaskItem } from '../types';
2
2
  export declare const TASK_REFERENCE_PREFIX = "T-";
3
+ export declare const LOCAL_TASK_REFERENCE_PREFIX = "LT-";
4
+ export interface TaskReferenceDisplayState {
5
+ label: string;
6
+ isProvisional: boolean;
7
+ }
3
8
  export declare function formatTaskReference(value: number | Pick<TaskItem, 'referenceNumber'> | null | undefined): string;
9
+ export declare function formatLocalTaskReference(value: number | Pick<TaskItem, 'localReferenceNumber'> | null | undefined): string;
4
10
  export declare function parseTaskReference(value: string | null | undefined): number | null;
5
- export declare function getTaskReferenceLabel(task: Pick<TaskItem, 'referenceNumber' | 'referenceLabel'> | null | undefined): string;
11
+ export declare function parseLocalTaskReference(value: string | null | undefined): number | null;
12
+ export declare function getTaskReferenceLabel(task: Pick<TaskItem, 'referenceNumber' | 'referenceLabel' | 'localReferenceNumber'> | null | undefined): string;
13
+ export declare function isTaskReferenceProvisional(task: Pick<TaskItem, 'referenceNumber' | 'localReferenceNumber'> | null | undefined): boolean;
14
+ export declare function getTaskReferenceDisplayState(task: Pick<TaskItem, 'referenceNumber' | 'referenceLabel' | 'localReferenceNumber'> | null | undefined): TaskReferenceDisplayState;
15
+ export declare function shouldUseProvisionalTaskReferences(runtimeMode: string | null | undefined): boolean;
@@ -1,11 +1,43 @@
1
1
  import { formatEntityReference, getEntityReferenceLabel, parseEntityReference, } from './entityReferences.js';
2
2
  export const TASK_REFERENCE_PREFIX = 'T-';
3
+ export const LOCAL_TASK_REFERENCE_PREFIX = 'LT-';
3
4
  export function formatTaskReference(value) {
4
5
  return formatEntityReference(TASK_REFERENCE_PREFIX, value);
5
6
  }
7
+ export function formatLocalTaskReference(value) {
8
+ if (typeof value === 'number' || value === null || value === undefined) {
9
+ return formatEntityReference(LOCAL_TASK_REFERENCE_PREFIX, value);
10
+ }
11
+ return formatEntityReference(LOCAL_TASK_REFERENCE_PREFIX, {
12
+ referenceNumber: value.localReferenceNumber ?? null
13
+ });
14
+ }
6
15
  export function parseTaskReference(value) {
7
16
  return parseEntityReference(TASK_REFERENCE_PREFIX, value);
8
17
  }
18
+ export function parseLocalTaskReference(value) {
19
+ return parseEntityReference(LOCAL_TASK_REFERENCE_PREFIX, value);
20
+ }
9
21
  export function getTaskReferenceLabel(task) {
10
- return getEntityReferenceLabel(TASK_REFERENCE_PREFIX, task);
22
+ if (!task)
23
+ return '';
24
+ if (typeof task.referenceLabel === 'string' && task.referenceLabel.trim().length > 0) {
25
+ return task.referenceLabel.trim();
26
+ }
27
+ const canonicalLabel = getEntityReferenceLabel(TASK_REFERENCE_PREFIX, task);
28
+ if (canonicalLabel)
29
+ return canonicalLabel;
30
+ return formatLocalTaskReference(task.localReferenceNumber);
31
+ }
32
+ export function isTaskReferenceProvisional(task) {
33
+ return !task?.referenceNumber && !!task?.localReferenceNumber;
34
+ }
35
+ export function getTaskReferenceDisplayState(task) {
36
+ return {
37
+ label: getTaskReferenceLabel(task),
38
+ isProvisional: isTaskReferenceProvisional(task)
39
+ };
40
+ }
41
+ export function shouldUseProvisionalTaskReferences(runtimeMode) {
42
+ return String(runtimeMode || '').trim().toLowerCase() === 'local';
11
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taskforcehq/taskforce",
3
- "version": "0.3.301",
3
+ "version": "0.3.302",
4
4
  "description": "Mission Command center for AI-powered task management (beta - under active development)",
5
5
  "author": "Taskforce HQ <hello@taskforcehq.ai> (https://taskforcehq.ai)",
6
6
  "license": "MIT",
@@ -78,6 +78,7 @@
78
78
  "test:e2e:realtime:reconnect": "npm run test:e2e:realtime -- tests/e2e/realtime-reconnect.spec.ts",
79
79
  "playwright:auth:bootstrap": "node scripts/bootstrap-playwright-auth.mjs",
80
80
  "playwright:auth": "sh scripts/playwright-auth.sh",
81
+ "qa:open": "node scripts/qa-open.mjs",
81
82
  "check:env": "node --import tsx scripts/check-env.ts --target=server --strict=true",
82
83
  "check:env:soak": "node --import tsx scripts/check-env.ts --target=soak --strict=true",
83
84
  "check:compat": "node scripts/check-compat-boundaries.mjs",
@@ -0,0 +1,175 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const QA_MODES = ['local', 'performance', 'staging'];
6
+
7
+ export const QA_CLOUD_BASE_URLS = {
8
+ performance: 'https://performance-app.taskforcehq.ai',
9
+ staging: 'https://staging-app.taskforcehq.ai'
10
+ };
11
+
12
+ function normalizePort(raw) {
13
+ const value = String(raw || '').trim();
14
+ if (!value) return null;
15
+ const parsed = Number.parseInt(value, 10);
16
+ if (!Number.isInteger(parsed) || parsed <= 0) {
17
+ return null;
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function stripQuotes(raw) {
23
+ const value = String(raw || '').trim();
24
+ if (
25
+ (value.startsWith('"') && value.endsWith('"'))
26
+ || (value.startsWith("'") && value.endsWith("'"))
27
+ ) {
28
+ return value.slice(1, -1);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function normalizeQaMode(raw) {
34
+ const normalized = String(raw || '').trim().toLowerCase();
35
+ return QA_MODES.includes(normalized) ? normalized : null;
36
+ }
37
+
38
+ export function sanitizeQaAgentId(raw) {
39
+ const normalized = String(raw || '')
40
+ .trim()
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9_-]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .replace(/-{2,}/g, '-');
45
+ return normalized || null;
46
+ }
47
+
48
+ export function parseQaArgs(argv, env = process.env) {
49
+ const positional = (argv || []).filter((arg) => !String(arg || '').startsWith('--'));
50
+ const mode = normalizeQaMode(positional[0] || env.TASKFORCE_QA_MODE || 'local');
51
+ const preferredPort = normalizePort(positional[1] || env.TASKFORCE_QA_PORT);
52
+ const agentId = sanitizeQaAgentId(env.TASKFORCE_QA_AGENT);
53
+ return { mode, agentId, preferredPort };
54
+ }
55
+
56
+ export function resolveQaPaths({ agentId, homeDir = os.homedir() }) {
57
+ const safeAgentId = sanitizeQaAgentId(agentId);
58
+ if (!safeAgentId) {
59
+ throw new Error('TASKFORCE_QA_AGENT is required and must contain at least one safe path character.');
60
+ }
61
+ const root = path.resolve(homeDir, '.taskforce', 'qa', safeAgentId);
62
+ return {
63
+ agentId: safeAgentId,
64
+ root,
65
+ projectRoot: path.join(root, 'project'),
66
+ envFile: path.join(root, 'env'),
67
+ logsDir: path.join(root, 'logs'),
68
+ artifactsDir: path.join(root, 'artifacts'),
69
+ homeDir: path.join(root, 'home'),
70
+ playwrightStorageState: path.join(root, 'playwright-auth.json')
71
+ };
72
+ }
73
+
74
+ export function isPathInside(parentPath, childPath) {
75
+ const parent = path.resolve(parentPath);
76
+ const child = path.resolve(childPath);
77
+ const relative = path.relative(parent, child);
78
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
79
+ }
80
+
81
+ export function assertQaProjectRootSafe(projectRoot, repoRoot) {
82
+ if (isPathInside(repoRoot, projectRoot)) {
83
+ throw new Error(
84
+ `Refusing to use QA project root inside the repository: ${projectRoot}\n`
85
+ + `Move the QA root outside ${repoRoot}.`
86
+ );
87
+ }
88
+ }
89
+
90
+ export function parseEnvFileContents(raw) {
91
+ const env = {};
92
+ const lines = String(raw || '').split(/\r?\n/);
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed || trimmed.startsWith('#')) continue;
96
+ const normalized = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed;
97
+ const separatorIndex = normalized.indexOf('=');
98
+ if (separatorIndex <= 0) continue;
99
+ const key = normalized.slice(0, separatorIndex).trim();
100
+ if (!key) continue;
101
+ const value = normalized.slice(separatorIndex + 1);
102
+ env[key] = stripQuotes(value);
103
+ }
104
+ return env;
105
+ }
106
+
107
+ export function loadEnvFile(filePath) {
108
+ const raw = fs.readFileSync(filePath, 'utf8');
109
+ return parseEnvFileContents(raw);
110
+ }
111
+
112
+ export function ensureQaRootStructure(paths, options = {}) {
113
+ const projectName = String(options.projectName || `taskforce-qa-${paths.agentId}`).trim();
114
+ fs.mkdirSync(paths.root, { recursive: true });
115
+ fs.mkdirSync(paths.projectRoot, { recursive: true });
116
+ fs.mkdirSync(paths.logsDir, { recursive: true });
117
+ fs.mkdirSync(paths.artifactsDir, { recursive: true });
118
+ fs.mkdirSync(paths.homeDir, { recursive: true });
119
+
120
+ const packageJsonPath = path.join(paths.projectRoot, 'package.json');
121
+ if (!fs.existsSync(packageJsonPath)) {
122
+ fs.writeFileSync(packageJsonPath, JSON.stringify({ name: projectName }, null, 2) + '\n', 'utf8');
123
+ }
124
+ return packageJsonPath;
125
+ }
126
+
127
+ export function resolveExpectedWorkspace(env) {
128
+ const workspaceId = String(env.TASKFORCE_QA_EXPECTED_WORKSPACE_ID || '').trim();
129
+ const workspaceSlug = String(env.TASKFORCE_QA_EXPECTED_WORKSPACE_SLUG || '').trim();
130
+ if (!workspaceId && !workspaceSlug) return null;
131
+ return {
132
+ workspaceId: workspaceId || null,
133
+ workspaceSlug: workspaceSlug || null
134
+ };
135
+ }
136
+
137
+ export function matchExpectedWorkspace(result, expected) {
138
+ const nestedWorkspaces = Array.isArray(result?.workspaces?.workspaces) ? result.workspaces.workspaces : [];
139
+ const workspaces = Array.isArray(result?.workspaces) ? result.workspaces : nestedWorkspaces;
140
+ const currentWorkspaceId = String(result?.currentWorkspaceId || '').trim();
141
+ if (!expected) {
142
+ return {
143
+ ok: true,
144
+ matchedWorkspaceId: currentWorkspaceId || null,
145
+ reason: 'No expected workspace configured.'
146
+ };
147
+ }
148
+
149
+ if (expected.workspaceId) {
150
+ return {
151
+ ok: currentWorkspaceId === expected.workspaceId,
152
+ matchedWorkspaceId: currentWorkspaceId || null,
153
+ reason: currentWorkspaceId === expected.workspaceId
154
+ ? 'Matched expected workspace id.'
155
+ : `Expected workspace id ${expected.workspaceId} but current workspace id was ${currentWorkspaceId || '(empty)'}.`
156
+ };
157
+ }
158
+
159
+ const matchedBySlug = workspaces.find((workspace) => String(workspace?.slug || '').trim() === expected.workspaceSlug);
160
+ if (!matchedBySlug) {
161
+ return {
162
+ ok: false,
163
+ matchedWorkspaceId: currentWorkspaceId || null,
164
+ reason: `Expected workspace slug ${expected.workspaceSlug} was not present in the available workspaces list.`
165
+ };
166
+ }
167
+
168
+ return {
169
+ ok: currentWorkspaceId === String(matchedBySlug.id || '').trim(),
170
+ matchedWorkspaceId: currentWorkspaceId || null,
171
+ reason: currentWorkspaceId === String(matchedBySlug.id || '').trim()
172
+ ? 'Matched expected workspace slug.'
173
+ : `Expected workspace slug ${expected.workspaceSlug} to resolve to workspace id ${String(matchedBySlug.id || '').trim()}, but current workspace id was ${currentWorkspaceId || '(empty)'}.`
174
+ };
175
+ }
@@ -0,0 +1,49 @@
1
+ export const QA_MODES: string[];
2
+ export const QA_CLOUD_BASE_URLS: Record<string, string>;
3
+
4
+ export function normalizeQaMode(raw: unknown): string | null;
5
+ export function sanitizeQaAgentId(raw: unknown): string | null;
6
+ export function parseQaArgs(
7
+ argv?: string[],
8
+ env?: Record<string, string | undefined>
9
+ ): { mode: string | null; agentId: string | null; preferredPort: number | null };
10
+ export function resolveQaPaths(input: {
11
+ agentId: string;
12
+ homeDir?: string;
13
+ }): {
14
+ agentId: string;
15
+ root: string;
16
+ projectRoot: string;
17
+ envFile: string;
18
+ logsDir: string;
19
+ artifactsDir: string;
20
+ homeDir: string;
21
+ playwrightStorageState: string;
22
+ };
23
+ export function isPathInside(parentPath: string, childPath: string): boolean;
24
+ export function assertQaProjectRootSafe(projectRoot: string, repoRoot: string): void;
25
+ export function parseEnvFileContents(raw: unknown): Record<string, string>;
26
+ export function loadEnvFile(filePath: string): Record<string, string>;
27
+ export function ensureQaRootStructure(
28
+ paths: {
29
+ agentId: string;
30
+ root: string;
31
+ projectRoot: string;
32
+ logsDir: string;
33
+ artifactsDir: string;
34
+ homeDir: string;
35
+ },
36
+ options?: { projectName?: string }
37
+ ): string;
38
+ export function resolveExpectedWorkspace(env: Record<string, string | undefined>): {
39
+ workspaceId: string | null;
40
+ workspaceSlug: string | null;
41
+ } | null;
42
+ export function matchExpectedWorkspace(
43
+ result: unknown,
44
+ expected: { workspaceId: string | null; workspaceSlug: string | null } | null
45
+ ): {
46
+ ok: boolean;
47
+ matchedWorkspaceId: string | null;
48
+ reason: string;
49
+ };