fraim-framework 2.0.124 → 2.0.127

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.
Files changed (46) hide show
  1. package/bin/fraim.js +1 -1
  2. package/dist/src/ai-hub/catalog.js +280 -44
  3. package/dist/src/ai-hub/desktop-main.js +2 -2
  4. package/dist/src/ai-hub/hosts.js +384 -10
  5. package/dist/src/ai-hub/server.js +255 -9
  6. package/dist/src/cli/commands/add-ide.js +4 -3
  7. package/dist/src/cli/commands/first-run.js +61 -0
  8. package/dist/src/cli/commands/hub.js +4 -4
  9. package/dist/src/cli/commands/init-project.js +4 -4
  10. package/dist/src/cli/commands/setup.js +4 -3
  11. package/dist/src/cli/commands/sync.js +21 -2
  12. package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
  13. package/dist/src/cli/fraim.js +2 -0
  14. package/dist/src/cli/mcp/ide-formats.js +29 -1
  15. package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
  16. package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
  17. package/dist/src/cli/setup/ide-detector.js +32 -1
  18. package/dist/src/cli/setup/ide-global-integration.js +5 -1
  19. package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
  20. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  21. package/dist/src/cli/utils/agent-adapters.js +12 -2
  22. package/dist/src/cli/utils/project-bootstrap.js +4 -3
  23. package/dist/src/core/quality-evidence.js +81 -8
  24. package/dist/src/core/utils/git-utils.js +32 -7
  25. package/dist/src/core/utils/job-aliases.js +47 -0
  26. package/dist/src/core/utils/workflow-parser.js +3 -5
  27. package/dist/src/first-run/install-state.js +68 -0
  28. package/dist/src/first-run/server.js +153 -0
  29. package/dist/src/first-run/session-service.js +302 -0
  30. package/dist/src/first-run/types.js +40 -0
  31. package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
  32. package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
  33. package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
  34. package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
  35. package/dist/src/local-mcp-server/stdio-server.js +70 -17
  36. package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
  37. package/dist/src/local-mcp-server/usage-collector.js +25 -0
  38. package/index.js +83 -83
  39. package/package.json +7 -1
  40. package/public/ai-hub/index.html +149 -102
  41. package/public/ai-hub/script.js +1154 -271
  42. package/public/ai-hub/styles.css +753 -450
  43. package/public/first-run/index.html +221 -0
  44. package/public/first-run/script.js +361 -0
  45. package/dist/src/cli/services/device-flow-service.js +0 -83
  46. package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
package/bin/fraim.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * FRAIM Framework CLI Entry Point
@@ -6,25 +6,48 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.summarizeProject = summarizeProject;
7
7
  exports.discoverEmployeeJobs = discoverEmployeeJobs;
8
8
  exports.discoverManagerTemplates = discoverManagerTemplates;
9
+ exports.loadJobPhases = loadJobPhases;
10
+ exports.loadAllJobPhaseIds = loadAllJobPhaseIds;
11
+ exports.labelForPhaseId = labelForPhaseId;
9
12
  exports.getAiHubCategories = getAiHubCategories;
10
13
  const fs_1 = __importDefault(require("fs"));
11
14
  const path_1 = __importDefault(require("path"));
