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.
- package/dist/src/ai-hub/hosts.js +1 -1
- package/dist/src/ai-hub/manager-turns.js +2 -2
- package/dist/src/ai-hub/preferences.js +3 -2
- package/dist/src/ai-hub/server.js +65 -21
- package/dist/src/cli/distribution/marketplace-bundles.js +26 -27
- package/dist/src/fraim/db-service.js +25 -3
- package/dist/src/routes/oauth-routes.js +40 -5
- package/dist/src/services/account-provisioning.js +38 -0
- package/dist/src/services/admin-service.js +10 -36
- package/package.json +1 -1
- package/public/ai-hub/script.js +47 -34
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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(/^[
|
|
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)([
|
|
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()
|
|
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
|
-
// #
|
|
1839
|
-
//
|
|
1840
|
-
|
|
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
|
|
2966
|
-
this.app.get('/api/ai-hub/schedules', (
|
|
2967
|
-
|
|
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
|
|
3047
|
-
this.app.get('/api/ai-hub/webhooks', (
|
|
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
|
-
|
|
3050
|
-
|
|
3051
|
-
.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
488
|
-
validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
144
|
-
company: company
|
|
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
|
|
188
|
-
|
|
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.
|
|
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": {
|
package/public/ai-hub/script.js
CHANGED
|
@@ -668,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
|
|
|
668
668
|
.trim();
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
-
function
|
|
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(
|
|
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
|
|
2632
|
-
//
|
|
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)[
|
|
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
|
|
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(/^(?:[
|
|
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(`^(?:[
|
|
2696
|
+
const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
2688
2697
|
return raw
|
|
2689
2698
|
.replace(invocationWithSlug, '')
|
|
2690
|
-
.replace(/^(?:[
|
|
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(/^[
|
|
4652
|
+
const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
|
|
4644
4653
|
const stripped = raw
|
|
4645
|
-
.replace(/^[
|
|
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
|
|
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
|
-
|
|
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([...(
|
|
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 = (
|
|
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.
|
|
6722
|
-
//
|
|
6723
|
-
//
|
|
6724
|
-
//
|
|
6725
|
-
//
|
|
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: ${(
|
|
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
|
-
|
|
9390
|
-
|
|
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 = (
|
|
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');
|