fraim-framework 2.0.182 → 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.
@@ -68,6 +68,10 @@ class AiHubConversationStore {
68
68
  constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations.json')) {
69
69
  this.stateFilePath = stateFilePath;
70
70
  }
71
+ listProjectPaths() {
72
+ const state = this.readStore();
73
+ return Object.keys(state.projects || {}).map((projectPath) => normalizeProjectPath(projectPath));
74
+ }
71
75
  loadProject(projectPath) {
72
76
  const state = this.readStore();
73
77
  const normalizedProjectPath = normalizeProjectPath(projectPath);
@@ -52,6 +52,13 @@ function parseArgs(argv) {
52
52
  }
53
53
  return { projectPath, preferredPort };
54
54
  }
55
+ function applyUserDataOverride() {
56
+ const userDataDir = process.env.FRAIM_AI_HUB_USER_DATA_DIR;
57
+ if (!userDataDir)
58
+ return;
59
+ fs_1.default.mkdirSync(userDataDir, { recursive: true });
60
+ electron_1.app.setPath('userData', userDataDir);
61
+ }
55
62
  // ---------------------------------------------------------------------------
56
63
  // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
57
64
  // so the app never crashes if assets aren't present.
@@ -271,6 +278,7 @@ async function launchDesktopShell(options) {
271
278
  // ---------------------------------------------------------------------------
272
279
  async function bootstrap() {
273
280
  const options = parseArgs(process.argv.slice(2));
281
+ applyUserDataOverride();
274
282
  // Single-instance lock — if another instance is already running, focus it
275
283
  // and exit rather than spawning a second server + window.
276
284
  // Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
@@ -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
  };
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AiHubPreferencesStore = void 0;
7
+ exports.normalizeAiHubProjectList = normalizeAiHubProjectList;
7
8
  const fs_1 = __importDefault(require("fs"));
9
+ const crypto_1 = require("crypto");
8
10
  const path_1 = __importDefault(require("path"));
9
11
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
10
12
  const DEFAULT_CATEGORY = 'marketing';
@@ -16,7 +18,89 @@ const defaultPreferences = (projectPath) => ({
16
18
  recentJobIds: [],
17
19
  recentJobInstructions: {},
18
20
  personaKey: null,
21
+ projects: normalizeAiHubProjectList([], projectPath),
19
22
  });
23
+ function normalizeProjectPath(projectPath) {
24
+ return path_1.default.resolve(projectPath || process.cwd());
25
+ }
26
+ function canonicalProjectPath(projectPath) {
27
+ const normalized = normalizeProjectPath(projectPath);
28
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
29
+ }
30
+ function projectPathExists(projectPath) {
31
+ try {
32
+ return fs_1.default.statSync(projectPath).isDirectory();
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function projectIdForPath(projectPath) {
39
+ return `p-${(0, crypto_1.createHash)('sha1').update(canonicalProjectPath(projectPath)).digest('base64url').slice(0, 16)}`;
40
+ }
41
+ function uniqueProjectId(entry, seenIds) {
42
+ const fallbackId = projectIdForPath(entry.folderPath);
43
+ const rawId = typeof entry.id === 'string' ? entry.id.trim() : '';
44
+ const preferredId = rawId && rawId !== 'p-current' ? rawId : fallbackId;
45
+ let id = preferredId;
46
+ let suffix = 2;
47
+ while (seenIds.has(id)) {
48
+ id = `${fallbackId}-${suffix}`;
49
+ suffix += 1;
50
+ }
51
+ seenIds.add(id);
52
+ return id;
53
+ }
54
+ function withUniqueProjectIds(projects) {
55
+ const seenIds = new Set();
56
+ return projects.map((entry) => ({ ...entry, id: uniqueProjectId(entry, seenIds) }));
57
+ }
58
+ function normalizeProjectEntry(raw, fallbackPath) {
59
+ if (!raw || typeof raw !== 'object')
60
+ return null;
61
+ const value = raw;
62
+ const rawPath = typeof value.folderPath === 'string'
63
+ ? value.folderPath
64
+ : (typeof value.path === 'string'
65
+ ? value.path
66
+ : (typeof value.folder === 'string' ? value.folder : fallbackPath));
67
+ if (!rawPath || typeof rawPath !== 'string')
68
+ return null;
69
+ const folderPath = normalizeProjectPath(rawPath);
70
+ const basename = path_1.default.basename(folderPath) || folderPath;
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.
75
+ return {
76
+ id: rawId || projectIdForPath(folderPath),
77
+ name: typeof value.name === 'string' && value.name.length > 0 ? value.name : basename,
78
+ folderPath,
79
+ intent: typeof value.intent === 'string' ? value.intent : '',
80
+ outcome: typeof value.outcome === 'string' ? value.outcome : '',
81
+ rules: typeof value.rules === 'string' ? value.rules : '',
82
+ brief: typeof value.brief === 'string' ? value.brief : (typeof value.intent === 'string' ? value.intent : ''),
83
+ updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
84
+ };
85
+ }
86
+ function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
87
+ const byPath = new Map();
88
+ const currentKey = currentProjectPath ? normalizeProjectPath(currentProjectPath) : null;
89
+ const add = (entry) => {
90
+ if (!entry)
91
+ return;
92
+ const key = normalizeProjectPath(entry.folderPath);
93
+ if (!options.includeMissing && key !== currentKey && !projectPathExists(key))
94
+ return;
95
+ const existing = byPath.get(key);
96
+ byPath.set(key, existing ? { ...existing, ...entry, folderPath: key } : { ...entry, folderPath: key });
97
+ };
98
+ if (currentProjectPath)
99
+ add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath));
100
+ for (const project of projects)
101
+ add(normalizeProjectEntry(project));
102
+ return withUniqueProjectIds(Array.from(byPath.values()));
103
+ }
20
104
  class AiHubPreferencesStore {
21
105
  constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
22
106
  this.stateFilePath = stateFilePath;
@@ -37,6 +121,7 @@ class AiHubPreferencesStore {
37
121
  : {},
38
122
  personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
39
123
  apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
124
+ projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath),
40
125
  };