12
15
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
- const EMPLOYEE_CATEGORIES = {
14
- marketing: {
15
- id: 'marketing',
16
- label: 'Marketing',
17
- description: 'Content, campaign, and creative production jobs.',
18
- },
19
- 'go-to-market': {
20
- id: 'go-to-market',
21
- label: 'GTM',
22
- description: 'Launch, distribution, and growth execution jobs.',
23
- },
24
- };
25
- const MANAGER_GROUPS = {
26
- coaching: 'Coaching',
27
- delegation: 'Delegation',
16
+ // Directories scanned for employee jobs, in lowest-to-highest precedence
17
+ // order. Later entries win on {categoryId, jobId} collision. All present
18
+ // layers contribute their category structure.
19
+ //
20
+ // Each entry is `[base, ...segments]` resolved relative to the project root:
21
+ // - 'registry' → <project>/registry/jobs/ai-employee/<category>/
22
+ // (present in the FRAIM source repo and in dist/)
23
+ // - 'fraim' → <project>/fraim/ai-employee/jobs/<category>/
24
+ // (the baseline synced into a regular project via
25
+ // `fraim setup` / `fraim sync`)
26
+ // - 'fraim' → <project>/fraim/personalized-employee/jobs/<category>/
27
+ // (per-project override layer; CLAUDE.md says it
28
+ // "takes precedence over synced baseline content")
29
+ const EMPLOYEE_JOB_LAYERS = [
30
+ { base: 'registry', segments: ['jobs', 'ai-employee'] },
31
+ { base: 'fraim', segments: ['ai-employee', 'jobs'] },
32
+ { base: 'fraim', segments: ['personalized-employee', 'jobs'] },
33
+ ];
34
+ // Manager templates use the matching three-layer model.
35
+ const MANAGER_JOB_LAYERS = [
36
+ { base: 'registry', segments: ['jobs', 'ai-manager'] },
37
+ { base: 'fraim', segments: ['ai-manager', 'jobs'] },
38
+ { base: 'fraim', segments: ['personalized-employee', 'manager-jobs'] },
39
+ ];
40
+ const KNOWN_LABEL_OVERRIDES = {
41
+ // Project conventions where a directory name should render as a recognized
42
+ // short form rather than its mechanical title-case spelling.
43
+ 'go-to-market': 'GTM',
44
+ gtm: 'GTM',
45
+ 'qa': 'QA',
46
+ rfp: 'RFP',
47
+ ai: 'AI',
48
+ // Manager-side groups whose directory names are already idiomatic and
49
+ // shouldn't be split on dashes by the default humanizer.
50
+ '1-1': '1:1',
28
51
  };
29
52
  const sectionValue = (content, heading) => {
30
53
  const match = content.match(new RegExp(`## ${heading}\\r?\\n([\\s\\S]*?)(?:\\r?\\n## |\\r?\\n---|$)`));
@@ -36,48 +59,68 @@ const sectionValue = (content, heading) => {
36
59
  .filter(Boolean)
37
60
  .map((line) => line.replace(/^[-*]\s*/, '').trim());
38
61
  };
39
- const humanizeJobName = (jobName) => jobName
40
- .split('-')
41
- .map((part) => (part.length > 0 ? `${part[0].toUpperCase()}${part.slice(1)}` : part))
42
- .join(' ');
62
+ const humanizeName = (raw) => {
63
+ const lower = raw.toLowerCase();
64
+ if (KNOWN_LABEL_OVERRIDES[lower])
65
+ return KNOWN_LABEL_OVERRIDES[lower];
66
+ return raw
67
+ .split(/[-_\s]+/)
68
+ .filter((part) => part.length > 0)
69
+ .map((part) => {
70
+ const partLower = part.toLowerCase();
71
+ if (KNOWN_LABEL_OVERRIDES[partLower])
72
+ return KNOWN_LABEL_OVERRIDES[partLower];
73
+ return `${part[0].toUpperCase()}${part.slice(1).toLowerCase()}`;
74
+ })
75
+ .join(' ');
76
+ };
43
77
  const toPosix = (value) => value.replace(/\\/g, '/');
44
- function parseJobStub(filePath, categoryId, projectPath) {
78
+ function readSubdirectoryNames(dirPath) {
79
+ if (!fs_1.default.existsSync(dirPath))
80
+ return [];
81
+ return fs_1.default
82
+ .readdirSync(dirPath, { withFileTypes: true })
83
+ .filter((entry) => entry.isDirectory())
84
+ .map((entry) => entry.name)
85
+ .sort((a, b) => a.localeCompare(b));
86
+ }
87
+ function readMarkdownFileNames(dirPath) {
88
+ if (!fs_1.default.existsSync(dirPath))
89
+ return [];
90
+ return fs_1.default
91
+ .readdirSync(dirPath, { withFileTypes: true })
92
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
93
+ .map((entry) => entry.name)
94
+ .sort((a, b) => a.localeCompare(b));
95
+ }
96
+ function parseJobStub(filePath, categoryId, categoryLabel, projectPath) {
45
97
  const content = fs_1.default.readFileSync(filePath, 'utf8');
46
98
  const fileName = path_1.default.basename(filePath, '.md');
47
99
  const intent = sectionValue(content, 'Intent')[0] || 'No intent summary available.';
48
100
  const outcome = sectionValue(content, 'Outcome');
49
101
  return {
50
102
  id: fileName,
51
- title: humanizeJobName(fileName),
103
+ title: humanizeName(fileName),
52
104
  categoryId,
53
- categoryLabel: EMPLOYEE_CATEGORIES[categoryId].label,
105
+ categoryLabel,
54
106
  intent,
55
107
  outcome,
56
108
  stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
57
109
  };
58
110
  }
59
- function parseManagerStub(filePath, groupId, projectPath) {
111
+ function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
60
112
  const content = fs_1.default.readFileSync(filePath, 'utf8');
61
113
  const fileName = path_1.default.basename(filePath, '.md');
62
114
  const intent = sectionValue(content, 'Intent')[0] || 'No intent summary available.';
63
115
  return {
64
116
  id: fileName,
65
- title: humanizeJobName(fileName),
117
+ title: humanizeName(fileName),
66
118
  groupId,
67
- groupLabel: MANAGER_GROUPS[groupId],
119
+ groupLabel,
68
120
  intent,
69
121
  stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
70
122
  };
71
123
  }
72
- const readMarkdownFiles = (dirPath) => {
73
- if (!fs_1.default.existsSync(dirPath))
74
- return [];
75
- return fs_1.default
76
- .readdirSync(dirPath, { withFileTypes: true })
77
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
78
- .map((entry) => path_1.default.join(dirPath, entry.name))
79
- .sort((a, b) => a.localeCompare(b));
80
- };
81
124
  function summarizeProject(projectPath) {
82
125
  if (!projectPath) {
83
126
  return {
@@ -96,12 +139,16 @@ function summarizeProject(projectPath) {
96
139
  };
97
140
  }
98
141
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
99
- if (!fs_1.default.existsSync(fraimDir)) {
142
+ const registryEmployeeDir = path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee');
143
+ const registryManagerDir = path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager');
144
+ const hasFraim = fs_1.default.existsSync(fraimDir);
145
+ const hasRegistry = fs_1.default.existsSync(registryEmployeeDir) || fs_1.default.existsSync(registryManagerDir);
146
+ if (!hasFraim && !hasRegistry) {
100
147
  return {
101
148
  path: projectPath,
102
149
  exists: true,
103
150
  hasFraim: false,
104
- message: 'This folder does not contain a local fraim/ directory.',
151
+ message: 'This folder does not contain a local fraim/ or registry/jobs/ directory.',
105
152
  };
106
153
  }
107
154
  return {
@@ -110,22 +157,211 @@ function summarizeProject(projectPath) {
110
157
  hasFraim: true,
111
158
  };
112
159
  }
160
+ function resolveLayerRoot(projectPath, layer) {
161
+ if (layer.base === 'registry') {
162
+ // The registry is the FRAIM source-of-truth catalog. It lives at the
163
+ // project root in the FRAIM source repo (this repo) and in any project
164
+ // that ships dist/registry alongside its source. Not under fraim/.
165
+ return path_1.default.join(projectPath, 'registry', ...layer.segments);
166
+ }
167
+ // 'fraim' layers live inside <projectRoot>/fraim/.
168
+ return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
169
+ }
170
+ function discoverLayers(projectPath, layers) {
171
+ const out = [];
172
+ for (const layer of layers) {
173
+ const layerRoot = resolveLayerRoot(projectPath, layer);
174
+ for (const categoryName of readSubdirectoryNames(layerRoot)) {
175
+ out.push({
176
+ layerRoot,
177
+ categoryId: categoryName,
178
+ categoryDir: path_1.default.join(layerRoot, categoryName),
179
+ });
180
+ }
181
+ }
182
+ return out;
183
+ }
113
184
  function discoverEmployeeJobs(projectPath) {
114
185
  const project = summarizeProject(projectPath);
115
186
  if (!project.exists || !project.hasFraim)
116
187
  return [];
117
- const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
118
- return Object.keys(EMPLOYEE_CATEGORIES)
119
- .flatMap((categoryId) => readMarkdownFiles(path_1.default.join(fraimDir, 'ai-employee', 'jobs', categoryId)).map((filePath) => parseJobStub(filePath, categoryId, projectPath)));
188
+ const layers = discoverLayers(projectPath, EMPLOYEE_JOB_LAYERS);
189
+ // Group by categoryId so all layers contribute to the same labelled category.
190
+ const jobsByKey = new Map();
191
+ for (const layer of layers) {
192
+ const categoryLabel = humanizeName(layer.categoryId);
193
+ for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
194
+ const filePath = path_1.default.join(layer.categoryDir, fileName);
195
+ const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath);
196
+ // Later layers override earlier layers on {category, jobId} collision —
197
+ // personalized-employee wins over the synced ai-employee baseline.
198
+ jobsByKey.set(`${job.categoryId}::${job.id}`, job);
199
+ }
200
+ }
201
+ // Stable sort: category label, then job title.
202
+ return [...jobsByKey.values()].sort((a, b) => {
203
+ if (a.categoryLabel !== b.categoryLabel)
204
+ return a.categoryLabel.localeCompare(b.categoryLabel);
205
+ return a.title.localeCompare(b.title);
206
+ });
120
207
  }
121
208
  function discoverManagerTemplates(projectPath) {
122
209
  const project = summarizeProject(projectPath);
123
210
  if (!project.exists || !project.hasFraim)
124
211
  return [];
125
- const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
126
- return Object.keys(MANAGER_GROUPS)
127
- .flatMap((groupId) => readMarkdownFiles(path_1.default.join(fraimDir, 'ai-manager', 'jobs', groupId)).map((filePath) => parseManagerStub(filePath, groupId, projectPath)));
212
+ const layers = discoverLayers(projectPath, MANAGER_JOB_LAYERS);
213
+ const templatesByKey = new Map();
214
+ for (const layer of layers) {
215
+ const groupLabel = humanizeName(layer.categoryId);
216
+ for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
217
+ const filePath = path_1.default.join(layer.categoryDir, fileName);
218
+ const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath);
219
+ templatesByKey.set(`${template.groupId}::${template.id}`, template);
220
+ }
221
+ }
222
+ return [...templatesByKey.values()].sort((a, b) => {
223
+ if (a.groupLabel !== b.groupLabel)
224
+ return a.groupLabel.localeCompare(b.groupLabel);
225
+ return a.title.localeCompare(b.title);
226
+ });
128
227
  }
129
- function getAiHubCategories() {
130
- return Object.values(EMPLOYEE_CATEGORIES);
228
+ // Issue #347 — load the phase list for a job, in declaration order, with
229
+ // only the phases reachable on the active run's path. The Hub UI uses
230
+ // this to render the pizza tracker.
231
+ // Namespace prefixes the friendly-label formatter strips. Order matters
232
+ // only for tie-breaking; we always pick the longest match.
233
+ const KNOWN_PHASE_PREFIXES = [
234
+ 'implement-',
235
+ 'address-',
236
+ 'spec-',
237
+ 'design-',
238
+ 'context-',
239
+ ];
240
+ function friendlyPhaseLabel(phaseId, override) {
241
+ if (override)
242
+ return override;
243
+ let body = phaseId;
244
+ for (const prefix of KNOWN_PHASE_PREFIXES) {
245
+ if (body.startsWith(prefix) && body.length > prefix.length) {
246
+ body = body.slice(prefix.length);
247
+ break;
248
+ }
249
+ }
250
+ const spaced = body.replace(/-/g, ' ').trim();
251
+ if (spaced.length === 0)
252
+ return phaseId;
253
+ // Spec R1.2: first letter uppercase, rest lowercase.
254
+ return spaced[0].toUpperCase() + spaced.slice(1).toLowerCase();
255
+ }
256
+ function readJobFrontmatter(filePath) {
257
+ const raw = fs_1.default.readFileSync(filePath, 'utf8');
258
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
259
+ if (!match)
260
+ return null;
261
+ const block = match[1].trim();
262
+ if (!block.startsWith('{'))
263
+ return null;
264
+ try {
265
+ return JSON.parse(block);
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ function findJobStubPath(projectPath, jobId) {
272
+ // Walk the same layer set used by discoverEmployeeJobs (employee layers
273
+ // are first-class jobs; manager layers are templates and don't carry
274
+ // multi-phase frontmatter). Personalized layer wins over baseline.
275
+ const layers = discoverLayers(projectPath, EMPLOYEE_JOB_LAYERS);
276
+ let resolved = null;
277
+ for (const layer of layers) {
278
+ const candidate = path_1.default.join(layer.categoryDir, `${jobId}.md`);
279
+ if (fs_1.default.existsSync(candidate)) {
280
+ resolved = candidate; // do not break — later layers override
281
+ }
282
+ }
283
+ return resolved;
284
+ }
285
+ // Resolve a phase's `onSuccess` edge to the next phase id given the run's
286
+ // discriminant. Returns null when the edge is absent or terminal.
287
+ function nextPhase(edge, discriminant) {
288
+ if (edge == null)
289
+ return null;
290
+ if (typeof edge === 'string')
291
+ return edge;
292
+ if (typeof edge === 'object') {
293
+ if (typeof edge[discriminant] === 'string')
294
+ return edge[discriminant];
295
+ if (typeof edge.default === 'string')
296
+ return edge.default;
297
+ }
298
+ return null;
299
+ }
300
+ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
301
+ const stubPath = findJobStubPath(projectPath, jobId);
302
+ if (!stubPath)
303
+ return [];
304
+ const fm = readJobFrontmatter(stubPath);
305
+ if (!fm || !fm.initialPhase || !fm.phases)
306
+ return [];
307
+ const visited = new Set();
308
+ const ordered = [];
309
+ let cursor = fm.initialPhase;
310
+ // Walk onSuccess edges to compute the path; guard against cycles by
311
+ // tracking visited phase ids. A phase can only appear once in the path.
312
+ while (cursor && !visited.has(cursor)) {
313
+ visited.add(cursor);
314
+ ordered.push(cursor);
315
+ const phaseDef = fm.phases[cursor];
316
+ if (!phaseDef)
317
+ break;
318
+ cursor = nextPhase(phaseDef.onSuccess, discriminant);
319
+ }
320
+ const labels = fm.phaseLabels || {};
321
+ return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
322
+ }
323
+ // Issue #347 — return the set of ALL phase ids declared in the job's
324
+ // frontmatter (not just the onSuccess-reachable path). Used by
325
+ // deriveStages to vet whether a visited phase id genuinely belongs to
326
+ // this job before appending it to the tracker, so cross-job pollution
327
+ // (agent calling seekMentoring for a different job mid-run) cannot
328
+ // surface stages from elsewhere.
329
+ function loadAllJobPhaseIds(jobId, projectPath) {
330
+ const stubPath = findJobStubPath(projectPath, jobId);
331
+ if (!stubPath)
332
+ return new Set();
333
+ const fm = readJobFrontmatter(stubPath);
334
+ if (!fm || !fm.phases)
335
+ return new Set();
336
+ return new Set(Object.keys(fm.phases));
337
+ }
338
+ // Issue #347 — public exposure of the friendly-label rule so callers
339
+ // outside catalog.ts can render labels for phases that exist in the
340
+ // frontmatter but weren't on the reachable path returned by loadJobPhases.
341
+ function labelForPhaseId(phaseId, jobId, projectPath) {
342
+ const stubPath = findJobStubPath(projectPath, jobId);
343
+ if (stubPath) {
344
+ const fm = readJobFrontmatter(stubPath);
345
+ if (fm?.phaseLabels?.[phaseId])
346
+ return fm.phaseLabels[phaseId];
347
+ }
348
+ return friendlyPhaseLabel(phaseId);
349
+ }
350
+ function getAiHubCategories(projectPath) {
351
+ const project = summarizeProject(projectPath);
352
+ if (!project.exists || !project.hasFraim)
353
+ return [];
354
+ // A category is any directory found at any layer; deduplicate by id.
355
+ const layers = discoverLayers(projectPath, EMPLOYEE_JOB_LAYERS);
356
+ const seen = new Map();
357
+ for (const layer of layers) {
358
+ if (!seen.has(layer.categoryId)) {
359
+ seen.set(layer.categoryId, {
360
+ id: layer.categoryId,
361
+ label: humanizeName(layer.categoryId),
362
+ description: `${humanizeName(layer.categoryId)} jobs discovered in this project.`,
363
+ });
364
+ }
365
+ }
366
+ return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label));
131
367
  }
@@ -48,7 +48,7 @@ function stopServerOnce() {
48
48
  async function createWindow(url) {
49
49
  const { width, height } = preferredWindowSize();
50
50
  mainWindow = new electron_1.BrowserWindow({
51
- title: 'Visa AI Hub',
51
+ title: 'AI Hub',
52
52
  width,
53
53
  height,
54
54
  minWidth: 1400,
@@ -86,7 +86,7 @@ async function launchDesktopShell(options) {
86
86
  async function bootstrap() {
87
87
  const options = parseArgs(process.argv.slice(2));
88
88
  await electron_1.app.whenReady();
89
- electron_1.app.setName('Visa AI Hub');
89
+ electron_1.app.setName('AI Hub');
90
90
  electron_1.app.on('activate', () => {
91
91
  if (electron_1.BrowserWindow.getAllWindows().length === 0) {
92
92
  void launchDesktopShell(options);