fraim-framework 2.0.183 → 2.0.184

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.
@@ -592,7 +592,7 @@ function parseFraimInvocation(message) {
592
592
  const trimmed = message.trim();
593
593
  if (!trimmed)
594
594
  return null;
595
- const match = trimmed.match(/^[/$]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
595
+ const match = trimmed.match(/^[$/@]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
596
596
  if (!match)
597
597
  return null;
598
598
  return {
@@ -6,14 +6,14 @@ exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
6
6
  exports.buildManagerMessage = buildManagerMessage;
7
7
  function extractExplicitFraimInvocation(text) {
8
8
  const raw = String(text || '');
9
- const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
9
+ const match = raw.match(/(?:^|\n|\s)([$/@]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
10
10
  if (!match || match.index == null)
11
11
  return null;
12
12
  const before = raw.slice(0, match.index).trim();
13
13
  const after = raw.slice(match.index + match[0].length).trim();
14
14
  const remainder = [before, after].filter(Boolean).join('\n\n').trim();
15
15
  return {
16
- symbol: match[1].toLowerCase() === '$fraim' ? '$fraim' : '/fraim',
16
+ symbol: match[1].toLowerCase(),
17
17
  jobId: match[2].toLowerCase(),
18
18
  remainder,
19
19
  };
@@ -68,8 +68,10 @@ function normalizeProjectEntry(raw, fallbackPath) {
68
68
  return null;
69
69
  const folderPath = normalizeProjectPath(rawPath);
70
70
  const basename = path_1.default.basename(folderPath) || folderPath;
71
- const team = Array.isArray(value.team) ? value.team.filter((entry) => typeof entry === 'string') : [];
72
71
  const rawId = typeof value.id === 'string' ? value.id.trim() : '';
72
+ // The employee roster is deliberately dropped here (not read, not persisted):
73
+ // it is derived from the bootstrap's hired personas at render time so it stays
74
+ // consistent across machines. Any `team` on incoming/legacy state is discarded.
73
75
  return {
74
76
  id: rawId || projectIdForPath(folderPath),
75
77
  name: typeof value.name === 'string' && value.name.length > 0 ? value.name : basename,
@@ -78,7 +80,6 @@ function normalizeProjectEntry(raw, fallbackPath) {
78
80
  outcome: typeof value.outcome === 'string' ? value.outcome : '',
79
81
  rules: typeof value.rules === 'string' ? value.rules : '',
80
82
  brief: typeof value.brief === 'string' ? value.brief : (typeof value.intent === 'string' ? value.intent : ''),
81
- team,
82
83
  updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
83
84
  };
84
85
  }
@@ -1009,6 +1009,22 @@ function ensureDirectoryPath(projectPath) {
1009
1009
  }
1010
1010
  return resolved;
1011
1011
  }
1012
+ function normalizedDirectoryPath(projectPath) {
1013
+ const resolved = path_1.default.resolve(projectPath);
1014
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
1015
+ }
1016
+ function sameDirectoryPath(left, right) {
1017
+ return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
1018
+ }
1019
+ function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
1020
+ return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
1021
+ }
1022
+ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath) {
1023
+ const deploymentProjectPath = typeof deployment.projectPath === 'string' && deployment.projectPath.trim()
1024
+ ? deployment.projectPath
1025
+ : fallbackProjectPath;
1026
+ return sameDirectoryPath(deploymentProjectPath, projectPath);
1027
+ }
1012
1028
  // ---------------------------------------------------------------------------
1013
1029
  // Issue #512 (S3) — Hub bootstrap projection helpers.
1014
1030
  // ---------------------------------------------------------------------------
@@ -1835,12 +1851,9 @@ class AiHubServer {
1835
1851
  if (!resolvedJobId) {
1836
1852
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1837
1853
  }
1838
- // #521: `display` is what the manager's conversation bubble shows only their own
1839
- // words. The FRAIM invocation (`$fraim <job>`), the stub path, and the shared-
1840
- // browser / communication-style notes are system context for the AGENT, kept out
1841
- // of the bubble so the thread isn't cluttered with machinery. `message` (with all
1842
- // of it) is what the agent actually receives.
1843
- const display = (explicit?.remainder || instructions || '').trim();
1854
+ // #696: `display` is the manager conversation bubble. Keep the full generated
1855
+ // turn in `message`, but show only the host-specific FRAIM invocation.
1856
+ const display = (0, manager_turns_1.fraimInvocationFor)(hostId, resolvedJobId) || (explicit?.remainder || instructions || '').trim();
1844
1857
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1845
1858
  // baked into the registry job/skill. It only appears when a shared browser is
1846
1859
  // available (env published at boot).
@@ -1869,13 +1882,12 @@ class AiHubServer {
1869
1882
  // as the target of the FRAIM invocation. The server picks the correct
1870
1883
  // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
1871
1884
  // passes raw invocation syntax.
1872
- const effectiveJobId = coachingJobId || run.jobId;
1873
- // #521: `display` = the manager's own words for the conversation bubble. When the
1874
- // manager typed nothing (a quick-coach button), fall back to the bare invocation
1875
- // so the bubble still shows what was triggered — but never the style note.
1876
1885
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1886
+ const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1887
+ // #696: the thread shows the invocation that the Hub sent, while the employee
1888
+ // still receives the full manager turn plus the style note.
1877
1889
  const userText = (explicit?.remainder || instructions || '').trim();
1878
- const display = userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1890
+ const display = (0, manager_turns_1.fraimInvocationFor)(run.hostId, effectiveJobId) || userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1879
1891
  return {
1880
1892
  message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1881
1893
  display,
@@ -2962,9 +2974,15 @@ class AiHubServer {
2962
2974
  this.scheduleDeployment(deployment);
2963
2975
  return res.status(201).json(deployment);
2964
2976
  });
2965
- // GET /api/ai-hub/schedules — list all scheduled deployments.
2966
- this.app.get('/api/ai-hub/schedules', (_req, res) => {
2967
- return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled'));
2977
+ // GET /api/ai-hub/schedules — list scheduled deployments for one project.
2978
+ this.app.get('/api/ai-hub/schedules', (req, res) => {
2979
+ try {
2980
+ const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
2981
+ return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled' && deploymentBelongsToProject(d, projectPath, this.projectPath)));
2982
+ }
2983
+ catch (err) {
2984
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
2985
+ }
2968
2986
  });
2969
2987
  // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
2970
2988
  this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
@@ -2982,7 +3000,7 @@ class AiHubServer {
2982
3000
  // PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
2983
3001
  this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
2984
3002
  const { id } = req.params;
2985
- const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
3003
+ const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
2986
3004
  if (cronExpr !== undefined) {
2987
3005
  // eslint-disable-next-line @typescript-eslint/no-var-requires
2988
3006
  const cronLib = require('node-cron');
@@ -2990,6 +3008,14 @@ class AiHubServer {
2990
3008
  return res.status(400).json({ error: 'Invalid cron expression.' });
2991
3009
  }
2992
3010
  }
3011
+ let resolvedProjectPath;
3012
+ try {
3013
+ if (projectPath !== undefined)
3014
+ resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
3015
+ }
3016
+ catch (err) {
3017
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3018
+ }
2993
3019
  const validEmployees = VALID_EMPLOYEE_IDS;
2994
3020
  let updated = null;
2995
3021
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -2997,6 +3023,8 @@ class AiHubServer {
2997
3023
  dep.label = label;
2998
3024
  if (jobId !== undefined)
2999
3025
  dep.jobId = jobId;
3026
+ if (resolvedProjectPath !== undefined)
3027
+ dep.projectPath = resolvedProjectPath;
3000
3028
  if (cronExpr !== undefined)
3001
3029
  dep.cronExpr = cronExpr;
3002
3030
  if (hostId !== undefined && validEmployees.includes(hostId))
@@ -3043,12 +3071,18 @@ class AiHubServer {
3043
3071
  this.deploymentStore.create(deployment);
3044
3072
  return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
3045
3073
  });
3046
- // GET /api/ai-hub/webhooks — list all webhook deployments.
3047
- this.app.get('/api/ai-hub/webhooks', (_req, res) => {
3074
+ // GET /api/ai-hub/webhooks — list webhook deployments for one project.
3075
+ this.app.get('/api/ai-hub/webhooks', (req, res) => {
3048
3076
  const hubBase = this.hubBase;
3049
- return res.json(this.deploymentStore.load()
3050
- .filter((d) => d.type === 'webhook')
3051
- .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
3077
+ try {
3078
+ const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
3079
+ return res.json(this.deploymentStore.load()
3080
+ .filter((d) => d.type === 'webhook' && deploymentBelongsToProject(d, projectPath, this.projectPath))
3081
+ .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
3082
+ }
3083
+ catch (err) {
3084
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3085
+ }
3052
3086
  });
3053
3087
  // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
3054
3088
  this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
@@ -3060,7 +3094,15 @@ class AiHubServer {
3060
3094
  // PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
3061
3095
  this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
3062
3096
  const { id } = req.params;
3063
- const { label, jobId, hostId, instructions, allowConcurrent } = req.body ?? {};
3097
+ const { label, jobId, projectPath, hostId, instructions, allowConcurrent } = req.body ?? {};
3098
+ let resolvedProjectPath;
3099
+ try {
3100
+ if (projectPath !== undefined)
3101
+ resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
3102
+ }
3103
+ catch (err) {
3104
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3105
+ }
3064
3106
  const validEmployees = VALID_EMPLOYEE_IDS;
3065
3107
  let updated = null;
3066
3108
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -3068,6 +3110,8 @@ class AiHubServer {
3068
3110
  dep.label = label;
3069
3111
  if (jobId !== undefined)
3070
3112
  dep.jobId = jobId;
3113
+ if (resolvedProjectPath !== undefined)
3114
+ dep.projectPath = resolvedProjectPath;
3071
3115
  if (hostId !== undefined && validEmployees.includes(hostId))
3072
3116
  dep.hostId = hostId;
3073
3117
  if (instructions !== undefined)
@@ -95,11 +95,6 @@ function asArray(value) {
95
95
  function stringValue(value) {
96
96
  return typeof value === 'string' && value.trim() ? value : null;
97
97
  }
98
- function readPackageVersion(repoRoot, result) {
99
- const relPath = 'package.json';
100
- const packageJson = readJsonObject(repoRoot, relPath, result);
101
- return packageJson ? stringValue(packageJson.version) : null;
102
- }
103
98
  function requireHttpsUrl(value, relPath, field, result) {
104
99
  const url = stringValue(value);
105
100
  if (!url) {
@@ -202,7 +197,7 @@ function validateTargetMatrix(repoRoot, result) {
202
197
  const relPath = path_1.default.join(TARGET_ROOT, 'marketplace-targets.json');
203
198
  const targetMatrix = readJsonObject(repoRoot, relPath, result);
204
199
  if (!targetMatrix) {
205
- return { remoteMcpUrl: null, oauthAuthorizationServer: null, productVersion: null, contactEmail: null };
200
+ return { remoteMcpUrl: null, oauthAuthorizationServer: null, contactEmail: null };
206
201
  }
207
202
  assertEqual(targetMatrix.schema, 'fraim-marketplace-targets-v1', relPath, 'schema', result);
208
203
  assertEqual(targetMatrix.issue, 674, relPath, 'issue', result);
@@ -214,10 +209,6 @@ function validateTargetMatrix(repoRoot, result) {
214
209
  if (!remoteMcp) {
215
210
  addIssue(result.errors, relPath, 'remoteMcp must be an object');
216
211
  }
217
- const productVersion = product ? stringValue(product.packageVersion) : null;
218
- if (!productVersion) {
219
- addIssue(result.errors, relPath, 'product.packageVersion must be set');
220
- }
221
212
  const contactEmail = product ? stringValue(product.contactEmail) : null;
222
213
  if (!contactEmail) {
223
214
  addIssue(result.errors, relPath, 'product.contactEmail must be set');
@@ -225,10 +216,6 @@ function validateTargetMatrix(repoRoot, result) {
225
216
  if (contactEmail && product) {
226
217
  assertEqual(product.support, contactEmail, relPath, 'product.support', result);
227
218
  }
228
- const packageVersion = readPackageVersion(repoRoot, result);
229
- if (productVersion && packageVersion) {
230
- assertEqual(productVersion, packageVersion, relPath, 'product.packageVersion', result);
231
- }
232
219
  const remoteMcpUrl = remoteMcp
233
220
  ? requireHttpsUrl(remoteMcp.url, relPath, 'remoteMcp.url', result)
234
221
  : null;
@@ -236,7 +223,7 @@ function validateTargetMatrix(repoRoot, result) {
236
223
  ? requireHttpsUrl(remoteMcp.oauthAuthorizationServer, relPath, 'remoteMcp.oauthAuthorizationServer', result)
237
224
  : null;
238
225
  validateTargetEntries(repoRoot, relPath, asArray(targetMatrix.targets), result);
239
- return { remoteMcpUrl, oauthAuthorizationServer, productVersion, contactEmail };
226
+ return { remoteMcpUrl, oauthAuthorizationServer, contactEmail };
240
227
  }
241
228
  function validateOpenAiAssets(repoRoot, openAiRoot, result) {
242
229
  assertFile(repoRoot, path_1.default.join(openAiRoot, 'README.md'), result);
@@ -345,11 +332,13 @@ function validateCodexManifestInterface(repoRoot, pluginRoot, manifestRelPath, m
345
332
  assertRelativeAsset(repoRoot, pluginRoot, screenshot, manifestRelPath, `interface.screenshots[${index}]`, result);
346
333
  }
347
334
  }
348
- function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, productVersion, contactEmail, result) {
335
+ function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, contactEmail, result) {
349
336
  if (!manifest)
350
337
  return;
351
338
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
352
- assertEqual(manifest.version, productVersion, manifestRelPath, 'version', result);
339
+ if (!stringValue(manifest.version)) {
340
+ addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
341
+ }
353
342
  assertEqual(asObject(manifest.author)?.email, contactEmail, manifestRelPath, 'author.email', result);
354
343
  assertEqual(manifest.skills, './skills/', manifestRelPath, 'skills', result);
355
344
  assertEqual(manifest.mcpServers, './.mcp.json', manifestRelPath, 'mcpServers', result);
@@ -409,11 +398,11 @@ function validateCodexPackage(repoRoot, result, targetDetails) {
409
398
  const manifest = readJsonObject(repoRoot, manifestRelPath, result);
410
399
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
411
400
  validateCodexMarketplace(marketplace, marketplaceRelPath, result);
412
- validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.productVersion, targetDetails.contactEmail, result);
401
+ validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.contactEmail, result);
413
402
  validateCodexMcpConfig(mcpConfig, mcpRelPath, targetDetails.remoteMcpUrl, result);
414
403
  validateCodexSkill(repoRoot, skillRelPath, result);
415
404
  }
416
- function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, productVersion, contactEmail, result) {
405
+ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, contactEmail, result) {
417
406
  if (!marketplace)
418
407
  return;
419
408
  assertEqual(marketplace.name, expectedName, marketplaceRelPath, 'name', result);
@@ -433,7 +422,9 @@ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expe
433
422
  return;
434
423
  }
435
424
  assertEqual(fraimEntry.source, expectedSource, marketplaceRelPath, 'fraim.source', result);
436
- assertEqual(fraimEntry.version, productVersion, marketplaceRelPath, 'fraim.version', result);
425
+ if (!stringValue(fraimEntry.version)) {
426
+ addIssue(result.errors, marketplaceRelPath, 'fraim.version must be a non-empty string');
427
+ }
437
428
  const author = asObject(fraimEntry.author);
438
429
  if (author) {
439
430
  assertEqual(author.email, contactEmail, marketplaceRelPath, 'fraim.author.email', result);
@@ -453,7 +444,9 @@ function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
453
444
  if (!description || description.length > 100) {
454
445
  addIssue(result.errors, serverRelPath, 'description must be a non-empty string of at most 100 characters');
455
446
  }
456
- assertEqual(serverJson.version, targetDetails.productVersion, serverRelPath, 'version', result);
447
+ if (!stringValue(serverJson.version)) {
448
+ addIssue(result.errors, serverRelPath, 'version must be a non-empty string');
449
+ }
457
450
  requireHttpsUrl(serverJson.websiteUrl, serverRelPath, 'websiteUrl', result);
458
451
  const repository = asObject(serverJson.repository);
459
452
  assertEqual(repository?.url, 'https://github.com/mathursrus/FRAIM', serverRelPath, 'repository.url', result);
@@ -484,11 +477,13 @@ function validateCursorPackage(repoRoot, result, targetDetails) {
484
477
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
485
478
  assertFile(repoRoot, path_1.default.join(cursorRoot, 'README.md'), result);
486
479
  assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
487
- validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
488
- validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
480
+ validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.contactEmail, result);
481
+ validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.contactEmail, result);
489
482
  if (manifest) {
490
483
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
491
- assertEqual(manifest.version, targetDetails.productVersion, manifestRelPath, 'version', result);
484
+ if (!stringValue(manifest.version)) {
485
+ addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
486
+ }
492
487
  assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
493
488
  assertEqual(manifest.rules, 'rules/', manifestRelPath, 'rules', result);
494
489
  assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
@@ -532,10 +527,12 @@ function validateCopilotPackage(repoRoot, result, targetDetails) {
532
527
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
533
528
  assertFile(repoRoot, path_1.default.join(vscodeRoot, 'README.md'), result);
534
529
  assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
535
- validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
530
+ validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.contactEmail, result);
536
531
  if (manifest) {
537
532
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
538
- assertEqual(manifest.version, targetDetails.productVersion, manifestRelPath, 'version', result);
533
+ if (!stringValue(manifest.version)) {
534
+ addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
535
+ }
539
536
  assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
540
537
  assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
541
538
  assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
@@ -559,7 +556,9 @@ function validateGeminiPackage(repoRoot, result, targetDetails) {
559
556
  if (!manifest)
560
557
  return;
561
558
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
562
- assertEqual(manifest.version, targetDetails.productVersion, manifestRelPath, 'version', result);
559
+ if (!stringValue(manifest.version)) {
560
+ addIssue(result.errors, manifestRelPath, 'version must be a non-empty string');
561
+ }
563
562
  assertEqual(manifest.contextFileName, 'GEMINI.md', manifestRelPath, 'contextFileName', result);
564
563
  const servers = asObject(manifest.mcpServers);
565
564
  const fraimServer = asObject(servers?.fraim);
@@ -74,6 +74,17 @@ function normalizeLabels(labels) {
74
74
  function escapeRegex(value) {
75
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
76
  }
77
+ /**
78
+ * Ordering for persona entitlements, applied in memory. Azure Cosmos DB (Mongo API)
79
+ * rejects an order-by on these fields without a composite index, so the sort must not
80
+ * be pushed into the query. Mirrors the former `.sort({ personaKey: 1, hireMode: 1 })`.
81
+ */
82
+ function comparePersonaEntitlements(a, b) {
83
+ const byKey = (a.personaKey || '').localeCompare(b.personaKey || '');
84
+ if (byKey !== 0)
85
+ return byKey;
86
+ return (a.hireMode || '').localeCompare(b.hireMode || '');
87
+ }
77
88
  function buildLabelMatch(label) {
78
89
  return { labels: { $regex: `^${escapeRegex(label)}$`, $options: 'i' } };
79
90
  }
@@ -728,12 +739,21 @@ class FraimDbService {
728
739
  if (activeOnly) {
729
740
  query.status = 'active';
730
741
  }
731
- return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
742
+ // Sort in memory, not in the query: Azure Cosmos DB (Mongo API) rejects an
743
+ // order-by on { personaKey, hireMode } with BadRequest 400 unless a matching
744
+ // composite index exists, and the initializeIndexes() attempt to create one
745
+ // is swallowed on Cosmos. A throw here bubbles up into getWorkspacePersonaState
746
+ // → the AI Hub silently falls back to ALL personas locked (no hired employees,
747
+ // empty manager team, empty project tree). Sorting client-side is index-free.
748
+ const rows = await this.personaEntitlementsCollection.find(query).toArray();
749
+ return rows.sort(comparePersonaEntitlements);
732
750
  }
733
751
  async listPersonaEntitlementsByStripeCustomerId(customerId) {
734
752
  if (!this.personaEntitlementsCollection)
735
753
  throw new Error('DB not connected');
736
- return await this.personaEntitlementsCollection.find({ stripeCustomerId: customerId }).sort({ updatedAt: -1 }).toArray();
754
+ // In-memory sort see getPersonaEntitlementsByWorkspaceId (Cosmos order-by).
755
+ const rows = await this.personaEntitlementsCollection.find({ stripeCustomerId: customerId }).toArray();
756
+ return rows.sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0));
737
757
  }
738
758
  async getPersonaEntitlementsByUserId(userId, activeOnly = true) {
739
759
  if (!this.personaEntitlementsCollection)
@@ -742,7 +762,9 @@ class FraimDbService {
742
762
  if (activeOnly) {
743
763
  query.status = 'active';
744
764
  }
745
- return await this.personaEntitlementsCollection.find(query).sort({ personaKey: 1, hireMode: 1 }).toArray();
765
+ // In-memory sort see getPersonaEntitlementsByWorkspaceId (Cosmos order-by).
766
+ const rows = await this.personaEntitlementsCollection.find(query).toArray();
767
+ return rows.sort(comparePersonaEntitlements);
746
768
  }
747
769
  async upsertPersonaEntitlement(entitlement) {
748
770
  if (!this.personaEntitlementsCollection)
@@ -4,6 +4,16 @@ exports.registerOAuthRoutes = registerOAuthRoutes;
4
4
  const cookie_service_1 = require("../services/cookie-service");
5
5
  const oauth_helpers_1 = require("../services/oauth-helpers");
6
6
  const audit_log_1 = require("../services/audit-log");
7
+ const account_provisioning_1 = require("../services/account-provisioning");
8
+ const rate_limit_1 = require("../middleware/rate-limit");
9
+ function pickIntent(value) {
10
+ return value === 'signup' ? 'signup' : 'signin';
11
+ }
12
+ // Issue #691: intent=signup makes /start reachable as an account-creation path
13
+ // (see completeSignIn below), the same sensitive business flow /api/request-access
14
+ // already rate-limits. Apply the same limiter here, scoped to signup attempts only —
15
+ // the pre-existing sign-in path (no intent) is left at its original, unlimited behavior.
16
+ const signupRateLimiter = (0, rate_limit_1.sensitiveRateLimiter)();
7
17
  const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
8
18
  const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
9
19
  const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
@@ -75,13 +85,17 @@ function registerOAuthRoutes(app, deps) {
75
85
  NODE_ENV: process.env.NODE_ENV || '(not set)',
76
86
  });
77
87
  // Helper used by both providers to finalise authentication once we have a verified email.
78
- async function completeSignIn(req, res, verifiedEmail, provider, surface, redirectTo) {
88
+ async function completeSignIn(req, res, params) {
89
+ const { verifiedEmail, provider, surface, redirectTo, intent } = params;
79
90
  const lower = verifiedEmail.toLowerCase();
80
91
  const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
81
- if (!apiKeyData) {
92
+ if (!apiKeyData && intent !== 'signup') {
82
93
  // Sign-in is NOT a sign-up. If the verified email doesn't already match a
83
94
  // FRAIM account, route the user to the sign-up flow. Account creation is
84
95
  // owned by /api/request-access, not by login. (Round-2 user feedback.)
96
+ // Issue #691: this only applies to the sign-in surface (intent !== 'signup').
97
+ // The dedicated signup surface uses intent='signup' to provision an account
98
+ // below instead of bouncing to no_account.
85
99
  await (0, audit_log_1.auditLog)('OAUTH_CALLBACK_SUCCESS', {
86
100
  userId: lower,
87
101
  provider,
@@ -92,6 +106,9 @@ function registerOAuthRoutes(app, deps) {
92
106
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
93
107
  return res.redirect(`/auth/error.html?reason=no_account`);
94
108
  }
109
+ if (!apiKeyData && intent === 'signup') {
110
+ await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
111
+ }
95
112
  const sessionId = (0, oauth_helpers_1.generateSessionId)();
96
113
  await dbService.createAuthSession({
97
114
  sessionId,
@@ -106,13 +123,18 @@ function registerOAuthRoutes(app, deps) {
106
123
  (0, cookie_service_1.clearOAuthPendingCookie)(res);
107
124
  res.redirect(redirectTo);
108
125
  }
109
- app.get('/api/auth/oauth/:provider/start', async (req, res) => {
126
+ app.get('/api/auth/oauth/:provider/start', (req, res, next) => {
127
+ if (pickIntent(req.query.intent) === 'signup')
128
+ return signupRateLimiter(req, res, next);
129
+ next();
130
+ }, async (req, res) => {
110
131
  const providerRaw = req.params.provider;
111
132
  const provider = typeof providerRaw === 'string' ? providerRaw : '';
112
133
  if (!(0, oauth_helpers_1.isSupportedProvider)(provider)) {
113
134
  return res.status(404).json({ error: 'unsupported_provider' });
114
135
  }
115
136
  const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
137
+ const intent = pickIntent(req.query.intent);
116
138
  const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
117
139
  try {
118
140
  const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
@@ -122,6 +144,7 @@ function registerOAuthRoutes(app, deps) {
122
144
  pendingId,
123
145
  provider,
124
146
  surface,
147
+ intent,
125
148
  codeVerifier,
126
149
  stateNonce,
127
150
  redirectTo,
@@ -228,7 +251,13 @@ function registerOAuthRoutes(app, deps) {
228
251
  await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'google', outcome: 'failure' });
229
252
  return res.redirect('/auth/error.html?reason=unverified_email');
230
253
  }
231
- await completeSignIn(req, res, payload.email, 'google', pending.surface, pending.redirectTo);
254
+ await completeSignIn(req, res, {
255
+ verifiedEmail: payload.email,
256
+ provider: 'google',
257
+ surface: pending.surface,
258
+ redirectTo: pending.redirectTo,
259
+ intent: pickIntent(pending.intent),
260
+ });
232
261
  }
233
262
  catch (err) {
234
263
  console.error('[FRAIM AUTH] /api/auth/oauth/google/callback error:', err instanceof Error ? err.message : String(err));
@@ -315,7 +344,13 @@ function registerOAuthRoutes(app, deps) {
315
344
  await (0, audit_log_1.auditLog)('OAUTH_UNVERIFIED_EMAIL', { provider: 'github', outcome: 'failure' });
316
345
  return res.redirect('/auth/error.html?reason=unverified_email');
317
346
  }
318
- await completeSignIn(req, res, verifiedEmail, 'github', pending.surface, pending.redirectTo);
347
+ await completeSignIn(req, res, {
348
+ verifiedEmail,
349
+ provider: 'github',
350
+ surface: pending.surface,
351
+ redirectTo: pending.redirectTo,
352
+ intent: pickIntent(pending.intent),
353
+ });
319
354
  }
320
355
  catch (err) {
321
356
  console.error('[FRAIM AUTH] /api/auth/oauth/github/callback error:', err instanceof Error ? err.message : String(err));
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureTrialApiKey = ensureTrialApiKey;
4
+ const feature_flags_1 = require("../config/feature-flags");
5
+ const TRIAL_DURATION_MS = 14 * 24 * 60 * 60 * 1000;
6
+ /**
7
+ * Provision a 14-day trial FraimApiKey for `emailLower` if one does not already
8
+ * exist; otherwise return the existing key. Shared by the email+code signup
9
+ * flow (`AdminService.verifyRequestAccessCode`) and the OAuth signup-intent
10
+ * flow (`oauth-routes.ts`) so account creation has exactly one implementation.
11
+ * Issue #691.
12
+ */
13
+ async function ensureTrialApiKey(dbService, emailLower) {
14
+ const existingKey = await dbService.getApiKeyByUserId(emailLower, false);
15
+ if (existingKey)
16
+ return existingKey.key;
17
+ const orgId = 'default';
18
+ const apiKey = dbService.generateApiKey(emailLower, orgId);
19
+ const trialExpiresAt = new Date(Date.now() + TRIAL_DURATION_MS);
20
+ await dbService.createApiKey({
21
+ key: apiKey,
22
+ userId: emailLower,
23
+ orgId,
24
+ tier: 'trial',
25
+ status: 'active',
26
+ expiresAt: trialExpiresAt,
27
+ stripeCustomerId: null,
28
+ stripeSubscriptionId: null,
29
+ currentPeriodEnd: null,
30
+ cancelAt: null,
31
+ suspendedAt: null,
32
+ suspensionReason: null,
33
+ lastUsedAt: null,
34
+ apiCallCount: 0,
35
+ personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
36
+ });
37
+ return apiKey;
38
+ }
@@ -7,6 +7,7 @@ const email_service_1 = require("./email-service");
7
7
  const dashboard_access_1 = require("./dashboard-access");
8
8
  const feature_flags_1 = require("../config/feature-flags");
9
9
  const email_code_1 = require("./email-code");
10
+ const account_provisioning_1 = require("./account-provisioning");
10
11
  class AdminService {
11
12
  constructor(dbService) {
12
13
  this.dbService = dbService;
@@ -130,8 +131,11 @@ class AdminService {
130
131
  */
131
132
  async requestAccess(body, req) {
132
133
  const { email, name, company, useCase } = body;
133
- if (!email || !name || !company) {
134
- throw new Error('Email, name, and company are required');
134
+ // Issue #691: signup is now email + verification code only. Name/company
135
+ // are optional lead-capture fields (createWebsiteSignup below already
136
+ // tolerates missing values); the trial key itself never required them.
137
+ if (!email) {
138
+ throw new Error('Email is required');
135
139
  }
136
140
  if (!(0, request_utils_1.validateEmail)(email))
137
141
  throw new Error('Invalid email address');
@@ -140,8 +144,8 @@ class AdminService {
140
144
  const { ip: ipAddress, userAgent } = (0, request_utils_1.getRequestMeta)(req);
141
145
  await this.dbService.createWebsiteSignup({
142
146
  email: emailLower,
143
- name: name.trim(),
144
- company: company.trim(),
147
+ name: name?.trim() || '',
148
+ company: company?.trim() || '',
145
149
  useCase: useCase?.trim(),
146
150
  source: 'request-access',
147
151
  timestamp: new Date(),
@@ -184,38 +188,8 @@ class AdminService {
184
188
  if (!verification) {
185
189
  return { success: false, error: 'Invalid or expired verification code' };
186
190
  }
187
- const orgId = 'default';
188
- const existingKey = await this.dbService.getApiKeyByUserId(emailLower, false);
189
- let apiKey;
190
- if (existingKey) {
191
- apiKey = existingKey.key;
192
- console.log('[FRAIM] verify_email_code existing_key_found', { userId: emailLower });
193
- }
194
- else {
195
- apiKey = this.dbService.generateApiKey(emailLower, orgId);
196
- const trialExpiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
197
- await this.dbService.createApiKey({
198
- key: apiKey,
199
- userId: emailLower,
200
- orgId,
201
- tier: 'trial',
202
- status: 'active',
203
- expiresAt: trialExpiresAt,
204
- stripeCustomerId: null,
205
- stripeSubscriptionId: null,
206
- currentPeriodEnd: null,
207
- cancelAt: null,
208
- suspendedAt: null,
209
- suspensionReason: null,
210
- lastUsedAt: null,
211
- apiCallCount: 0,
212
- personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
213
- });
214
- console.log('[FRAIM] verify_email_code trial_key_created', {
215
- userId: emailLower,
216
- expiresAt: trialExpiresAt.toISOString()
217
- });
218
- }
191
+ const apiKey = await (0, account_provisioning_1.ensureTrialApiKey)(this.dbService, emailLower);
192
+ console.log('[FRAIM] verify_email_code key_resolved', { userId: emailLower });
219
193
  const access = await (0, dashboard_access_1.createDashboardAccessLink)(this.dbService, emailLower, apiKey);
220
194
  console.log('[FRAIM] verify_email_code verification_completed', { userId: emailLower });
221
195
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.183",
3
+ "version": "2.0.184",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -668,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
668
668
  .trim();
669
669
  }
670
670
 
671
- function stripHubInjectedPromptBlocks(text) {
671
+ function stripHubInjectedNotes(text) {
672
672
  return String(text || '')
673
673
  .replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
674
- .replace(/(?:^|\n)\s*(?:[$/]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
674
+ .replace(/\n{3,}/g, '\n\n')
675
+ .trim();
676
+ }
677
+
678
+ function stripHubInjectedPromptBlocks(text) {
679
+ return stripHubInjectedNotes(text)
680
+ .replace(/(?:^|\n)\s*(?:[$/@]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
675
681
  .replace(/\n{3,}/g, '\n\n')
676
682
  .trim();
677
683
  }
@@ -2628,8 +2634,8 @@ function closeTemplatePopover() {
2628
2634
  }
2629
2635
 
2630
2636
  function applyTemplateInvocation(managerJobId) {
2631
- // The invocation prefix ($fraim / /fraim) is an implementation detail that
2632
- // the server adds based on the active employee the user should never see it.
2637
+ // The server adds the invocation prefix based on the active employee. Keep
2638
+ // that syntax out of the editable textarea; the thread shows it after send.
2633
2639
  // Store the job ID as a pending coaching job. The #coach-note shows a
2634
2640
  // human-readable label; the textarea gets editable context the user can amend.
2635
2641
  // When the user clicks Send, continueRun sends { coachingJobId, instructions }.
@@ -2638,7 +2644,7 @@ function applyTemplateInvocation(managerJobId) {
2638
2644
  // for sessions that stored raw invocation text before this change).
2639
2645
  const textarea = els['coach-text'];
2640
2646
  textarea.value = textarea.value
2641
- .replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2647
+ .replace(/(?:^|\n|\s)[$/@]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2642
2648
  .replace(/\s+/g, ' ')
2643
2649
  .trim();
2644
2650
  textarea.focus();
@@ -2677,17 +2683,20 @@ function stripStubReference(text) {
2677
2683
  }
2678
2684
 
2679
2685
  function surfaceText(role, text, conv) {
2680
- const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
2686
+ const withoutStub = stripStubReference(text);
2687
+ const raw = role === 'manager'
2688
+ ? stripHubInjectedNotes(withoutStub)
2689
+ : stripHubInjectedPromptBlocks(withoutStub);
2681
2690
  if (!raw) return '';
2682
2691
 
2683
2692
  if (role === 'manager') {
2684
- const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
2693
+ const invocationOnly = raw.match(/^(?:[$/@]fraim)\s+([a-z0-9-]+)\s*$/i);
2685
2694
  if (invocationOnly) return raw;
2686
2695
  const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
2687
- const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
2696
+ const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
2688
2697
  return raw
2689
2698
  .replace(invocationWithSlug, '')
2690
- .replace(/^(?:[$/]fraim)\s*/i, '')
2699
+ .replace(/^(?:[$/@]fraim)\s*/i, '')
2691
2700
  .trim();
2692
2701
  }
2693
2702
 
@@ -4640,9 +4649,9 @@ function deriveTitle(jobTitle, instructions) {
4640
4649
  const trimmedJob = (jobTitle || '').trim();
4641
4650
  const raw = (instructions || '').trim();
4642
4651
  // Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
4643
- const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4652
+ const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4644
4653
  const stripped = raw
4645
- .replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4654
+ .replace(/^[$/@]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4646
4655
  .replace(/\s+/g, ' ')
4647
4656
  .trim();
4648
4657
  const words = stripped.split(' ').filter(Boolean).slice(0, 6);
@@ -6004,7 +6013,7 @@ const tf = {
6004
6013
  area: 'projects', // company | manager | projects | brain
6005
6014
  projectView: 'overview', // overview | workspace
6006
6015
  activeProjectId: null,
6007
- projects: [], // [{ id, name, intent, brief, team:[personaKey] }]
6016
+ projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
6008
6017
  assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
6009
6018
  npStep: 1,
6010
6019
  npSelectedEmployees: [],
@@ -6026,6 +6035,12 @@ function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) ||
6026
6035
  function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
6027
6036
  function tfAvailablePersonas() { return tfPersonas().filter((p) => p.status !== 'hired'); }
6028
6037
  function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) || null; }
6038
+ // A project's employee roster is derived from the hired personas in the bootstrap
6039
+ // (DB-authoritative), NOT from per-machine local state. With the legacy-workspace
6040
+ // bypass every user sees the full employee catalog, and the roster is identical on
6041
+ // every machine the user signs in from. Deliberately not persisted to disk /
6042
+ // localStorage — see AiHubProjectListEntry in src/ai-hub/types.ts.
6043
+ function tfProjectTeamKeys() { return tfHiredPersonas().map((p) => p.key); }
6029
6044
 
6030
6045
  // ---------------------------------------------------------------------------
6031
6046
  // Persistence (projects + assignments, mirrors the conversation model)
@@ -6056,7 +6071,8 @@ function tfNormalizeProject(raw, fallbackPath) {
6056
6071
  outcome: raw.outcome || '',
6057
6072
  rules: raw.rules || '',
6058
6073
  brief: raw.brief || raw.intent || '',
6059
- team: Array.isArray(raw.team) ? raw.team.filter((key) => typeof key === 'string') : [],
6074
+ // Roster is derived from hired personas at render time (tfProjectTeamKeys),
6075
+ // never stored per machine. Any legacy `team` on stored state is dropped.
6060
6076
  updatedAt: raw.updatedAt || undefined,
6061
6077
  };
6062
6078
  }
@@ -6086,9 +6102,6 @@ function tfMergeProjects(projects) {
6086
6102
  const existing = merged.get(key);
6087
6103
  if (existing) {
6088
6104
  const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
6089
- if ((!Array.isArray(normalized.team) || normalized.team.length === 0) && Array.isArray(existing.team) && existing.team.length > 0) {
6090
- next.team = existing.team;
6091
- }
6092
6105
  merged.set(key, next);
6093
6106
  } else {
6094
6107
  merged.set(key, normalized);
@@ -6110,7 +6123,6 @@ function tfEnsureCurrentProject() {
6110
6123
  id: tfProjectIdForPath(state.projectPath),
6111
6124
  name: friendlyProjectShortName(state.projectPath),
6112
6125
  folderPath: state.projectPath,
6113
- team: tfHiredPersonas().map((p) => p.key),
6114
6126
  }, state.projectPath);
6115
6127
  tfMergeProjects([current]);
6116
6128
  const resolved = tfProjectForPath(state.projectPath);
@@ -6411,7 +6423,7 @@ function tfRenderOverview() {
6411
6423
  const team = document.createElement('div');
6412
6424
  team.className = 'proj-team';
6413
6425
  const assigned = tfProjectAssignments(proj.id);
6414
- const empKeys = Array.from(new Set([...(proj.team || []), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6426
+ const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6415
6427
  empKeys.slice(0, 6).forEach((key, i) => {
6416
6428
  const persona = tfPersonaByKey(key);
6417
6429
  const av = tfAvatarFor(persona ? persona.displayName : key, i);
@@ -6713,17 +6725,16 @@ function tfRenderTree() {
6713
6725
  const projectId = tf.activeProjectId;
6714
6726
  const assigned = tfProjectAssignments(projectId);
6715
6727
  const project = tf.projects.find((p) => p.id === projectId);
6716
- const onProject = (project && project.team) || [];
6728
+ const onProject = tfProjectTeamKeys();
6717
6729
  // Issue #540 R8: include personas on the manager's team so delegate buttons
6718
6730
  // appear in the workspace tree even when the persona hasn't been assigned yet.
6719
6731
  const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
6720
6732
  const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
6721
- // #538 follow-up: reconcile against real entitlements. The localStorage
6722
- // project.team can hold members who are no longer hired (or were seeded
6723
- // before an entitlement change), which made un-hired personas like swen
6724
- // appear in a workspace where only beza/pam are actually hired. Drop any
6725
- // key that resolves to a known persona that is NOT hired; keep hired
6726
- // personas and genuinely custom employees (no persona record).
6733
+ // #538 follow-up: reconcile against real entitlements. `assigned` job records
6734
+ // and manager-team keys can reference personas that are no longer hired; drop
6735
+ // any key that resolves to a known persona that is NOT hired. Keep hired
6736
+ // personas and genuinely custom employees (no persona record). onProject is
6737
+ // already hired-only (tfProjectTeamKeys), so this is a no-op for it.
6727
6738
  .filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
6728
6739
  const managerTeamKeySet = new Set(managerTeamPersonaKeys);
6729
6740
 
@@ -6991,9 +7002,12 @@ async function loadDeployments() {
6991
7002
  const list = document.getElementById('proj-deployments-list');
6992
7003
  if (!list) return;
6993
7004
  try {
7005
+ const params = new URLSearchParams();
7006
+ if (state.projectPath) params.set('projectPath', state.projectPath);
7007
+ const suffix = params.toString() ? '?' + params.toString() : '';
6994
7008
  const [schResp, whResp] = await Promise.all([
6995
- fetch('/api/ai-hub/schedules'),
6996
- fetch('/api/ai-hub/webhooks'),
7009
+ fetch('/api/ai-hub/schedules' + suffix),
7010
+ fetch('/api/ai-hub/webhooks' + suffix),
6997
7011
  ]);
6998
7012
  const schedules = schResp.ok ? await schResp.json() : [];
6999
7013
  const webhooks = whResp.ok ? await whResp.json() : [];
@@ -7333,7 +7347,7 @@ async function saveScheduleDeployment() {
7333
7347
  const resp = await fetch(url, {
7334
7348
  method,
7335
7349
  headers: { 'Content-Type': 'application/json' },
7336
- body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
7350
+ body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }),
7337
7351
  });
7338
7352
  if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
7339
7353
  _editingDepId = null;
@@ -7356,7 +7370,7 @@ async function saveWebhookDeployment() {
7356
7370
  const resp = await fetch(url, {
7357
7371
  method,
7358
7372
  headers: { 'Content-Type': 'application/json' },
7359
- body: JSON.stringify({ label, jobId, hostId, instructions: instructions || undefined }),
7373
+ body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }),
7360
7374
  });
7361
7375
  if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
7362
7376
  if (!isEdit) {
@@ -9200,7 +9214,7 @@ async function tfCreateProject() {
9200
9214
  intent && `Project intent: ${intent}`,
9201
9215
  val('np-outcome') && `Success looks like: ${val('np-outcome')}`,
9202
9216
  val('np-rules') && `Rules & guardrails: ${val('np-rules')}`,
9203
- `Team: ${(project.team || []).join(', ') || 'no employees assigned yet'}`,
9217
+ `Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
9204
9218
  ].filter(Boolean).join('\n\n');
9205
9219
 
9206
9220
  if (onboardingContext) {
@@ -9386,10 +9400,9 @@ function tfAssignJobToEmployee(employeeKey, job) {
9386
9400
  if (!projectId) { tfCloseAssignJob(); return; }
9387
9401
  if (!tf.assignments[projectId]) tf.assignments[projectId] = [];
9388
9402
  tf.assignments[projectId].push({ id: 'a-' + Date.now(), employeeKey, jobName: job.title || job.id, jobId: job.id });
9389
- const proj = tf.projects.find((p) => p.id === projectId);
9390
- if (proj) { proj.team = proj.team || []; if (!proj.team.includes(employeeKey)) proj.team.push(employeeKey); }
9403
+ // No longer mutate a stored project.team: the roster is derived from hired
9404
+ // personas (tfProjectTeamKeys) and every employee is always available.
9391
9405
  tfPersistAssignments();
9392
- tfPersistProjects();
9393
9406
  tfCloseAssignJob();
9394
9407
  tfRenderTree();
9395
9408
  tfRenderProjectTabs();
@@ -9408,7 +9421,7 @@ function tfOpenAddEmp(preselectKey) {
9408
9421
  const sub = document.getElementById('ae-sub');
9409
9422
  if (!modal || !body) return;
9410
9423
  body.innerHTML = '';
9411
- const onProject = (tf.projects.find((p) => p.id === tf.activeProjectId) || {}).team || [];
9424
+ const onProject = tfProjectTeamKeys();
9412
9425
  const onProjectPersonas = onProject.map(tfPersonaByKey).filter(Boolean);
9413
9426
  if (onProjectPersonas.length) {
9414
9427
  const label = document.createElement('div');