41
126
  }
42
127
  catch {
@@ -47,6 +132,20 @@ class AiHubPreferencesStore {
47
132
  fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
48
133
  fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
49
134
  }
135
+ saveProjects(projectPath, projects) {
136
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
137
+ const preferences = this.load(normalizedProjectPath);
138
+ const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath);
139
+ this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
140
+ return nextProjects;
141
+ }
142
+ mergeProjects(projectPath, projects) {
143
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
144
+ const preferences = this.load(normalizedProjectPath);
145
+ const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath);
146
+ this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
147
+ return nextProjects;
148
+ }
50
149
  remember(preferences, jobId, instructions) {
51
150
  const recentJobIds = jobId
52
151
  ? [jobId, ...preferences.recentJobIds.filter((value) => value !== jobId)].slice(0, 8)
@@ -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
  // ---------------------------------------------------------------------------
@@ -1373,6 +1389,19 @@ class AiHubServer {
1373
1389
  }
1374
1390
  }
1375
1391
  getHttpsPort() { return this.httpsPort; }
1392
+ knownProjects(projectPath, extras = []) {
1393
+ const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1394
+ const preferences = this.preferencesStore.load(normalizedProjectPath);
1395
+ const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
1396
+ return (0, preferences_1.normalizeAiHubProjectList)([
1397
+ ...(preferences.projects || []),
1398
+ ...conversationProjects,
1399
+ ...extras,
1400
+ ], normalizedProjectPath);
1401
+ }
1402
+ saveKnownProjects(projectPath, projects) {
1403
+ return this.preferencesStore.saveProjects(path_1.default.resolve(projectPath || this.projectPath), this.knownProjects(projectPath, projects));
1404
+ }
1376
1405
  async bootstrapResponse(projectPath, apiKey) {
1377
1406
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1378
1407
  const employees = this.hostRuntime.detectEmployees();
@@ -1402,6 +1431,9 @@ class AiHubServer {
1402
1431
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1403
1432
  const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
1404
1433
  const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
1434
+ const projects = this.knownProjects(normalizedProjectPath);
1435
+ preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1436
+ this.preferencesStore.save(preferences);
1405
1437
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
1406
1438
  // so the bootstrap surface (used on first paint) carries stages and
1407
1439
  // live totals — not just the raw run state.
@@ -1418,6 +1450,7 @@ class AiHubServer {
1418
1450
  personas,
1419
1451
  subscriptionActive,
1420
1452
  activeRun,
1453
+ projects,
1421
1454
  // Issue #512 (S3) — additive manager-flow projections.
1422
1455
  firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
1423
1456
  teamContext: this.computeTeamContext(normalizedProjectPath),
@@ -1818,12 +1851,9 @@ class AiHubServer {
1818
1851
  if (!resolvedJobId) {
1819
1852
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1820
1853
  }
1821
- // #521: `display` is what the manager's conversation bubble shows only their own
1822
- // words. The FRAIM invocation (`$fraim <job>`), the stub path, and the shared-
1823
- // browser / communication-style notes are system context for the AGENT, kept out
1824
- // of the bubble so the thread isn't cluttered with machinery. `message` (with all
1825
- // of it) is what the agent actually receives.
1826
- 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();
1827
1857
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1828
1858
  // baked into the registry job/skill. It only appears when a shared browser is
1829
1859
  // available (env published at boot).
@@ -1852,13 +1882,12 @@ class AiHubServer {
1852
1882
  // as the target of the FRAIM invocation. The server picks the correct
1853
1883
  // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
1854
1884
  // passes raw invocation syntax.
1855
- const effectiveJobId = coachingJobId || run.jobId;
1856
- // #521: `display` = the manager's own words for the conversation bubble. When the
1857
- // manager typed nothing (a quick-coach button), fall back to the bare invocation
1858
- // so the bubble still shows what was triggered — but never the style note.
1859
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.
1860
1889
  const userText = (explicit?.remainder || instructions || '').trim();
1861
- 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', '');
1862
1891
  return {
1863
1892
  message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1864
1893
  display,
@@ -2183,6 +2212,26 @@ class AiHubServer {
2183
2212
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
2184
2213
  }
2185
2214
  });
2215
+ this.app.get('/api/ai-hub/projects', (req, res) => {
2216
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2217
+ ? path_1.default.resolve(req.query.projectPath)
2218
+ : this.projectPath;
2219
+ return res.json({ projectPath, projects: this.knownProjects(projectPath), source: 'disk' });
2220
+ });
2221
+ this.app.put('/api/ai-hub/projects', (req, res) => {
2222
+ try {
2223
+ const body = (req.body ?? {});
2224
+ const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2225
+ if (!Array.isArray(body.projects)) {
2226
+ return res.status(400).json({ error: 'projects array required' });
2227
+ }
2228
+ const projects = this.saveKnownProjects(projectPath, body.projects);
2229
+ return res.json({ projectPath, projects, source: 'disk' });
2230
+ }
2231
+ catch (error) {
2232
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
2233
+ }
2234
+ });
2186
2235
  this.app.post('/api/ai-hub/api-key', (req, res) => {
2187
2236
  const { apiKey } = req.body;
2188
2237
  if (!apiKey || typeof apiKey !== 'string')
@@ -2925,9 +2974,15 @@ class AiHubServer {
2925
2974
  this.scheduleDeployment(deployment);
2926
2975
  return res.status(201).json(deployment);
2927
2976
  });
2928
- // GET /api/ai-hub/schedules — list all scheduled deployments.
2929
- this.app.get('/api/ai-hub/schedules', (_req, res) => {
2930
- 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
+ }
2931
2986
  });
2932
2987
  // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
2933
2988
  this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
@@ -2945,7 +3000,7 @@ class AiHubServer {
2945
3000
  // PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
2946
3001
  this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
2947
3002
  const { id } = req.params;
2948
- const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
3003
+ const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
2949
3004
  if (cronExpr !== undefined) {
2950
3005
  // eslint-disable-next-line @typescript-eslint/no-var-requires
2951
3006
  const cronLib = require('node-cron');
@@ -2953,6 +3008,14 @@ class AiHubServer {
2953
3008
  return res.status(400).json({ error: 'Invalid cron expression.' });
2954
3009
  }
2955
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
+ }
2956
3019
  const validEmployees = VALID_EMPLOYEE_IDS;
2957
3020
  let updated = null;
2958
3021
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -2960,6 +3023,8 @@ class AiHubServer {
2960
3023
  dep.label = label;
2961
3024
  if (jobId !== undefined)
2962
3025
  dep.jobId = jobId;
3026
+ if (resolvedProjectPath !== undefined)
3027
+ dep.projectPath = resolvedProjectPath;
2963
3028
  if (cronExpr !== undefined)
2964
3029
  dep.cronExpr = cronExpr;
2965
3030
  if (hostId !== undefined && validEmployees.includes(hostId))
@@ -3006,12 +3071,18 @@ class AiHubServer {
3006
3071
  this.deploymentStore.create(deployment);
3007
3072
  return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
3008
3073
  });
3009
- // GET /api/ai-hub/webhooks — list all webhook deployments.
3010
- 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) => {
3011
3076
  const hubBase = this.hubBase;
3012
- return res.json(this.deploymentStore.load()
3013
- .filter((d) => d.type === 'webhook')
3014
- .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
+ }
3015
3086
  });
3016
3087
  // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
3017
3088
  this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
@@ -3023,7 +3094,15 @@ class AiHubServer {
3023
3094
  // PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
3024
3095
  this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
3025
3096
  const { id } = req.params;
3026
- 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
+ }
3027
3106
  const validEmployees = VALID_EMPLOYEE_IDS;
3028
3107
  let updated = null;
3029
3108
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -3031,6 +3110,8 @@ class AiHubServer {
3031
3110
  dep.label = label;
3032
3111
  if (jobId !== undefined)
3033
3112
  dep.jobId = jobId;
3113
+ if (resolvedProjectPath !== undefined)
3114
+ dep.projectPath = resolvedProjectPath;
3034
3115
  if (hostId !== undefined && validEmployees.includes(hostId))
3035
3116
  dep.hostId = hostId;
3036
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 };
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,13 +209,12 @@ 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');
212
+ const contactEmail = product ? stringValue(product.contactEmail) : null;
213
+ if (!contactEmail) {
214
+ addIssue(result.errors, relPath, 'product.contactEmail must be set');
220
215
  }
221
- const packageVersion = readPackageVersion(repoRoot, result);
222
- if (productVersion && packageVersion) {
223
- assertEqual(productVersion, packageVersion, relPath, 'product.packageVersion', result);
216
+ if (contactEmail && product) {
217
+ assertEqual(product.support, contactEmail, relPath, 'product.support', result);
224
218
  }
225
219
  const remoteMcpUrl = remoteMcp
226
220
  ? requireHttpsUrl(remoteMcp.url, relPath, 'remoteMcp.url', result)
@@ -229,7 +223,7 @@ function validateTargetMatrix(repoRoot, result) {
229
223
  ? requireHttpsUrl(remoteMcp.oauthAuthorizationServer, relPath, 'remoteMcp.oauthAuthorizationServer', result)
230
224
  : null;
231
225
  validateTargetEntries(repoRoot, relPath, asArray(targetMatrix.targets), result);
232
- return { remoteMcpUrl, oauthAuthorizationServer, productVersion };
226
+ return { remoteMcpUrl, oauthAuthorizationServer, contactEmail };
233
227
  }
234
228
  function validateOpenAiAssets(repoRoot, openAiRoot, result) {
235
229
  assertFile(repoRoot, path_1.default.join(openAiRoot, 'README.md'), result);
@@ -254,6 +248,7 @@ function validateOpenAiPacketFields(packet, relPath, targetDetails, result) {
254
248
  else {
255
249
  assertEqual(app.name, 'FRAIM', relPath, 'app.name', result);
256
250
  requireHttpsUrl(app.website, relPath, 'app.website', result);
251
+ assertEqual(app.support, targetDetails.contactEmail, relPath, 'app.support', result);
257
252
  requireHttpsUrl(app.privacyPolicy, relPath, 'app.privacyPolicy', result);
258
253
  requireHttpsUrl(app.termsOfService, relPath, 'app.termsOfService', result);
259
254
  }
@@ -288,7 +283,7 @@ function validateOpenAiPacket(repoRoot, result, targetDetails) {
288
283
  assertEqual(packet.schema, 'fraim-openai-app-submission-packet-v1', relPath, 'schema', result);
289
284
  validateOpenAiPacketFields(packet, relPath, targetDetails, result);
290
285
  }
291
- function validatePolicyPages(repoRoot, result) {
286
+ function validatePolicyPages(repoRoot, targetDetails, result) {
292
287
  for (const relPath of POLICY_PAGE_PATHS) {
293
288
  if (!assertFile(repoRoot, relPath, result)) {
294
289
  continue;
@@ -297,7 +292,7 @@ function validatePolicyPages(repoRoot, result) {
297
292
  if (!content.includes('FRAIM')) {
298
293
  addIssue(result.errors, relPath, 'policy page must identify FRAIM');
299
294
  }
300
- if (!content.includes('support@fraimworks.ai')) {
295
+ if (targetDetails.contactEmail && !content.includes(targetDetails.contactEmail)) {
301
296
  addIssue(result.errors, relPath, 'policy page must include support contact');
302
297
  }
303
298
  }
@@ -337,11 +332,14 @@ function validateCodexManifestInterface(repoRoot, pluginRoot, manifestRelPath, m
337
332
  assertRelativeAsset(repoRoot, pluginRoot, screenshot, manifestRelPath, `interface.screenshots[${index}]`, result);
338
333
  }
339
334
  }
340
- function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, productVersion, result) {
335
+ function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, contactEmail, result) {
341
336
  if (!manifest)
342
337
  return;
343
338
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
344
- 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
+ }
342
+ assertEqual(asObject(manifest.author)?.email, contactEmail, manifestRelPath, 'author.email', result);
345
343
  assertEqual(manifest.skills, './skills/', manifestRelPath, 'skills', result);
346
344
  assertEqual(manifest.mcpServers, './.mcp.json', manifestRelPath, 'mcpServers', result);
347
345
  const manifestInterface = asObject(manifest.interface);
@@ -400,17 +398,21 @@ function validateCodexPackage(repoRoot, result, targetDetails) {
400
398
  const manifest = readJsonObject(repoRoot, manifestRelPath, result);
401
399
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
402
400
  validateCodexMarketplace(marketplace, marketplaceRelPath, result);
403
- validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.productVersion, result);
401
+ validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.contactEmail, result);
404
402
  validateCodexMcpConfig(mcpConfig, mcpRelPath, targetDetails.remoteMcpUrl, result);
405
403
  validateCodexSkill(repoRoot, skillRelPath, result);
406
404
  }
407
- function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, productVersion, result) {
405
+ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, contactEmail, result) {
408
406
  if (!marketplace)
409
407
  return;
410
408
  assertEqual(marketplace.name, expectedName, marketplaceRelPath, 'name', result);
411
- if (!asObject(marketplace.owner)) {
409
+ const owner = asObject(marketplace.owner);
410
+ if (!owner) {
412
411
  addIssue(result.errors, marketplaceRelPath, 'owner must be an object');
413
412
  }
413
+ else {
414
+ assertEqual(owner.email, contactEmail, marketplaceRelPath, 'owner.email', result);
415
+ }
414
416
  const entries = asArray(marketplace.plugins);
415
417
  const fraimEntry = entries
416
418
  .map((entry) => asObject(entry))
@@ -420,7 +422,13 @@ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expe
420
422
  return;
421
423
  }
422
424
  assertEqual(fraimEntry.source, expectedSource, marketplaceRelPath, 'fraim.source', result);
423
- 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
+ }
428
+ const author = asObject(fraimEntry.author);
429
+ if (author) {
430
+ assertEqual(author.email, contactEmail, marketplaceRelPath, 'fraim.author.email', result);
431
+ }
424
432
  }
425
433
  function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
426
434
  const registryRoot = path_1.default.join(TARGET_ROOT, 'mcp-registry');
@@ -436,7 +444,9 @@ function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
436
444
  if (!description || description.length > 100) {
437
445
  addIssue(result.errors, serverRelPath, 'description must be a non-empty string of at most 100 characters');
438
446
  }
439
- 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
+ }
440
450
  requireHttpsUrl(serverJson.websiteUrl, serverRelPath, 'websiteUrl', result);
441
451
  const repository = asObject(serverJson.repository);
442
452
  assertEqual(repository?.url, 'https://github.com/mathursrus/FRAIM', serverRelPath, 'repository.url', result);
@@ -467,11 +477,14 @@ function validateCursorPackage(repoRoot, result, targetDetails) {
467
477
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
468
478
  assertFile(repoRoot, path_1.default.join(cursorRoot, 'README.md'), result);
469
479
  assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
470
- validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.productVersion, result);
471
- validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.productVersion, 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);
472
482
  if (manifest) {
473
483
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
474
- 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
+ }
487
+ assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
475
488
  assertEqual(manifest.rules, 'rules/', manifestRelPath, 'rules', result);
476
489
  assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
477
490
  assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
@@ -514,10 +527,13 @@ function validateCopilotPackage(repoRoot, result, targetDetails) {
514
527
  const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
515
528
  assertFile(repoRoot, path_1.default.join(vscodeRoot, 'README.md'), result);
516
529
  assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
517
- validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.productVersion, result);
530
+ validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.contactEmail, result);
518
531
  if (manifest) {
519
532
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
520
- 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
+ }
536
+ assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
521
537
  assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
522
538
  assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
523
539
  assertEqual(manifest.mcpServers, '.mcp.json', manifestRelPath, 'mcpServers', result);
@@ -540,7 +556,9 @@ function validateGeminiPackage(repoRoot, result, targetDetails) {
540
556
  if (!manifest)
541
557
  return;
542
558
  assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
543
- 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
+ }
544
562
  assertEqual(manifest.contextFileName, 'GEMINI.md', manifestRelPath, 'contextFileName', result);
545
563
  const servers = asObject(manifest.mcpServers);
546
564
  const fraimServer = asObject(servers?.fraim);
@@ -559,7 +577,7 @@ function validateMarketplaceBundles(repoRoot = process.cwd()) {
559
577
  assertFile(repoRoot, ACCEPTANCE_EVIDENCE_PATH, result);
560
578
  const targetDetails = validateTargetMatrix(repoRoot, result);
561
579
  validateOpenAiPacket(repoRoot, result, targetDetails);
562
- validatePolicyPages(repoRoot, result);
580
+ validatePolicyPages(repoRoot, targetDetails, result);
563
581
  validateCodexPackage(repoRoot, result, targetDetails);
564
582
  validateMcpRegistryPackage(repoRoot, result, targetDetails);
565
583
  validateCursorPackage(repoRoot, result, targetDetails);