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.
- package/bin/fraim.js +1 -1
- package/dist/src/ai-hub/catalog.js +280 -44
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/ai-hub/hosts.js +384 -10
- package/dist/src/ai-hub/server.js +255 -9
- package/dist/src/cli/commands/add-ide.js +4 -3
- package/dist/src/cli/commands/first-run.js +61 -0
- package/dist/src/cli/commands/hub.js +4 -4
- package/dist/src/cli/commands/init-project.js +4 -4
- package/dist/src/cli/commands/setup.js +4 -3
- package/dist/src/cli/commands/sync.js +21 -2
- package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +29 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
- package/dist/src/cli/setup/ide-detector.js +32 -1
- package/dist/src/cli/setup/ide-global-integration.js +5 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +12 -2
- package/dist/src/cli/utils/project-bootstrap.js +4 -3
- package/dist/src/core/quality-evidence.js +81 -8
- package/dist/src/core/utils/git-utils.js +32 -7
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/workflow-parser.js +3 -5
- package/dist/src/first-run/install-state.js +68 -0
- package/dist/src/first-run/server.js +153 -0
- package/dist/src/first-run/session-service.js +302 -0
- package/dist/src/first-run/types.js +40 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
- package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
- package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
- package/dist/src/local-mcp-server/stdio-server.js +70 -17
- package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
- package/dist/src/local-mcp-server/usage-collector.js +25 -0
- package/index.js +83 -83
- package/package.json +7 -1
- package/public/ai-hub/index.html +149 -102
- package/public/ai-hub/script.js +1154 -271
- package/public/ai-hub/styles.css +753 -450
- package/public/first-run/index.html +221 -0
- package/public/first-run/script.js +361 -0
- package/dist/src/cli/services/device-flow-service.js +0 -83
- package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
package/bin/fraim.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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:
|
|
103
|
+
title: humanizeName(fileName),
|
|
52
104
|
categoryId,
|
|
53
|
-
categoryLabel
|
|
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:
|
|
117
|
+
title: humanizeName(fileName),
|
|
66
118
|
groupId,
|
|
67
|
-
groupLabel
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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: '
|
|
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('
|
|
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);
|