fraim 2.0.106 ā 2.0.109
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/cli/commands/setup.js +37 -12
- package/dist/src/cli/setup/ide-global-integration.js +8 -1
- package/dist/src/cli/setup/user-level-sync.js +18 -98
- package/dist/src/cli/utils/remote-sync.js +8 -17
- package/dist/src/core/quality-evidence.js +121 -0
- package/dist/src/local-mcp-server/stdio-server.js +62 -0
- package/package.json +1 -1
|
@@ -633,24 +633,49 @@ const runSetup = async (options) => {
|
|
|
633
633
|
console.log(chalk_1.default.cyan(' To initialize a project later, cd into a repo and run: fraim init-project'));
|
|
634
634
|
}
|
|
635
635
|
}
|
|
636
|
-
// Show summary
|
|
637
|
-
console.log(chalk_1.default.green('\n
|
|
638
|
-
console.log(chalk_1.default.
|
|
636
|
+
// Show mode-aware summary with clear value prop and next steps
|
|
637
|
+
console.log(chalk_1.default.green('\nāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
638
|
+
console.log(chalk_1.default.green(' FRAIM is ready!'));
|
|
639
|
+
console.log(chalk_1.default.green('āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
640
|
+
console.log(chalk_1.default.white('\n FRAIM is an AI management layer that turns you into'));
|
|
641
|
+
console.log(chalk_1.default.white(' a manager of AI agents. Run multiple agents through'));
|
|
642
|
+
console.log(chalk_1.default.white(' structured jobs, get manager-style coaching, and build'));
|
|
643
|
+
console.log(chalk_1.default.white(' a learning loop where agents improve over time.'));
|
|
644
|
+
console.log(chalk_1.default.gray('\n 60+ jobs across engineering, marketing, fundraising,'));
|
|
645
|
+
console.log(chalk_1.default.gray(' legal, product, hiring, customer development, and more.'));
|
|
646
|
+
// Show which IDEs were configured and how to use FRAIM in each
|
|
647
|
+
const { detectInstalledIDEs: detectIDEs } = await Promise.resolve().then(() => __importStar(require('../setup/ide-detector')));
|
|
648
|
+
const configuredIDEs = detectIDEs();
|
|
649
|
+
if (configuredIDEs.length > 0) {
|
|
650
|
+
const ideNames = configuredIDEs.map(ide => ide.name).join(', ');
|
|
651
|
+
console.log(chalk_1.default.cyan(`\n FRAIM is configured for: `) + chalk_1.default.white(ideNames));
|
|
652
|
+
console.log(chalk_1.default.cyan('\n Get started ā open any of those tools and:'));
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
console.log(chalk_1.default.cyan('\n Get started ā open your AI tool and:'));
|
|
656
|
+
}
|
|
657
|
+
// Check if Claude is among configured IDEs
|
|
658
|
+
const hasClaude = configuredIDEs.some(ide => ide.name.toLowerCase().includes('claude'));
|
|
659
|
+
if (hasClaude) {
|
|
660
|
+
console.log(chalk_1.default.white(' /fraim ') + chalk_1.default.gray('(Claude Code) Browse all jobs'));
|
|
661
|
+
}
|
|
662
|
+
console.log(chalk_1.default.white(' "What can FRAIM help me with?" ') + chalk_1.default.gray('Works in any AI tool'));
|
|
663
|
+
console.log(chalk_1.default.gray('\n Just tell your AI what you need ā FRAIM will find the right job.'));
|
|
639
664
|
if (mode !== 'conversational') {
|
|
665
|
+
console.log(chalk_1.default.cyan('\n To set up FRAIM in a specific project:'));
|
|
666
|
+
console.log(chalk_1.default.white(' cd your-project && fraim init-project'));
|
|
667
|
+
console.log(chalk_1.default.gray(' This enables project-specific customizations,'));
|
|
668
|
+
console.log(chalk_1.default.gray(' GitHub workflows, and team learning.'));
|
|
640
669
|
const configuredProviders = await Promise.all(Object.keys(tokens).map(async (id) => await (0, provider_registry_1.getProviderDisplayName)(id)));
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
console.log(chalk_1.default.cyan(' 1. cd into any project directory'));
|
|
645
|
-
console.log(chalk_1.default.cyan(' 2. Run: fraim init-project'));
|
|
646
|
-
console.log(chalk_1.default.cyan(' 3. Ask your AI agent: "FRAIM was just installed. Read the FRAIM docs, explain what it can do for me, then run project-onboarding."'));
|
|
647
|
-
if (mode === 'integrated') {
|
|
670
|
+
if (configuredProviders.length > 0) {
|
|
671
|
+
console.log(chalk_1.default.gray(`\n Platforms: ${configuredProviders.join(', ')}`));
|
|
672
|
+
}
|
|
648
673
|
const allProviderIds = await (0, provider_registry_1.getAllProviderIds)();
|
|
649
674
|
const unconfiguredProviders = allProviderIds.filter(id => !tokens[id]);
|
|
650
675
|
if (unconfiguredProviders.length > 0) {
|
|
651
|
-
console.log(chalk_1.default.gray('\n
|
|
676
|
+
console.log(chalk_1.default.gray('\n To add more platforms later:'));
|
|
652
677
|
unconfiguredProviders.forEach(id => {
|
|
653
|
-
console.log(chalk_1.default.gray(`
|
|
678
|
+
console.log(chalk_1.default.gray(` fraim setup --${id}`));
|
|
654
679
|
});
|
|
655
680
|
}
|
|
656
681
|
}
|
|
@@ -22,7 +22,14 @@ const FRAIM_SLASH_COMMAND_CONTENT = `The user wants to run FRAIM. The requested
|
|
|
22
22
|
|
|
23
23
|
Follow this process:
|
|
24
24
|
|
|
25
|
-
1. **If no argument was given** (the line above ends with ": "):
|
|
25
|
+
1. **If no argument was given** (the line above ends with ": "):
|
|
26
|
+
Call \`list_fraim_jobs()\` to discover available jobs. Present the results to the user grouped by business function (the server returns jobs organized by category ā use those categories as group headings). For each group, list 3-5 of the most impactful jobs with a one-line description.
|
|
27
|
+
|
|
28
|
+
After listing, suggest 2-3 starting points based on what seems most relevant:
|
|
29
|
+
- If in a code repo: suggest jobs from engineering/product-building categories
|
|
30
|
+
- If no repo context: suggest jobs from marketing, fundraising, or business categories
|
|
31
|
+
|
|
32
|
+
Ask the user which job they want to run, then proceed to step 2.
|
|
26
33
|
|
|
27
34
|
2. **Find the match**: from the list returned by \`list_fraim_jobs()\`, find the job whose name matches or closely resembles the argument. If no job matches, search for a matching skill by calling \`get_fraim_file({ path: "skills/<likely-category>/<argument>.md" })\` ā try common categories like \`engineering/\`, \`marketing/\`, \`business/\`, \`product-management/\`, \`ai-tools/\`. Confirm the match with the user.
|
|
28
35
|
|
|
@@ -1,37 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
2
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
4
|
};
|
|
@@ -39,11 +6,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
6
|
exports.ensureUserLevelDirectories = ensureUserLevelDirectories;
|
|
40
7
|
exports.syncUserLevelArtifacts = syncUserLevelArtifacts;
|
|
41
8
|
/**
|
|
42
|
-
* User-Level FRAIM
|
|
9
|
+
* User-Level FRAIM Setup
|
|
10
|
+
*
|
|
11
|
+
* Ensures the user-level ~/.fraim/ directory has the minimal structure needed
|
|
12
|
+
* for FRAIM to work outside of any project:
|
|
43
13
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
14
|
+
* - config.json ā auth, mode, tokens (managed by setup.ts)
|
|
15
|
+
* - scripts/ ā locally-executed scripts (synced during any fraim sync)
|
|
16
|
+
* - personalized-employee/ ā user-level overrides and learnings
|
|
17
|
+
*
|
|
18
|
+
* Job/skill/rule stubs are NOT synced to user-level. The MCP proxy serves
|
|
19
|
+
* those on demand via list_fraim_jobs() and get_fraim_job(). Stubs only exist
|
|
20
|
+
* at project-level (fraim/) where CLAUDE.md tells agents to scan them on disk.
|
|
47
21
|
*
|
|
48
22
|
* Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
|
|
49
23
|
*/
|
|
@@ -52,21 +26,17 @@ const path_1 = __importDefault(require("path"));
|
|
|
52
26
|
const chalk_1 = __importDefault(require("chalk"));
|
|
53
27
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
54
28
|
/**
|
|
55
|
-
* Ensure the user-level FRAIM directory structure exists
|
|
56
|
-
* Creates
|
|
29
|
+
* Ensure the user-level FRAIM directory structure exists.
|
|
30
|
+
* Creates personalized-employee dirs for user-level overrides.
|
|
31
|
+
* Scripts dir is handled by existing script-sync-utils during any fraim sync.
|
|
57
32
|
*/
|
|
58
33
|
function ensureUserLevelDirectories(userFraimDir) {
|
|
59
34
|
const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
|
|
60
35
|
const dirs = [
|
|
61
|
-
path_1.default.join(baseDir, 'ai-employee', 'jobs'),
|
|
62
|
-
path_1.default.join(baseDir, 'ai-employee', 'skills'),
|
|
63
|
-
path_1.default.join(baseDir, 'ai-employee', 'rules'),
|
|
64
|
-
path_1.default.join(baseDir, 'ai-manager', 'jobs'),
|
|
65
36
|
path_1.default.join(baseDir, 'personalized-employee', 'jobs'),
|
|
66
37
|
path_1.default.join(baseDir, 'personalized-employee', 'skills'),
|
|
67
38
|
path_1.default.join(baseDir, 'personalized-employee', 'rules'),
|
|
68
39
|
path_1.default.join(baseDir, 'personalized-employee', 'learnings'),
|
|
69
|
-
path_1.default.join(baseDir, 'docs'),
|
|
70
40
|
];
|
|
71
41
|
for (const dir of dirs) {
|
|
72
42
|
if (!fs_1.default.existsSync(dir)) {
|
|
@@ -75,65 +45,15 @@ function ensureUserLevelDirectories(userFraimDir) {
|
|
|
75
45
|
}
|
|
76
46
|
}
|
|
77
47
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* Uses the same remote sync endpoint as project-level sync, but writes
|
|
82
|
-
* content directly to the user-level directory structure instead of under
|
|
83
|
-
* a project's fraim/ subdirectory.
|
|
48
|
+
* Set up the user-level FRAIM directory.
|
|
49
|
+
* Creates the personalized-employee structure so FRAIM works outside any project.
|
|
84
50
|
*
|
|
85
51
|
* @param userFraimDir - Override for the target directory (for testing)
|
|
86
52
|
*/
|
|
87
53
|
async function syncUserLevelArtifacts(userFraimDir) {
|
|
88
54
|
const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
|
|
89
|
-
console.log(chalk_1.default.blue('š¦
|
|
55
|
+
console.log(chalk_1.default.blue('š¦ Setting up user-level FRAIM directory...'));
|
|
90
56
|
console.log(chalk_1.default.gray(` Target: ${baseDir}`));
|
|
91
|
-
// Ensure directory structure exists
|
|
92
57
|
ensureUserLevelDirectories(baseDir);
|
|
93
|
-
|
|
94
|
-
// we still have the directory structure in place.
|
|
95
|
-
try {
|
|
96
|
-
const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
|
|
97
|
-
const apiKey = loadApiKeyFromConfig(baseDir);
|
|
98
|
-
if (!apiKey) {
|
|
99
|
-
console.log(chalk_1.default.yellow('ā ļø No API key found. User-level content sync skipped.'));
|
|
100
|
-
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const result = await syncFromRemote({
|
|
104
|
-
apiKey,
|
|
105
|
-
projectRoot: baseDir,
|
|
106
|
-
targetIsUserLevel: true,
|
|
107
|
-
skipUpdates: true
|
|
108
|
-
});
|
|
109
|
-
if (result.success) {
|
|
110
|
-
console.log(chalk_1.default.green(`ā
Synced ${result.employeeJobsSynced} ai-employee jobs, ` +
|
|
111
|
-
`${result.managerJobsSynced} ai-manager jobs, ` +
|
|
112
|
-
`${result.skillsSynced} skills, ${result.rulesSynced} rules, ` +
|
|
113
|
-
`${result.scriptsSynced} scripts, and ${result.docsSynced} docs to ~/.fraim/`));
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
console.log(chalk_1.default.yellow(`ā ļø User-level content sync failed: ${result.error}`));
|
|
117
|
-
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
console.log(chalk_1.default.yellow(`ā ļø User-level content sync failed: ${error.message}`));
|
|
122
|
-
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Load API key from the user-level config file.
|
|
127
|
-
*/
|
|
128
|
-
function loadApiKeyFromConfig(userFraimDir) {
|
|
129
|
-
const configPath = path_1.default.join(userFraimDir, 'config.json');
|
|
130
|
-
if (!fs_1.default.existsSync(configPath))
|
|
131
|
-
return undefined;
|
|
132
|
-
try {
|
|
133
|
-
const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
134
|
-
return config.apiKey;
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
58
|
+
console.log(chalk_1.default.green('ā
User-level FRAIM directory ready'));
|
|
139
59
|
}
|
|
@@ -135,17 +135,8 @@ async function syncFromRemote(options) {
|
|
|
135
135
|
error: 'No files received'
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// For project-level sync, use getWorkspaceFraimPath which prepends 'fraim/'.
|
|
141
|
-
const resolveFraimPath = (...parts) => {
|
|
142
|
-
if (options.targetIsUserLevel) {
|
|
143
|
-
return (0, path_1.join)(options.projectRoot, ...parts);
|
|
144
|
-
}
|
|
145
|
-
return (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, ...parts);
|
|
146
|
-
};
|
|
147
|
-
const lockTargets = options.targetIsUserLevel ? [] : getSyncedContentLockTargets(options.projectRoot);
|
|
148
|
-
if (!options.targetIsUserLevel && shouldLockSyncedContent()) {
|
|
138
|
+
const lockTargets = getSyncedContentLockTargets(options.projectRoot);
|
|
139
|
+
if (shouldLockSyncedContent()) {
|
|
149
140
|
// If previous sync locked these paths read-only, temporarily unlock before cleanup/write.
|
|
150
141
|
for (const target of lockTargets) {
|
|
151
142
|
setFileWriteLockRecursively(target, false);
|
|
@@ -155,7 +146,7 @@ async function syncFromRemote(options) {
|
|
|
155
146
|
const allJobFiles = files.filter(f => f.type === 'job');
|
|
156
147
|
const managerJobFiles = allJobFiles.filter(f => f.path.startsWith('ai-manager/'));
|
|
157
148
|
const jobFiles = allJobFiles.filter(f => !f.path.startsWith('ai-manager/'));
|
|
158
|
-
const employeeJobsDir =
|
|
149
|
+
const employeeJobsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'jobs');
|
|
159
150
|
if (!(0, fs_1.existsSync)(employeeJobsDir)) {
|
|
160
151
|
(0, fs_1.mkdirSync)(employeeJobsDir, { recursive: true });
|
|
161
152
|
}
|
|
@@ -175,7 +166,7 @@ async function syncFromRemote(options) {
|
|
|
175
166
|
console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-employee/jobs/${relPath}`)}`));
|
|
176
167
|
}
|
|
177
168
|
// Sync ai-manager job stubs to fraim/ai-manager/jobs/
|
|
178
|
-
const managerJobsDir =
|
|
169
|
+
const managerJobsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-manager', 'jobs');
|
|
179
170
|
if (!(0, fs_1.existsSync)(managerJobsDir)) {
|
|
180
171
|
(0, fs_1.mkdirSync)(managerJobsDir, { recursive: true });
|
|
181
172
|
}
|
|
@@ -196,7 +187,7 @@ async function syncFromRemote(options) {
|
|
|
196
187
|
}
|
|
197
188
|
// Sync skill STUBS to fraim/ai-employee/skills/
|
|
198
189
|
const skillFiles = files.filter(f => f.type === 'skill');
|
|
199
|
-
const skillsDir =
|
|
190
|
+
const skillsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'skills');
|
|
200
191
|
if (!(0, fs_1.existsSync)(skillsDir)) {
|
|
201
192
|
(0, fs_1.mkdirSync)(skillsDir, { recursive: true });
|
|
202
193
|
}
|
|
@@ -216,7 +207,7 @@ async function syncFromRemote(options) {
|
|
|
216
207
|
}
|
|
217
208
|
// Sync rule STUBS to fraim/ai-employee/rules/
|
|
218
209
|
const ruleFiles = files.filter(f => f.type === 'rule');
|
|
219
|
-
const rulesDir =
|
|
210
|
+
const rulesDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'rules');
|
|
220
211
|
if (!(0, fs_1.existsSync)(rulesDir)) {
|
|
221
212
|
(0, fs_1.mkdirSync)(rulesDir, { recursive: true });
|
|
222
213
|
}
|
|
@@ -255,7 +246,7 @@ async function syncFromRemote(options) {
|
|
|
255
246
|
}
|
|
256
247
|
// Sync docs to fraim/docs/
|
|
257
248
|
const docsFiles = files.filter(f => f.type === 'docs');
|
|
258
|
-
const docsDir =
|
|
249
|
+
const docsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'docs');
|
|
259
250
|
if (!(0, fs_1.existsSync)(docsDir)) {
|
|
260
251
|
(0, fs_1.mkdirSync)(docsDir, { recursive: true });
|
|
261
252
|
}
|
|
@@ -269,7 +260,7 @@ async function syncFromRemote(options) {
|
|
|
269
260
|
(0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
|
|
270
261
|
console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`docs/${file.path}`)}`));
|
|
271
262
|
}
|
|
272
|
-
if (
|
|
263
|
+
if (shouldLockSyncedContent()) {
|
|
273
264
|
for (const target of lockTargets) {
|
|
274
265
|
setFileWriteLockRecursively(target, true);
|
|
275
266
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Quality Evidence Validation (Issue #251)
|
|
4
|
+
*
|
|
5
|
+
* Pure helpers for validating the `evidence.quality` object that
|
|
6
|
+
* quality-producing FRAIM jobs must emit on their final seekMentoring
|
|
7
|
+
* completion. Lives in src/core so both the local MCP proxy and the
|
|
8
|
+
* remote server can import the same contract without cross-layer
|
|
9
|
+
* dependencies.
|
|
10
|
+
*
|
|
11
|
+
* Two call sites currently:
|
|
12
|
+
* 1. src/local-mcp-server/stdio-server.ts ā enforces on local
|
|
13
|
+
* seekMentoring completion and POSTs valid scores to the remote
|
|
14
|
+
* via /api/analytics/quality-score.
|
|
15
|
+
* 2. src/routes/analytics.ts ā defense-in-depth validation on the
|
|
16
|
+
* /api/analytics/quality-score endpoint.
|
|
17
|
+
*
|
|
18
|
+
* A third (rare) call site lives in src/services/mcp-service.ts for
|
|
19
|
+
* the case where the local proxy falls through to the remote's own
|
|
20
|
+
* seekMentoring handler (e.g., when the local AIMentor errors).
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.REQUIRED_QUALITY_FIELDS = exports.QUALITY_PRODUCING_JOBS = void 0;
|
|
24
|
+
exports.validateQualityEvidence = validateQualityEvidence;
|
|
25
|
+
exports.buildQualityRejectionMessage = buildQualityRejectionMessage;
|
|
26
|
+
/**
|
|
27
|
+
* Jobs that produce a quality assessment and MUST emit a quality score
|
|
28
|
+
* via `evidence.quality` on their final seekMentoring completion.
|
|
29
|
+
*/
|
|
30
|
+
exports.QUALITY_PRODUCING_JOBS = [
|
|
31
|
+
'process-interview-notes',
|
|
32
|
+
'triage-customer-needs'
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Required numeric fields inside `evidence.quality` for QUALITY_PRODUCING_JOBS.
|
|
36
|
+
* The schema is intentionally lenient beyond these ā extra fields are
|
|
37
|
+
* allowed so per-job assessment can evolve ā but these five gate completion.
|
|
38
|
+
*/
|
|
39
|
+
exports.REQUIRED_QUALITY_FIELDS = [
|
|
40
|
+
{ path: 'composite', type: 'number' },
|
|
41
|
+
{ path: 'participant.fit', type: 'number' },
|
|
42
|
+
{ path: 'participant.urgency', type: 'number' },
|
|
43
|
+
{ path: 'participant.authority', type: 'number' },
|
|
44
|
+
{ path: 'evidence.quoteSpecificityAvg', type: 'number' }
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Validate an `evidence.quality` object against REQUIRED_QUALITY_FIELDS.
|
|
48
|
+
* Returns null if valid, or an array of human-readable error strings.
|
|
49
|
+
*
|
|
50
|
+
* Design note: when `quality` is entirely missing we still walk every
|
|
51
|
+
* required field and emit a per-field error, so the agent sees the
|
|
52
|
+
* complete schema in one round rather than being told only that
|
|
53
|
+
* `quality` itself is missing.
|
|
54
|
+
*/
|
|
55
|
+
function validateQualityEvidence(quality) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const isMissing = quality === undefined || quality === null;
|
|
58
|
+
if (isMissing) {
|
|
59
|
+
errors.push('evidence.quality is missing');
|
|
60
|
+
}
|
|
61
|
+
else if (typeof quality !== 'object' || Array.isArray(quality)) {
|
|
62
|
+
errors.push('evidence.quality must be an object');
|
|
63
|
+
}
|
|
64
|
+
const effective = isMissing || typeof quality !== 'object' || Array.isArray(quality)
|
|
65
|
+
? undefined
|
|
66
|
+
: quality;
|
|
67
|
+
for (const { path, type } of exports.REQUIRED_QUALITY_FIELDS) {
|
|
68
|
+
const parts = path.split('.');
|
|
69
|
+
let cursor = effective;
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
if (cursor && typeof cursor === 'object' && part in cursor) {
|
|
72
|
+
cursor = cursor[part];
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
cursor = undefined;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (typeof cursor !== type || (type === 'number' && Number.isNaN(cursor))) {
|
|
80
|
+
const got = cursor === undefined ? 'missing' : (Number.isNaN(cursor) ? 'NaN' : typeof cursor);
|
|
81
|
+
errors.push(`evidence.quality.${path} must be a ${type} (got ${got})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return errors.length > 0 ? errors : null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build the rejection message sent back to the agent when a quality-producing
|
|
88
|
+
* job tries to complete without a valid `evidence.quality` object. The message
|
|
89
|
+
* lists the specific errors and the required minimum schema so the agent can
|
|
90
|
+
* fix everything in one round.
|
|
91
|
+
*/
|
|
92
|
+
function buildQualityRejectionMessage(jobName, currentPhase, errors) {
|
|
93
|
+
const errorBullets = errors.map(e => `- ${e}`).join('\n');
|
|
94
|
+
return [
|
|
95
|
+
`ā **Job completion rejected** for \`${jobName}\`.`,
|
|
96
|
+
'',
|
|
97
|
+
`This job is required to emit a quality score on its final \`seekMentoring\` call so the result is captured in \`fraim_quality_scores\` for the manager dashboard. The following problems were found in \`evidence.quality\`:`,
|
|
98
|
+
'',
|
|
99
|
+
errorBullets,
|
|
100
|
+
'',
|
|
101
|
+
'Required minimum schema:',
|
|
102
|
+
'',
|
|
103
|
+
'```javascript',
|
|
104
|
+
'evidence: {',
|
|
105
|
+
' quality: {',
|
|
106
|
+
' composite: <number 0-10>,',
|
|
107
|
+
' participant: { fit: <number 1-10>, urgency: <number 1-10>, authority: <number 1-10> },',
|
|
108
|
+
' evidence: {',
|
|
109
|
+
' quoteSpecificityAvg: <number 1-10>',
|
|
110
|
+
' // other fields are allowed and encouraged but not required',
|
|
111
|
+
' },',
|
|
112
|
+
' interviewee: "<name>",',
|
|
113
|
+
' company: "<company>",',
|
|
114
|
+
' artifactPath: "docs/customer-development/<slug>-interview.md"',
|
|
115
|
+
' }',
|
|
116
|
+
'}',
|
|
117
|
+
'```',
|
|
118
|
+
'',
|
|
119
|
+
`You are still in phase \`${currentPhase}\`. The job is **not** marked complete. Build the \`evidence.quality\` object from your Phase 4 (\`conversation-quality-assessment\`) work and resubmit this final phase.`
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
@@ -29,6 +29,7 @@ const provider_utils_1 = require("../core/utils/provider-utils");
|
|
|
29
29
|
const object_utils_1 = require("../core/utils/object-utils");
|
|
30
30
|
const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
|
|
31
31
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
32
|
+
const quality_evidence_1 = require("../core/quality-evidence");
|
|
32
33
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
33
34
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
34
35
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
@@ -1664,6 +1665,40 @@ class FraimLocalMCPServer {
|
|
|
1664
1665
|
const mentor = this.getMentor(requestSessionId);
|
|
1665
1666
|
const tutoringResponse = await mentor.handleMentoringRequest(args);
|
|
1666
1667
|
this.log(`ā
Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
|
|
1668
|
+
// Quality enforcement (Issue #251).
|
|
1669
|
+
//
|
|
1670
|
+
// The local proxy owns seekMentoring for personalized-job support.
|
|
1671
|
+
// That means the server-side enforcement in mcp-service.handleSeekMentoring
|
|
1672
|
+
// rarely fires in practice, so this is the primary enforcement point.
|
|
1673
|
+
//
|
|
1674
|
+
// When a QUALITY_PRODUCING_JOBS job reaches its final phase
|
|
1675
|
+
// (nextPhase === null) with status === 'complete', the agent MUST
|
|
1676
|
+
// include a valid `evidence.quality` object. If invalid, swap the
|
|
1677
|
+
// accomplishment message for a rejection and DO NOT emit. If valid,
|
|
1678
|
+
// fire-and-forget POST to /api/analytics/quality-score so the row
|
|
1679
|
+
// lands in fraim_quality_scores.
|
|
1680
|
+
const isQualityJob = quality_evidence_1.QUALITY_PRODUCING_JOBS.includes(args.jobName);
|
|
1681
|
+
const isFinalCompletion = args.status === 'complete' &&
|
|
1682
|
+
(tutoringResponse.nextPhase === null || tutoringResponse.nextPhase === undefined);
|
|
1683
|
+
if (isQualityJob && isFinalCompletion) {
|
|
1684
|
+
const qualityErrors = (0, quality_evidence_1.validateQualityEvidence)(args.evidence?.quality);
|
|
1685
|
+
if (qualityErrors) {
|
|
1686
|
+
this.log(`ā Quality enforcement rejected ${args.jobName} completion: ${qualityErrors.join('; ')}`);
|
|
1687
|
+
const rejection = (0, quality_evidence_1.buildQualityRejectionMessage)(args.jobName, args.currentPhase, qualityErrors);
|
|
1688
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
1689
|
+
}
|
|
1690
|
+
// Valid payload. Emit to the remote asynchronously.
|
|
1691
|
+
this.emitQualityScoreToRemote({
|
|
1692
|
+
jobName: args.jobName,
|
|
1693
|
+
jobId: args.jobId,
|
|
1694
|
+
sessionId: requestSessionId || args.sessionId || 'unknown',
|
|
1695
|
+
quality: args.evidence.quality,
|
|
1696
|
+
artifactPath: args.evidence.quality.artifactPath
|
|
1697
|
+
}).catch((err) => {
|
|
1698
|
+
// Best-effort: log but never fail the user-facing response.
|
|
1699
|
+
this.log(`ā ļø Quality score emission failed: ${err?.message || err}`);
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1667
1702
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
1668
1703
|
}
|
|
1669
1704
|
catch (error) {
|
|
@@ -1971,6 +2006,33 @@ class FraimLocalMCPServer {
|
|
|
1971
2006
|
this.log(`ā ļø Skipping usage collection: toolName=${toolName}, sessionId=${requestSessionId}`);
|
|
1972
2007
|
}
|
|
1973
2008
|
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Emit a quality score to the remote FRAIM server (Issue #251).
|
|
2011
|
+
*
|
|
2012
|
+
* Called from the seekMentoring short-circuit when a QUALITY_PRODUCING_JOBS
|
|
2013
|
+
* job reaches its final phase with a validated `evidence.quality` payload.
|
|
2014
|
+
* Fire-and-forget from the caller's perspective: a failure here is logged
|
|
2015
|
+
* but does not block the user-facing response (analytics capture is best
|
|
2016
|
+
* effort, not blocking).
|
|
2017
|
+
*
|
|
2018
|
+
* On success the remote writes a row to the `fraim_quality_scores` MongoDB
|
|
2019
|
+
* collection which powers the manager dashboard trajectory chart.
|
|
2020
|
+
*/
|
|
2021
|
+
async emitQualityScoreToRemote(payload) {
|
|
2022
|
+
const url = `${this.remoteUrl}/api/analytics/quality-score`;
|
|
2023
|
+
this.log(`š Emitting quality score ā ${url} (job=${payload.jobName}, jobId=${payload.jobId})`);
|
|
2024
|
+
const response = await axios_1.default.post(url, payload, {
|
|
2025
|
+
headers: {
|
|
2026
|
+
'Content-Type': 'application/json',
|
|
2027
|
+
'x-api-key': this.apiKey
|
|
2028
|
+
},
|
|
2029
|
+
timeout: 10000
|
|
2030
|
+
});
|
|
2031
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2032
|
+
throw new Error(`quality-score endpoint returned ${response.status}: ${JSON.stringify(response.data)}`);
|
|
2033
|
+
}
|
|
2034
|
+
this.log(`š ā
Quality score accepted by remote (composite=${payload.quality.composite})`);
|
|
2035
|
+
}
|
|
1974
2036
|
/**
|
|
1975
2037
|
* Flush collected usage data to the remote server
|
|
1976
2038
|
*/
|