fraim-framework 2.0.86 → 2.0.88

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.
@@ -51,7 +51,15 @@ const LOCAL_PROVIDERS = [
51
51
  capabilities: ['code', 'issues', 'integrated'],
52
52
  docsUrl: 'https://docs.microsoft.com/azure/devops',
53
53
  setupInstructions: 'Create a Personal Access Token in Azure DevOps',
54
- hasAdditionalConfig: false
54
+ hasAdditionalConfig: true,
55
+ mcpServer: {
56
+ type: 'stdio',
57
+ command: 'npx',
58
+ args: ['-y', '@azure-devops/mcp', '{config.organization}', '--authentication', 'envvar'],
59
+ envTemplate: {
60
+ ADO_MCP_AUTH_TOKEN: '{token}'
61
+ }
62
+ }
55
63
  },
56
64
  {
57
65
  id: 'jira',
@@ -73,6 +81,16 @@ const LOCAL_PROVIDERS = [
73
81
  }
74
82
  }
75
83
  ];
84
+ const ADO_CONFIG_REQUIREMENTS = [
85
+ {
86
+ key: 'organization',
87
+ displayName: 'Azure DevOps Organization',
88
+ description: 'Your Azure DevOps organization name (e.g., contoso)',
89
+ required: true,
90
+ type: 'string',
91
+ cliOptionName: 'organization'
92
+ }
93
+ ];
76
94
  const JIRA_CONFIG_REQUIREMENTS = [
77
95
  {
78
96
  key: 'baseUrl',
@@ -111,6 +129,9 @@ function getLocalProviderSetupInstructions(providerId) {
111
129
  return provider?.setupInstructions || '';
112
130
  }
113
131
  function getLocalProviderConfigRequirements(providerId) {
132
+ if (providerId === 'ado') {
133
+ return ADO_CONFIG_REQUIREMENTS;
134
+ }
114
135
  if (providerId === 'jira') {
115
136
  return JIRA_CONFIG_REQUIREMENTS;
116
137
  }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DeviceFlowService = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ class DeviceFlowService {
10
+ constructor(config) {
11
+ this.config = config;
12
+ }
13
+ /**
14
+ * Start the Device Flow Login
15
+ */
16
+ async login() {
17
+ console.log(chalk_1.default.blue('\nšŸ”— Starting Authentication...'));
18
+ try {
19
+ // 1. Request device and user codes
20
+ const deviceCode = await this.requestDeviceCode();
21
+ console.log(chalk_1.default.yellow('\nACTION REQUIRED:'));
22
+ console.log(`1. Go to: ${chalk_1.default.cyan.underline(deviceCode.verification_uri)}`);
23
+ console.log(`2. Enter the code: ${chalk_1.default.bold.green(deviceCode.user_code)}`);
24
+ console.log(chalk_1.default.gray(`\nWaiting for authorization (expires in ${Math.floor(deviceCode.expires_in / 60)} minutes)...`));
25
+ // 2. Poll for the access token
26
+ const token = await this.pollForToken(deviceCode.device_code, deviceCode.interval);
27
+ console.log(chalk_1.default.green('\nāœ… Authentication Successful!'));
28
+ return token;
29
+ }
30
+ catch (error) {
31
+ console.error(chalk_1.default.red(`\nāŒ Authentication failed: ${error.message}`));
32
+ throw error;
33
+ }
34
+ }
35
+ async requestDeviceCode() {
36
+ const response = await axios_1.default.post(this.config.authUrl, {
37
+ client_id: this.config.clientId,
38
+ scope: this.config.scope
39
+ }, {
40
+ headers: { Accept: 'application/json' }
41
+ });
42
+ return response.data;
43
+ }
44
+ async pollForToken(deviceCode, interval) {
45
+ let currentInterval = interval * 1000;
46
+ return new Promise((resolve, reject) => {
47
+ const poll = async () => {
48
+ try {
49
+ const response = await axios_1.default.post(this.config.tokenUrl, {
50
+ client_id: this.config.clientId,
51
+ device_code: deviceCode,
52
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
53
+ }, {
54
+ headers: { Accept: 'application/json' }
55
+ });
56
+ if (response.data.access_token) {
57
+ resolve(response.data.access_token);
58
+ return;
59
+ }
60
+ if (response.data.error) {
61
+ const error = response.data.error;
62
+ if (error === 'authorization_pending') {
63
+ // Keep polling
64
+ setTimeout(poll, currentInterval);
65
+ }
66
+ else if (error === 'slow_down') {
67
+ currentInterval += 5000;
68
+ setTimeout(poll, currentInterval);
69
+ }
70
+ else {
71
+ reject(new Error(response.data.error_description || error));
72
+ }
73
+ }
74
+ }
75
+ catch (error) {
76
+ reject(error);
77
+ }
78
+ };
79
+ setTimeout(poll, currentInterval);
80
+ });
81
+ }
82
+ }
83
+ exports.DeviceFlowService = DeviceFlowService;
@@ -60,6 +60,10 @@ async function promptForProviders(client, preselectedIds) {
60
60
  value: provider.id,
61
61
  selected: preselectedIds?.includes(provider.id) ?? provider.id === defaultProviderId
62
62
  }));
63
+ if (process.env.FRAIM_NON_INTERACTIVE) {
64
+ console.log(chalk_1.default.yellow(`\nā„¹ļø Non-interactive mode: defaulting to ${integratedProviders[0]?.displayName || 'first available provider'}`));
65
+ return [defaultProviderId];
66
+ }
63
67
  const response = await (0, prompts_1.default)({
64
68
  type: 'multiselect',
65
69
  name: 'providers',
@@ -109,6 +113,10 @@ async function promptForSingleProvider(client, purpose, availableIds) {
109
113
  title: provider.displayName,
110
114
  value: provider.id
111
115
  }));
116
+ if (process.env.FRAIM_NON_INTERACTIVE) {
117
+ console.log(chalk_1.default.yellow(`\nā„¹ļø Non-interactive mode: defaulting to ${providers[0]?.displayName || 'first available provider'}`));
118
+ return defaultProviderId;
119
+ }
112
120
  const response = await (0, prompts_1.default)({
113
121
  type: 'select',
114
122
  name: 'provider',
@@ -145,6 +153,9 @@ async function promptForProviderToken(client, providerId) {
145
153
  }
146
154
  console.log(chalk_1.default.blue(`\nšŸ”§ ${provider.displayName} Integration Setup`));
147
155
  console.log(`FRAIM requires a ${provider.displayName} token for integration.\n`);
156
+ if (process.env.FRAIM_NON_INTERACTIVE) {
157
+ throw new Error(`Non-interactive mode: ${provider.displayName} token is missing and cannot prompt.`);
158
+ }
148
159
  const hasToken = await (0, prompts_1.default)({
149
160
  type: 'confirm',
150
161
  name: 'hasToken',
@@ -152,6 +163,27 @@ async function promptForProviderToken(client, providerId) {
152
163
  initial: false
153
164
  });
154
165
  if (!hasToken.hasToken) {
166
+ if (providerId === 'github') {
167
+ const loginChoice = await (0, prompts_1.default)({
168
+ type: 'confirm',
169
+ name: 'login',
170
+ message: `Would you like to login to ${provider.displayName} now using your browser? (Recommended)`,
171
+ initial: true
172
+ });
173
+ if (loginChoice.login) {
174
+ const { DeviceFlowService } = await Promise.resolve().then(() => __importStar(require('../internal/device-flow-service')));
175
+ if (!provider.deviceFlowConfig) {
176
+ throw new Error(`Device flow configuration not found for provider: ${providerId}`);
177
+ }
178
+ const deviceFlow = new DeviceFlowService(provider.deviceFlowConfig);
179
+ try {
180
+ return await deviceFlow.login();
181
+ }
182
+ catch (e) {
183
+ console.log(chalk_1.default.yellow('\nBrowser login failed or was cancelled. Fallback to manual token entry.'));
184
+ }
185
+ }
186
+ }
155
187
  console.log(chalk_1.default.yellow(`\nšŸ“ To create a ${provider.displayName} token:`));
156
188
  console.log(chalk_1.default.gray(` ${provider.setupInstructions}`));
157
189
  console.log(chalk_1.default.gray(` Visit: ${provider.docsUrl}\n`));
@@ -225,6 +257,13 @@ async function promptForProviderConfig(client, providerId) {
225
257
  console.log(`Additional configuration required for ${provider.displayName}.\n`);
226
258
  const config = {};
227
259
  for (const req of requirements) {
260
+ if (process.env.FRAIM_NON_INTERACTIVE) {
261
+ if (req.required) {
262
+ throw new Error(`Non-interactive mode: Required configuration "${req.displayName}" for ${provider.displayName} is missing.`);
263
+ }
264
+ console.log(chalk_1.default.yellow(`\nā„¹ļø Non-interactive mode: skipping optional configuration "${req.displayName}"`));
265
+ continue;
266
+ }
228
267
  const response = await (0, prompts_1.default)({
229
268
  type: req.type === 'email' ? 'text' : req.type === 'url' ? 'text' : 'text',
230
269
  name: 'value',
@@ -17,6 +17,81 @@ const fs_1 = require("fs");
17
17
  const path_1 = require("path");
18
18
  const chalk_1 = __importDefault(require("chalk"));
19
19
  const script_sync_utils_1 = require("./script-sync-utils");
20
+ const fraim_gitignore_1 = require("./fraim-gitignore");
21
+ const LOCK_SYNCED_CONTENT_ENV = 'FRAIM_LOCK_SYNCED_CONTENT';
22
+ const SYNCED_CONTENT_BANNER_MARKER = '<!-- FRAIM_SYNC_MANAGED_CONTENT -->';
23
+ function shouldLockSyncedContent() {
24
+ const raw = process.env[LOCK_SYNCED_CONTENT_ENV];
25
+ if (!raw) {
26
+ return true;
27
+ }
28
+ const normalized = raw.trim().toLowerCase();
29
+ return !['0', 'false', 'off', 'no'].includes(normalized);
30
+ }
31
+ function getSyncedContentLockTargets(projectRoot) {
32
+ return fraim_gitignore_1.FRAIM_SYNC_GITIGNORE_ENTRIES
33
+ .map((entry) => entry.replace(/[\\/]+$/, ''))
34
+ .filter((entry) => entry.length > 0)
35
+ .map((entry) => (0, path_1.join)(projectRoot, entry));
36
+ }
37
+ function setFileWriteLockRecursively(dirPath, readOnly) {
38
+ if (!(0, fs_1.existsSync)(dirPath)) {
39
+ return;
40
+ }
41
+ const entries = (0, fs_1.readdirSync)(dirPath, { withFileTypes: true });
42
+ for (const entry of entries) {
43
+ const fullPath = (0, path_1.join)(dirPath, entry.name);
44
+ if (entry.isDirectory()) {
45
+ setFileWriteLockRecursively(fullPath, readOnly);
46
+ continue;
47
+ }
48
+ try {
49
+ // Cross-platform write lock for text files:
50
+ // - Unix: mode bits
51
+ // - Windows: toggles read-only attribute behavior for file writes
52
+ (0, fs_1.chmodSync)(fullPath, readOnly ? 0o444 : 0o666);
53
+ }
54
+ catch {
55
+ // Best-effort permission adjustment; keep sync non-blocking.
56
+ }
57
+ }
58
+ }
59
+ function getBannerRegistryPath(file) {
60
+ if (file.type === 'job') {
61
+ return `jobs/${file.path}`;
62
+ }
63
+ if (file.type === 'skill') {
64
+ return `skills/${file.path}`;
65
+ }
66
+ if (file.type === 'rule') {
67
+ return `rules/${file.path}`;
68
+ }
69
+ return null;
70
+ }
71
+ function insertAfterFrontmatter(content, banner) {
72
+ const normalized = content.replace(/^\uFEFF/, '');
73
+ const frontmatterMatch = normalized.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n)?/);
74
+ if (!frontmatterMatch) {
75
+ return `${banner}${normalized}`;
76
+ }
77
+ const frontmatter = frontmatterMatch[0];
78
+ const body = normalized.slice(frontmatter.length);
79
+ return `${frontmatter}${banner}${body}`;
80
+ }
81
+ function buildSyncedContentBanner(typeLabel) {
82
+ return `${SYNCED_CONTENT_BANNER_MARKER}\r\n> [!IMPORTANT]\r\n> This ${typeLabel} is synced from FRAIM and will be overwritten on the next \`fraim sync\`.\r\n> Do not edit this file.\r\n`;
83
+ }
84
+ function applySyncedContentBanner(file) {
85
+ const registryPath = getBannerRegistryPath(file);
86
+ if (!registryPath) {
87
+ return file.content;
88
+ }
89
+ const typeLabel = file.type === 'job' || file.type === 'skill' || file.type === 'rule'
90
+ ? `${file.type} stub`
91
+ : `${file.type} file`;
92
+ const banner = buildSyncedContentBanner(typeLabel);
93
+ return insertAfterFrontmatter(file.content, banner);
94
+ }
20
95
  /**
21
96
  * Sync workflows and scripts from remote FRAIM server
22
97
  */
@@ -61,23 +136,56 @@ async function syncFromRemote(options) {
61
136
  error: 'No files received'
62
137
  };
63
138
  }
64
- // Sync workflows
65
- const workflowFiles = files.filter(f => f.type === 'workflow');
66
- const workflowsDir = (0, path_1.join)(options.projectRoot, '.fraim', 'workflows');
67
- if (!(0, fs_1.existsSync)(workflowsDir)) {
68
- (0, fs_1.mkdirSync)(workflowsDir, { recursive: true });
69
- }
70
- // Clean existing workflows
71
- cleanDirectory(workflowsDir);
72
- // Write workflow files
73
- for (const file of workflowFiles) {
74
- const filePath = (0, path_1.join)(workflowsDir, file.path);
139
+ const lockTargets = getSyncedContentLockTargets(options.projectRoot);
140
+ if (shouldLockSyncedContent()) {
141
+ // If previous sync locked these paths read-only, temporarily unlock before cleanup/write.
142
+ for (const target of lockTargets) {
143
+ setFileWriteLockRecursively(target, false);
144
+ }
145
+ }
146
+ // Sync workflows to role-specific folders under .fraim
147
+ const allWorkflowFiles = files.filter(f => f.type === 'workflow');
148
+ const managerWorkflowFiles = allWorkflowFiles.filter(f => f.path.startsWith('ai-manager/'));
149
+ const employeeWorkflowFiles = allWorkflowFiles.filter(f => !f.path.startsWith('ai-manager/'));
150
+ // Write employee workflows
151
+ const employeeWorkflowsDir = (0, path_1.join)(options.projectRoot, '.fraim', 'ai-employee', 'workflows');
152
+ if (!(0, fs_1.existsSync)(employeeWorkflowsDir)) {
153
+ (0, fs_1.mkdirSync)(employeeWorkflowsDir, { recursive: true });
154
+ }
155
+ cleanDirectory(employeeWorkflowsDir);
156
+ for (const file of employeeWorkflowFiles) {
157
+ // Strip "workflows/" prefix and "ai-employee/" role prefix for cleaner local layout
158
+ let relPath = file.path;
159
+ if (relPath.startsWith('workflows/'))
160
+ relPath = relPath.substring('workflows/'.length);
161
+ relPath = relPath.replace(/^ai-employee\//, '');
162
+ const filePath = (0, path_1.join)(employeeWorkflowsDir, relPath);
75
163
  const fileDir = (0, path_1.dirname)(filePath);
76
164
  if (!(0, fs_1.existsSync)(fileDir)) {
77
165
  (0, fs_1.mkdirSync)(fileDir, { recursive: true });
78
166
  }
79
167
  (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
80
- console.log(chalk_1.default.gray(` + ${file.path}`));
168
+ console.log(chalk_1.default.gray(` + .fraim/ai-employee/workflows/${relPath}`));
169
+ }
170
+ // Write manager workflows
171
+ const managerWorkflowsDir = (0, path_1.join)(options.projectRoot, '.fraim', 'ai-manager', 'workflows');
172
+ if (!(0, fs_1.existsSync)(managerWorkflowsDir)) {
173
+ (0, fs_1.mkdirSync)(managerWorkflowsDir, { recursive: true });
174
+ }
175
+ cleanDirectory(managerWorkflowsDir);
176
+ for (const file of managerWorkflowFiles) {
177
+ // Strip "workflows/" prefix and "ai-manager/" role prefix
178
+ let relPath = file.path;
179
+ if (relPath.startsWith('workflows/'))
180
+ relPath = relPath.substring('workflows/'.length);
181
+ relPath = relPath.replace(/^ai-manager\//, '');
182
+ const filePath = (0, path_1.join)(managerWorkflowsDir, relPath);
183
+ const fileDir = (0, path_1.dirname)(filePath);
184
+ if (!(0, fs_1.existsSync)(fileDir)) {
185
+ (0, fs_1.mkdirSync)(fileDir, { recursive: true });
186
+ }
187
+ (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
188
+ console.log(chalk_1.default.gray(` + .fraim/ai-manager/workflows/${relPath}`));
81
189
  }
82
190
  // Sync job stubs to role-specific folders under .fraim
83
191
  const allJobFiles = files.filter(f => f.type === 'job');
@@ -89,13 +197,18 @@ async function syncFromRemote(options) {
89
197
  }
90
198
  cleanDirectory(employeeJobsDir);
91
199
  for (const file of jobFiles) {
92
- const filePath = (0, path_1.join)(employeeJobsDir, file.path);
200
+ // Strip "jobs/" prefix and "ai-employee/" role prefix
201
+ let relPath = file.path;
202
+ if (relPath.startsWith('jobs/'))
203
+ relPath = relPath.substring('jobs/'.length);
204
+ relPath = relPath.replace(/^ai-employee\//, '');
205
+ const filePath = (0, path_1.join)(employeeJobsDir, relPath);
93
206
  const fileDir = (0, path_1.dirname)(filePath);
94
207
  if (!(0, fs_1.existsSync)(fileDir)) {
95
208
  (0, fs_1.mkdirSync)(fileDir, { recursive: true });
96
209
  }
97
- (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
98
- console.log(chalk_1.default.gray(` + ai-employee/jobs/${file.path}`));
210
+ (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
211
+ console.log(chalk_1.default.gray(` + ai-employee/jobs/${relPath}`));
99
212
  }
100
213
  // Sync ai-manager job stubs to .fraim/ai-manager/jobs/
101
214
  const managerJobsDir = (0, path_1.join)(options.projectRoot, '.fraim', 'ai-manager', 'jobs');
@@ -104,16 +217,20 @@ async function syncFromRemote(options) {
104
217
  }
105
218
  cleanDirectory(managerJobsDir);
106
219
  for (const file of managerJobFiles) {
107
- const managerRelativePath = file.path.replace(/^ai-manager\//, '');
108
- const filePath = (0, path_1.join)(managerJobsDir, managerRelativePath);
220
+ // Strip "jobs/" prefix and "ai-manager/" role prefix
221
+ let relPath = file.path;
222
+ if (relPath.startsWith('jobs/'))
223
+ relPath = relPath.substring('jobs/'.length);
224
+ relPath = relPath.replace(/^ai-manager\//, '');
225
+ const filePath = (0, path_1.join)(managerJobsDir, relPath);
109
226
  const fileDir = (0, path_1.dirname)(filePath);
110
227
  if (!(0, fs_1.existsSync)(fileDir)) {
111
228
  (0, fs_1.mkdirSync)(fileDir, { recursive: true });
112
229
  }
113
- (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
114
- console.log(chalk_1.default.gray(` + ai-manager/jobs/${managerRelativePath}`));
230
+ (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
231
+ console.log(chalk_1.default.gray(` + .fraim/ai-manager/jobs/${relPath}`));
115
232
  }
116
- // Sync full skill files to .fraim/ai-employee/skills/
233
+ // Sync skill STUBS to .fraim/ai-employee/skills/
117
234
  const skillFiles = files.filter(f => f.type === 'skill');
118
235
  const skillsDir = (0, path_1.join)(options.projectRoot, '.fraim', 'ai-employee', 'skills');
119
236
  if (!(0, fs_1.existsSync)(skillsDir)) {
@@ -121,15 +238,19 @@ async function syncFromRemote(options) {
121
238
  }
122
239
  cleanDirectory(skillsDir);
123
240
  for (const file of skillFiles) {
124
- const filePath = (0, path_1.join)(skillsDir, file.path);
241
+ // Strip "skills/" prefix to avoid redundant nesting in .fraim/ai-employee/skills/
242
+ let relPath = file.path;
243
+ if (relPath.startsWith('skills/'))
244
+ relPath = relPath.substring('skills/'.length);
245
+ const filePath = (0, path_1.join)(skillsDir, relPath);
125
246
  const fileDir = (0, path_1.dirname)(filePath);
126
247
  if (!(0, fs_1.existsSync)(fileDir)) {
127
248
  (0, fs_1.mkdirSync)(fileDir, { recursive: true });
128
249
  }
129
- (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
130
- console.log(chalk_1.default.gray(` + ai-employee/skills/${file.path}`));
250
+ (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
251
+ console.log(chalk_1.default.gray(` + ai-employee/skills/${file.path} (stub)`));
131
252
  }
132
- // Sync full rule files to .fraim/ai-employee/rules/
253
+ // Sync rule STUBS to .fraim/ai-employee/rules/
133
254
  const ruleFiles = files.filter(f => f.type === 'rule');
134
255
  const rulesDir = (0, path_1.join)(options.projectRoot, '.fraim', 'ai-employee', 'rules');
135
256
  if (!(0, fs_1.existsSync)(rulesDir)) {
@@ -137,13 +258,17 @@ async function syncFromRemote(options) {
137
258
  }
138
259
  cleanDirectory(rulesDir);
139
260
  for (const file of ruleFiles) {
140
- const filePath = (0, path_1.join)(rulesDir, file.path);
261
+ // Strip "rules/" prefix to avoid redundant nesting in .fraim/ai-employee/rules/
262
+ let relPath = file.path;
263
+ if (relPath.startsWith('rules/'))
264
+ relPath = relPath.substring('rules/'.length);
265
+ const filePath = (0, path_1.join)(rulesDir, relPath);
141
266
  const fileDir = (0, path_1.dirname)(filePath);
142
267
  if (!(0, fs_1.existsSync)(fileDir)) {
143
268
  (0, fs_1.mkdirSync)(fileDir, { recursive: true });
144
269
  }
145
- (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
146
- console.log(chalk_1.default.gray(` + ai-employee/rules/${file.path}`));
270
+ (0, fs_1.writeFileSync)(filePath, applySyncedContentBanner(file), 'utf8');
271
+ console.log(chalk_1.default.gray(` + ai-employee/rules/${file.path} (stub)`));
147
272
  }
148
273
  // Sync scripts to user directory
149
274
  const scriptFiles = files.filter(f => f.type === 'script');
@@ -180,9 +305,15 @@ async function syncFromRemote(options) {
180
305
  (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
181
306
  console.log(chalk_1.default.gray(` + docs/${file.path}`));
182
307
  }
308
+ if (shouldLockSyncedContent()) {
309
+ for (const target of lockTargets) {
310
+ setFileWriteLockRecursively(target, true);
311
+ }
312
+ console.log(chalk_1.default.gray(` šŸ”’ Synced FRAIM content locked as read-only (set ${LOCK_SYNCED_CONTENT_ENV}=false to disable)`));
313
+ }
183
314
  return {
184
315
  success: true,
185
- workflowsSynced: workflowFiles.length,
316
+ workflowsSynced: allWorkflowFiles.length,
186
317
  employeeJobsSynced: jobFiles.length,
187
318
  managerJobsSynced: managerJobFiles.length,
188
319
  skillsSynced: skillFiles.length,