fraim-hub 2.0.276 → 2.0.277
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/catalog.js +33 -35
- package/dist/src/ai-hub/hosts.js +104 -4
- package/dist/src/ai-hub/manager-turns.js +12 -0
- package/dist/src/ai-hub/server.js +81 -45
- package/dist/src/cli/utils/pack-home.js +40 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +19 -0
- package/public/ai-hub/script.js +183 -2
- package/public/ai-hub/styles.css +77 -0
|
@@ -15,6 +15,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
15
15
|
const path_1 = __importDefault(require("path"));
|
|
16
16
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
17
|
const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
|
|
18
|
+
const pack_home_1 = require("../cli/utils/pack-home");
|
|
18
19
|
// Directories scanned for employee jobs at runtime, in lowest-to-highest
|
|
19
20
|
// precedence order. Later entries win on {categoryId, jobId} collision.
|
|
20
21
|
//
|
|
@@ -24,27 +25,12 @@ const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
|
|
|
24
25
|
// - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
|
|
25
26
|
// - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
|
|
26
27
|
// Only the personalized-employee layer is "personalized" (issue #566).
|
|
27
|
-
// Issue #1002: the machine-level layers are added here so a capability authored
|
|
28
|
-
// at the manager level is DISCOVERABLE, not merely resolvable by exact name.
|
|
29
|
-
// Order matters and mirrors LocalRegistryResolver.resolveFile precedence
|
|
30
|
-
// inverted, because later entries win on a {categoryId, jobId} collision:
|
|
31
|
-
// synced baseline < company cache < manager synced cache < manager local < project
|
|
32
|
-
// A job that resolves from the manager level must therefore also DISPLAY as the
|
|
33
|
-
// manager's, otherwise the rail would show a baseline job the runtime never runs.
|
|
34
28
|
const EMPLOYEE_JOB_LAYERS = [
|
|
35
29
|
{ segments: ['ai-employee', 'jobs'], personalized: false },
|
|
36
|
-
{ base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
|
|
37
|
-
{ base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
|
|
38
|
-
{ base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
|
|
39
|
-
{ segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
|
|
40
30
|
];
|
|
41
31
|
// Manager templates use the matching layer model.
|
|
42
32
|
const MANAGER_JOB_LAYERS = [
|
|
43
33
|
{ segments: ['ai-manager', 'jobs'], personalized: false },
|
|
44
|
-
{ base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
|
|
45
|
-
{ base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
46
|
-
{ base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
47
|
-
{ segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
|
|
48
34
|
];
|
|
49
35
|
const REGISTRY_EMPLOYEE_JOB_LAYERS = [
|
|
50
36
|
{ base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
|
|
@@ -108,7 +94,6 @@ function resolveExtendedStubPath(projectPath, extendsValue) {
|
|
|
108
94
|
path_1.default.join(projectPath, 'fraim', 'ai-manager', 'jobs'),
|
|
109
95
|
path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee'),
|
|
110
96
|
path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager'),
|
|
111
|
-
path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-employee', 'jobs'),
|
|
112
97
|
];
|
|
113
98
|
for (const root of candidateRoots) {
|
|
114
99
|
const candidate = path_1.default.join(root, `${relative}.md`);
|
|
@@ -185,13 +170,13 @@ function readMarkdownFileNames(dirPath) {
|
|
|
185
170
|
* as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
|
|
186
171
|
* which is what path.relative would otherwise produce for a user-level layer.
|
|
187
172
|
*/
|
|
188
|
-
function stubDisplayPath(filePath, projectPath,
|
|
189
|
-
if (!
|
|
173
|
+
function stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix) {
|
|
174
|
+
if (!displayRoot || !displayPrefix)
|
|
190
175
|
return toPosix(path_1.default.relative(projectPath, filePath));
|
|
191
|
-
const rel = toPosix(path_1.default.relative(
|
|
192
|
-
return (
|
|
176
|
+
const rel = toPosix(path_1.default.relative(displayRoot, filePath));
|
|
177
|
+
return rel ? `${displayPrefix.replace(/\/$/, '')}/${rel}` : displayPrefix;
|
|
193
178
|
}
|
|
194
|
-
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope,
|
|
179
|
+
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, displayRoot, displayPrefix) {
|
|
195
180
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
196
181
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
197
182
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -206,12 +191,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
|
|
|
206
191
|
categoryLabel,
|
|
207
192
|
intent,
|
|
208
193
|
outcome,
|
|
209
|
-
stubPath: stubDisplayPath(filePath, projectPath,
|
|
194
|
+
stubPath: stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix),
|
|
210
195
|
personalized: !!personalized,
|
|
211
196
|
...(scope ? { scope } : {}),
|
|
212
197
|
};
|
|
213
198
|
}
|
|
214
|
-
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope,
|
|
199
|
+
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, displayRoot, displayPrefix) {
|
|
215
200
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
216
201
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
217
202
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -223,7 +208,7 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, use
|
|
|
223
208
|
groupId,
|
|
224
209
|
groupLabel,
|
|
225
210
|
intent,
|
|
226
|
-
stubPath: stubDisplayPath(filePath, projectPath,
|
|
211
|
+
stubPath: stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix),
|
|
227
212
|
...(scope ? { scope } : {}),
|
|
228
213
|
};
|
|
229
214
|
}
|
|
@@ -262,10 +247,10 @@ function summarizeProject(projectPath) {
|
|
|
262
247
|
};
|
|
263
248
|
}
|
|
264
249
|
function resolveLayerRoot(projectPath, layer) {
|
|
250
|
+
if (layer.root)
|
|
251
|
+
return layer.root;
|
|
265
252
|
if (layer.base === 'project')
|
|
266
253
|
return path_1.default.join(projectPath, ...layer.segments);
|
|
267
|
-
if (layer.base === 'user')
|
|
268
|
-
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
|
|
269
254
|
return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
|
|
270
255
|
}
|
|
271
256
|
function discoverLayers(projectPath, layers) {
|
|
@@ -279,26 +264,37 @@ function discoverLayers(projectPath, layers) {
|
|
|
279
264
|
categoryDir: path_1.default.join(layerRoot, categoryName),
|
|
280
265
|
personalized: layer.personalized,
|
|
281
266
|
scope: layer.scope,
|
|
282
|
-
|
|
267
|
+
displayRoot: layer.displayRoot,
|
|
268
|
+
displayPrefix: layer.displayPrefix,
|
|
283
269
|
});
|
|
284
270
|
}
|
|
285
271
|
}
|
|
286
272
|
return out;
|
|
287
273
|
}
|
|
274
|
+
function personalizedLayerSegments(projectPath, capabilityDir) {
|
|
275
|
+
return (0, pack_home_1.personalizedCapabilityReadRoots)(projectPath, capabilityDir).map((root) => ({
|
|
276
|
+
root: root.capabilityRoot,
|
|
277
|
+
segments: [],
|
|
278
|
+
personalized: true,
|
|
279
|
+
scope: root.scope,
|
|
280
|
+
displayRoot: root.displayRoot,
|
|
281
|
+
displayPrefix: root.displayPrefix,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
288
284
|
function discoverEmployeeJobs(projectPath, options = {}) {
|
|
289
285
|
const project = summarizeProject(projectPath);
|
|
290
286
|
if (!project.exists || (!project.hasFraim && !options.includeRegistry))
|
|
291
287
|
return [];
|
|
292
288
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
293
|
-
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
|
|
294
|
-
: EMPLOYEE_JOB_LAYERS);
|
|
289
|
+
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]
|
|
290
|
+
: [...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]);
|
|
295
291
|
// Group by categoryId so all layers contribute to the same labelled category.
|
|
296
292
|
const jobsByKey = new Map();
|
|
297
293
|
for (const layer of layers) {
|
|
298
294
|
const categoryLabel = humanizeName(layer.categoryId);
|
|
299
295
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
300
296
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
301
|
-
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.
|
|
297
|
+
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.displayRoot, layer.displayPrefix);
|
|
302
298
|
// Later layers override earlier layers on {category, jobId} collision —
|
|
303
299
|
// personalized-employee wins over the synced ai-employee baseline. The
|
|
304
300
|
// winning job carries its own layer's `personalized` flag (issue #566).
|
|
@@ -317,14 +313,14 @@ function discoverManagerTemplates(projectPath, options = {}) {
|
|
|
317
313
|
if (!project.exists || (!project.hasFraim && !options.includeRegistry))
|
|
318
314
|
return [];
|
|
319
315
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
320
|
-
? [...REGISTRY_MANAGER_JOB_LAYERS, ...MANAGER_JOB_LAYERS]
|
|
321
|
-
: MANAGER_JOB_LAYERS);
|
|
316
|
+
? [...REGISTRY_MANAGER_JOB_LAYERS, ...MANAGER_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'manager-jobs')]
|
|
317
|
+
: [...MANAGER_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'manager-jobs')]);
|
|
322
318
|
const templatesByKey = new Map();
|
|
323
319
|
for (const layer of layers) {
|
|
324
320
|
const groupLabel = humanizeName(layer.categoryId);
|
|
325
321
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
326
322
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
327
|
-
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.
|
|
323
|
+
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.displayRoot, layer.displayPrefix);
|
|
328
324
|
templatesByKey.set(`${template.groupId}::${template.id}`, template);
|
|
329
325
|
}
|
|
330
326
|
}
|
|
@@ -407,6 +403,8 @@ function findJobStubPath(projectPath, jobId) {
|
|
|
407
403
|
const layers = discoverLayers(projectPath, [
|
|
408
404
|
...EMPLOYEE_JOB_LAYERS,
|
|
409
405
|
...MANAGER_JOB_LAYERS,
|
|
406
|
+
...personalizedLayerSegments(projectPath, 'jobs'),
|
|
407
|
+
...personalizedLayerSegments(projectPath, 'manager-jobs'),
|
|
410
408
|
]);
|
|
411
409
|
let resolved = null;
|
|
412
410
|
for (const layer of layers) {
|
|
@@ -520,8 +518,8 @@ function getAiHubCategories(projectPath, options = {}) {
|
|
|
520
518
|
return [];
|
|
521
519
|
// A category is any directory found at any layer; deduplicate by id.
|
|
522
520
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
523
|
-
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
|
|
524
|
-
: EMPLOYEE_JOB_LAYERS);
|
|
521
|
+
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]
|
|
522
|
+
: [...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]);
|
|
525
523
|
const seen = new Map();
|
|
526
524
|
for (const layer of layers) {
|
|
527
525
|
if (!seen.has(layer.categoryId)) {
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -47,7 +47,10 @@ const pack_home_1 = require("../cli/utils/pack-home");
|
|
|
47
47
|
// whose item.type === 'mcp_tool_call' and item.tool === 'seekMentoring'
|
|
48
48
|
// (Codex separates the MCP server name from the tool name).
|
|
49
49
|
function parseSeekMentoringSignal(line) {
|
|
50
|
-
|
|
50
|
+
// Issue #1264: case-insensitive so a host that flattens the tool name to
|
|
51
|
+
// all-lowercase (e.g. "fraim_seekmentoring") still reaches the JSON parse
|
|
52
|
+
// and isFraimTool checks below instead of being dropped by this fast gate.
|
|
53
|
+
if (!line.toLowerCase().includes('seekmentoring'))
|
|
51
54
|
return null;
|
|
52
55
|
let parsed;
|
|
53
56
|
try {
|
|
@@ -301,6 +304,68 @@ function parseAgentIdentitySignal(line) {
|
|
|
301
304
|
}
|
|
302
305
|
return null;
|
|
303
306
|
}
|
|
307
|
+
// Issue #1264 (R3): defense-in-depth diagnostic. A tool call that carries a
|
|
308
|
+
// submit-phase reviewHandoff (reviewRequired: true) under a name isFraimTool
|
|
309
|
+
// cannot resolve to 'seekMentoring' — even after the flattened-lowercase
|
|
310
|
+
// matching above — means the Hub is about to silently drop a real review
|
|
311
|
+
// handoff, the #1264/#1265 failure mode for a naming shape not yet
|
|
312
|
+
// anticipated. Returns a human-readable diagnostic surfaced via the existing
|
|
313
|
+
// `hostError` channel (already rendered, non-droppable) instead of inventing
|
|
314
|
+
// new UI. Scoped to lines that already carry a `reviewHandoff` key so it
|
|
315
|
+
// never fires for submits that legitimately have none.
|
|
316
|
+
function detectUnrecognizedReviewHandoffCall(line) {
|
|
317
|
+
if (!line.toLowerCase().includes('reviewhandoff'))
|
|
318
|
+
return null;
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
parsed = JSON.parse(line);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
327
|
+
return null;
|
|
328
|
+
const obj = parsed;
|
|
329
|
+
const check = (rawName, rawArgs) => {
|
|
330
|
+
if (isFraimTool(rawName, 'seekMentoring'))
|
|
331
|
+
return null;
|
|
332
|
+
const args = normalizeToolArgs(rawArgs);
|
|
333
|
+
if (!args)
|
|
334
|
+
return null;
|
|
335
|
+
const reviewHandoff = extractReviewHandoffFromArgs(args);
|
|
336
|
+
if (!reviewHandoff || !reviewHandoff.reviewRequired)
|
|
337
|
+
return null;
|
|
338
|
+
const name = typeof rawName === 'string' ? rawName : String(rawName);
|
|
339
|
+
return `FRAIM review handoff detected in host output under an unrecognized tool name ("${name}") — the Hub could not project it onto this conversation. File a FRAIM issue naming this exact tool name so recognition can be extended.`;
|
|
340
|
+
};
|
|
341
|
+
if ((obj.type === 'item.started' || obj.type === 'item.completed') && typeof obj.item === 'object' && obj.item !== null) {
|
|
342
|
+
const item = obj.item;
|
|
343
|
+
if (item.type === 'mcp_tool_call') {
|
|
344
|
+
const hit = check(item.tool, item.arguments);
|
|
345
|
+
if (hit)
|
|
346
|
+
return hit;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const candidates = [obj];
|
|
350
|
+
if (Array.isArray(obj.content))
|
|
351
|
+
candidates.push(...obj.content);
|
|
352
|
+
if (typeof obj.message === 'object' && obj.message !== null) {
|
|
353
|
+
const msg = obj.message;
|
|
354
|
+
if (Array.isArray(msg.content))
|
|
355
|
+
candidates.push(...msg.content);
|
|
356
|
+
}
|
|
357
|
+
for (const candidate of candidates) {
|
|
358
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
359
|
+
continue;
|
|
360
|
+
const c = candidate;
|
|
361
|
+
if (c.type !== 'tool_use' && c.type !== 'function_call')
|
|
362
|
+
continue;
|
|
363
|
+
const hit = check(readToolName(c), c.input || c.arguments || c.parameters);
|
|
364
|
+
if (hit)
|
|
365
|
+
return hit;
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
304
369
|
function readAgentFromArgs(args) {
|
|
305
370
|
if (!args || typeof args.agent !== 'object' || args.agent === null)
|
|
306
371
|
return null;
|
|
@@ -335,7 +400,27 @@ function canonicalToolName(rawName) {
|
|
|
335
400
|
return byDoubleUnderscore.split(/[./:]/).filter(Boolean).pop() || null;
|
|
336
401
|
}
|
|
337
402
|
function isFraimTool(rawName, canonicalName) {
|
|
338
|
-
|
|
403
|
+
if (canonicalToolName(rawName) === canonicalName)
|
|
404
|
+
return true;
|
|
405
|
+
return isFlattenedFraimToolName(rawName, canonicalName);
|
|
406
|
+
}
|
|
407
|
+
// Issue #1264: the "Codex Azure Script" configured agent (MCP server
|
|
408
|
+
// codex_apps) flattens tool identity into a single lowercase, separator-less
|
|
409
|
+
// token instead of a qualifier syntax canonicalToolName's __/./\//: split can
|
|
410
|
+
// find (e.g. "fraim_seekmentoring", "fraim_get_fraim_job", "fraim_fraim_connect"
|
|
411
|
+
// — confirmed verbatim in the 2026-08-21 event log). Match that exact flattened
|
|
412
|
+
// shape, plus the bare lowercase form, case-insensitively — scoped only to the
|
|
413
|
+
// three known canonical FRAIM tool identities so an unrelated tool name is
|
|
414
|
+
// never accepted (#982's case-sensitivity intent is preserved for everything
|
|
415
|
+
// that isn't a legitimate flattened/qualified form of a known FRAIM tool).
|
|
416
|
+
function isFlattenedFraimToolName(rawName, canonicalName) {
|
|
417
|
+
if (typeof rawName !== 'string')
|
|
418
|
+
return false;
|
|
419
|
+
const trimmedLower = rawName.trim().toLowerCase();
|
|
420
|
+
if (!trimmedLower)
|
|
421
|
+
return false;
|
|
422
|
+
const lowerCanonical = canonicalName.toLowerCase();
|
|
423
|
+
return trimmedLower === lowerCanonical || trimmedLower === `fraim_${lowerCanonical}`;
|
|
339
424
|
}
|
|
340
425
|
function readFraimJobFromArgs(rawArgs) {
|
|
341
426
|
const args = normalizeToolArgs(rawArgs);
|
|
@@ -1534,8 +1619,11 @@ function parseHostLine(hostId, line) {
|
|
|
1534
1619
|
const fraimJob = parseFraimJobLoadSignal(trimmed);
|
|
1535
1620
|
const usage = parseUsageSignal(trimmed);
|
|
1536
1621
|
const agentIdentity = parseAgentIdentitySignal(trimmed);
|
|
1622
|
+
// Issue #1264 (R3): only worth checking when seekMentoring itself did not
|
|
1623
|
+
// already resolve — a recognized call has nothing to diagnose.
|
|
1624
|
+
const unrecognizedReviewHandoffError = seekMentoring ? null : detectUnrecognizedReviewHandoffCall(trimmed);
|
|
1537
1625
|
const withSignal = (event) => {
|
|
1538
|
-
if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
|
|
1626
|
+
if (!seekMentoring && !fraimJob && !usage && !agentIdentity && !unrecognizedReviewHandoffError)
|
|
1539
1627
|
return event;
|
|
1540
1628
|
return {
|
|
1541
1629
|
...event,
|
|
@@ -1543,6 +1631,7 @@ function parseHostLine(hostId, line) {
|
|
|
1543
1631
|
...(fraimJob ? { fraimJob } : {}),
|
|
1544
1632
|
...(usage ? { usage } : {}),
|
|
1545
1633
|
...(agentIdentity ? { agentIdentity } : {}),
|
|
1634
|
+
...(unrecognizedReviewHandoffError && !event.hostError ? { hostError: unrecognizedReviewHandoffError } : {}),
|
|
1546
1635
|
};
|
|
1547
1636
|
};
|
|
1548
1637
|
if (hostId === 'codex') {
|
|
@@ -1655,7 +1744,7 @@ function parseHostLine(hostId, line) {
|
|
|
1655
1744
|
source: hostId,
|
|
1656
1745
|
backgroundTasks: parsed.tasks
|
|
1657
1746
|
.filter((t) => typeof t.task_id === 'string')
|
|
1658
|
-
.map((t) => ({ taskId: t.task_id, description: t.description })),
|
|
1747
|
+
.map((t) => ({ taskId: t.task_id, description: t.description, taskType: t.task_type })),
|
|
1659
1748
|
},
|
|
1660
1749
|
});
|
|
1661
1750
|
}
|
|
@@ -2209,6 +2298,17 @@ class ScriptedHostRuntime {
|
|
|
2209
2298
|
agentIdentity: { agentName, agentModel },
|
|
2210
2299
|
}, 'stdout');
|
|
2211
2300
|
}
|
|
2301
|
+
// Test API — feed a raw stdout line through the REAL parseHostLine parser
|
|
2302
|
+
// instead of a hand-built ParsedHostEvent. Issue #1264: lets a test prove
|
|
2303
|
+
// the actual tool-call-name recognition (not just the projection logic)
|
|
2304
|
+
// handles a given raw shape, e.g. a flattened-lowercase tool name.
|
|
2305
|
+
emitRawLine(runId, hostId, line) {
|
|
2306
|
+
const target = this.resolveSession(runId);
|
|
2307
|
+
if (!target)
|
|
2308
|
+
return;
|
|
2309
|
+
const parsed = parseHostLine(hostId, line);
|
|
2310
|
+
target.handlers.onEvent({ ...parsed, sessionId: target.sessionId }, 'stdout');
|
|
2311
|
+
}
|
|
2212
2312
|
// Test API — emit a message from the employee (appears in the thread as an employee bubble).
|
|
2213
2313
|
emitEmployeeMessage(runId, text) {
|
|
2214
2314
|
const target = this.resolveSession(runId);
|
|
@@ -4,6 +4,7 @@ exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
|
|
|
4
4
|
exports.fraimInvocationFor = fraimInvocationFor;
|
|
5
5
|
exports.fraimDirectiveFor = fraimDirectiveFor;
|
|
6
6
|
exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
|
|
7
|
+
exports.buildBackgroundTaskPolicyNote = buildBackgroundTaskPolicyNote;
|
|
7
8
|
exports.buildSameJobContinueMessage = buildSameJobContinueMessage;
|
|
8
9
|
exports.buildManagerMessage = buildManagerMessage;
|
|
9
10
|
function extractExplicitFraimInvocation(text) {
|
|
@@ -41,6 +42,17 @@ function buildCommunicationStyleNote() {
|
|
|
41
42
|
'[How to talk to me] In your messages to me, report ONLY on the job and its outcome — what you found, what you changed, the decisions you made, blockers, and what you need from me. Do NOT narrate the FRAIM machinery: don\'t announce that you are talking to FRAIM, asking your mentor, following the process, moving between phases, or calling tools (git, playwright, etc.). I can see the raw tool activity separately if I want it. Keep your updates short and about the work, not the process.',
|
|
42
43
|
].join('\n');
|
|
43
44
|
}
|
|
45
|
+
// #1275: Hub-injected background-task policy. Agents running in Hub headless
|
|
46
|
+
// (-p) mode must never end their turn while background tasks are active — the
|
|
47
|
+
// Hub cannot reattach to running bash processes or sub-agents after the Claude
|
|
48
|
+
// Code process exits. This note is injected at the Hub layer; it is NOT a
|
|
49
|
+
// registry rule because it only applies to Hub's non-interactive -p invocation.
|
|
50
|
+
function buildBackgroundTaskPolicyNote() {
|
|
51
|
+
return [
|
|
52
|
+
'',
|
|
53
|
+
'[Background task policy] You are running in Hub headless mode. Never end your turn while background tasks (bash commands started with run_in_background=true, or sub-agents) are still active. Instead: monitor them, poll for results, and provide periodic progress commentary to the manager. Only end your turn when all background tasks have finished, or when you genuinely need a user decision or input to continue. If you exit while a background task is active, the Hub loses the link to that task permanently.',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
44
56
|
// Issue #732: a plain continue of the SAME active job must not re-load the job.
|
|
45
57
|
// Continue turns resume the existing agent session, which already has the job
|
|
46
58
|
// loaded, so this message carries NO `/fraim <job>` invocation — the headless
|
|
@@ -51,6 +51,7 @@ const learning_context_builder_1 = require("../local-mcp-server/learning-context
|
|
|
51
51
|
const brand_store_1 = require("../core/brand-store");
|
|
52
52
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
53
53
|
const catalog_1 = require("./catalog");
|
|
54
|
+
const job_visualization_1 = require("../core/job-visualization");
|
|
54
55
|
const custom_employees_1 = require("./custom-employees");
|
|
55
56
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
56
57
|
const hosts_1 = require("./hosts");
|
|
@@ -867,30 +868,6 @@ function normalizeDelegationLedger(raw) {
|
|
|
867
868
|
tasks,
|
|
868
869
|
};
|
|
869
870
|
}
|
|
870
|
-
function extractReviewHandoffFromText(text) {
|
|
871
|
-
if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
|
|
872
|
-
return null;
|
|
873
|
-
const candidates = [];
|
|
874
|
-
for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
875
|
-
candidates.push(match[1]);
|
|
876
|
-
const tagged = String(text).match(/<review_handoff>\s*([\s\S]*?)\s*<\/review_handoff>/i);
|
|
877
|
-
if (tagged)
|
|
878
|
-
candidates.push(tagged[1]);
|
|
879
|
-
const inline = String(text).match(/(\{\s*"reviewRequired"[\s\S]*\})/i);
|
|
880
|
-
if (inline)
|
|
881
|
-
candidates.push(inline[1]);
|
|
882
|
-
for (const candidate of candidates) {
|
|
883
|
-
try {
|
|
884
|
-
const handoff = normalizeReviewHandoff(JSON.parse(candidate.trim()));
|
|
885
|
-
if (handoff)
|
|
886
|
-
return handoff;
|
|
887
|
-
}
|
|
888
|
-
catch {
|
|
889
|
-
// Malformed snippets are ignored; the UI can still surface legacy fallback state.
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
return null;
|
|
893
|
-
}
|
|
894
871
|
function extractDelegationLedgerFromText(text) {
|
|
895
872
|
if (!text || !/delegationRequired|delegation_ledger|delegationLedger|"goal"\s*:.*"groups"\s*:/is.test(text))
|
|
896
873
|
return null;
|
|
@@ -915,7 +892,14 @@ function extractDelegationLedgerFromText(text) {
|
|
|
915
892
|
}
|
|
916
893
|
return null;
|
|
917
894
|
}
|
|
918
|
-
|
|
895
|
+
// Issue #1264: review handoffs project ONLY through the structured seekMentoring
|
|
896
|
+
// evidence channel (applySeekMentoringSignal) now — a prose-embedded
|
|
897
|
+
// <review_handoff> tag or bare JSON in an agent's text reply is no longer a
|
|
898
|
+
// recognized review-signal channel, so it is not extracted or applied here.
|
|
899
|
+
// Delegation ledgers still use this text-embedded channel because the
|
|
900
|
+
// fully-delegate job's registry instructions (delegation-graph-planning.md)
|
|
901
|
+
// have not yet been migrated to the structured evidence.delegationLedger path.
|
|
902
|
+
function applyDelegationLedgerProjection(run, text) {
|
|
919
903
|
const delegation = extractDelegationLedgerFromText(text);
|
|
920
904
|
if (delegation) {
|
|
921
905
|
run.delegation = {
|
|
@@ -924,12 +908,6 @@ function applyReviewProjection(run, text) {
|
|
|
924
908
|
managerRunId: delegation.managerRunId || run.id,
|
|
925
909
|
};
|
|
926
910
|
}
|
|
927
|
-
const handoff = extractReviewHandoffFromText(text);
|
|
928
|
-
if (handoff) {
|
|
929
|
-
run.reviewHandoff = handoff;
|
|
930
|
-
run.artifacts = handoff.reviewTarget?.type === 'artifact_set' ? handoff.artifacts : [];
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
911
|
}
|
|
934
912
|
function emptyTotals() {
|
|
935
913
|
return {
|
|
@@ -1226,13 +1204,13 @@ function stripStructuredHostPayloads(text) {
|
|
|
1226
1204
|
function appendHostMessage(run, hostId, event, channel) {
|
|
1227
1205
|
if (!event.message || channel !== 'stdout')
|
|
1228
1206
|
return;
|
|
1229
|
-
|
|
1207
|
+
applyDelegationLedgerProjection(run, event.message);
|
|
1230
1208
|
if (hostId === 'gemini') {
|
|
1231
1209
|
const last = run.messages[run.messages.length - 1];
|
|
1232
1210
|
if (last?.role === 'employee') {
|
|
1233
1211
|
last.text = `${last.text}\n${event.message}`;
|
|
1234
1212
|
last.createdAt = new Date().toISOString();
|
|
1235
|
-
|
|
1213
|
+
applyDelegationLedgerProjection(run, last.text);
|
|
1236
1214
|
last.text = stripStructuredHostPayloads(last.text);
|
|
1237
1215
|
if (!last.text)
|
|
1238
1216
|
run.messages.pop();
|
|
@@ -1890,6 +1868,22 @@ function buildHubCompactionRecoveryContinueMessage(run, exitCode, attempt) {
|
|
|
1890
1868
|
function createHubCompactionRecoveryEvent(run, exitCode, attempt) {
|
|
1891
1869
|
return (0, hosts_1.createHubEvent)('system', `Hub compaction recovery attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS} for run ${run.id} session ${run.sessionId || 'unknown'} after exit ${exitCode ?? 'unknown'}.${hostErrorSuffix(run)}`);
|
|
1892
1870
|
}
|
|
1871
|
+
// #1275: safety-net auto-resume when the agent exited with an active local_bash
|
|
1872
|
+
// task. recoveryAttempts is NOT incremented — this is not an error recovery.
|
|
1873
|
+
function createHubBackgroundTaskContinueEvent(run) {
|
|
1874
|
+
return (0, hosts_1.createHubEvent)('system', `Hub background-task continuation for run ${run.id} session ${run.sessionId || 'unknown'} — agent exited while a local_bash task was active.${hostErrorSuffix(run)}`);
|
|
1875
|
+
}
|
|
1876
|
+
function buildHubBackgroundTaskContinueMessage(run) {
|
|
1877
|
+
return [
|
|
1878
|
+
'[FRAIM Hub system recovery]',
|
|
1879
|
+
'Your previous turn started a background bash task and then ended the turn before the task completed.',
|
|
1880
|
+
'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
|
|
1881
|
+
`Run id: ${run.id}`,
|
|
1882
|
+
`Session id: ${run.sessionId || 'unknown'}`,
|
|
1883
|
+
'The background bash task may still be running as an orphan process. Check whether its results are available on disk and continue your work.',
|
|
1884
|
+
'If results are not yet available, wait briefly or check again. Once you have the results, continue with your original plan.',
|
|
1885
|
+
].join('\n');
|
|
1886
|
+
}
|
|
1893
1887
|
function isHumanActionGate(run) {
|
|
1894
1888
|
if (run.stoppedByUser)
|
|
1895
1889
|
return true;
|
|
@@ -1899,15 +1893,28 @@ function isHumanActionGate(run) {
|
|
|
1899
1893
|
const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
|
|
1900
1894
|
return lastEntry?.latestStatus === 'incomplete' || lastEntry?.latestStatus === 'failure';
|
|
1901
1895
|
}
|
|
1896
|
+
// #1275: true when the host reported an active local_bash task. These are
|
|
1897
|
+
// intentional async continuations — the agent ended its turn knowing bash was
|
|
1898
|
+
// running, intending to check results in the next turn. In -p mode Claude Code
|
|
1899
|
+
// exits before bash finishes, so task_updated is never emitted. Hub should
|
|
1900
|
+
// auto-resume (safety net) rather than error.
|
|
1901
|
+
function hasActiveLocalBashTask(run) {
|
|
1902
|
+
const tasks = run.hostLifecycle?.backgroundTasks;
|
|
1903
|
+
if (!tasks)
|
|
1904
|
+
return false;
|
|
1905
|
+
return Object.values(tasks).some((task) => task.status === 'active' && task.taskType === 'local_bash');
|
|
1906
|
+
}
|
|
1902
1907
|
// Issue #1234: true when the host reported a background task as active
|
|
1903
1908
|
// (`background_tasks_changed`) and it either never resolved before the host's
|
|
1904
1909
|
// child process exited, or resolved as `killed` rather than `completed`. Both
|
|
1905
1910
|
// are evidence the agent's promised follow-up did not happen.
|
|
1911
|
+
// #1275: tightened — local_bash active tasks are handled by hasActiveLocalBashTask above.
|
|
1906
1912
|
function hasUnresolvedBackgroundTask(run) {
|
|
1907
1913
|
const tasks = run.hostLifecycle?.backgroundTasks;
|
|
1908
1914
|
if (!tasks)
|
|
1909
1915
|
return false;
|
|
1910
|
-
return Object.values(tasks).some((task) => task.status === 'active'
|
|
1916
|
+
return Object.values(tasks).some((task) => (task.status === 'active' && task.taskType !== 'local_bash') ||
|
|
1917
|
+
task.status === 'killed');
|
|
1911
1918
|
}
|
|
1912
1919
|
function describeUnresolvedBackgroundTasks(run) {
|
|
1913
1920
|
const descriptions = Object.values(run.hostLifecycle?.backgroundTasks || {})
|
|
@@ -1955,6 +1962,9 @@ function classifyExit(run, exitCode) {
|
|
|
1955
1962
|
if (compactingActive) {
|
|
1956
1963
|
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
1957
1964
|
}
|
|
1965
|
+
if (hasActiveLocalBashTask(run)) {
|
|
1966
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task' };
|
|
1967
|
+
}
|
|
1958
1968
|
if (hasUnresolvedBackgroundTask(run)) {
|
|
1959
1969
|
return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
|
|
1960
1970
|
}
|
|
@@ -2035,6 +2045,7 @@ function applyBackgroundTasksChangedSignal(run, signal, now) {
|
|
|
2035
2045
|
const existing = existingTasks[task.taskId];
|
|
2036
2046
|
nextTasks[task.taskId] = {
|
|
2037
2047
|
description: task.description ?? existing?.description,
|
|
2048
|
+
taskType: task.taskType ?? existing?.taskType,
|
|
2038
2049
|
status: 'active',
|
|
2039
2050
|
startedAt: existing?.startedAt || now,
|
|
2040
2051
|
lastEventAt: now,
|
|
@@ -2795,7 +2806,7 @@ class AiHubServer {
|
|
|
2795
2806
|
run.lastHostError = undefined;
|
|
2796
2807
|
}
|
|
2797
2808
|
if (event.raw) {
|
|
2798
|
-
|
|
2809
|
+
applyDelegationLedgerProjection(run, event.raw);
|
|
2799
2810
|
try {
|
|
2800
2811
|
const ref = this.rawEventLogStore.append((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, run.id, { channel, text: event.raw });
|
|
2801
2812
|
run.eventLogRefs = upsertEventLogRef(run.eventLogRefs, ref);
|
|
@@ -3945,12 +3956,13 @@ class AiHubServer {
|
|
|
3945
3956
|
// available (env published at boot).
|
|
3946
3957
|
const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
3947
3958
|
const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
3959
|
+
const backgroundTaskNote = (0, manager_turns_1.buildBackgroundTaskPolicyNote)();
|
|
3948
3960
|
const ignoreEmbedded = options?.ignoreEmbeddedInvocation === true;
|
|
3949
3961
|
if (resolvedJobId === '__freeform__') {
|
|
3950
3962
|
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
|
|
3951
3963
|
return {
|
|
3952
3964
|
jobId: resolvedJobId,
|
|
3953
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
|
|
3965
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote + backgroundTaskNote,
|
|
3954
3966
|
display,
|
|
3955
3967
|
};
|
|
3956
3968
|
}
|
|
@@ -3961,7 +3973,7 @@ class AiHubServer {
|
|
|
3961
3973
|
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
|
|
3962
3974
|
return {
|
|
3963
3975
|
jobId: resolvedJobId,
|
|
3964
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { stubPath: absoluteStubPath, ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
|
|
3976
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { stubPath: absoluteStubPath, ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote + backgroundTaskNote,
|
|
3965
3977
|
display,
|
|
3966
3978
|
};
|
|
3967
3979
|
}
|
|
@@ -4015,8 +4027,8 @@ class AiHubServer {
|
|
|
4015
4027
|
// Bubble: the command form for a real switch, else the manager's own words.
|
|
4016
4028
|
const display = invocationForm ?? userText;
|
|
4017
4029
|
// Agent payload: the same command form for a switch, else a lightweight
|
|
4018
|
-
// same-job continue; the communication-style
|
|
4019
|
-
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
4030
|
+
// same-job continue; the communication-style and background-task notes are agent-only.
|
|
4031
|
+
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)() + (0, manager_turns_1.buildBackgroundTaskPolicyNote)();
|
|
4020
4032
|
return { message, display };
|
|
4021
4033
|
}
|
|
4022
4034
|
async computePersonas(apiKey, managerTeamPromise,
|
|
@@ -4346,6 +4358,17 @@ class AiHubServer {
|
|
|
4346
4358
|
const userEmail = await this.resolveHubIdentity();
|
|
4347
4359
|
return res.json(this.computeBrain(projectPath, jobCount, userEmail));
|
|
4348
4360
|
});
|
|
4361
|
+
// Issue #1278: Job visualization endpoint — phases + skills for the viz modal.
|
|
4362
|
+
this.app.get('/api/ai-hub/job/:jobId/visualization', async (req, res) => {
|
|
4363
|
+
const { jobId } = req.params;
|
|
4364
|
+
if (!jobId || !/^[a-z0-9-]+$/.test(jobId)) {
|
|
4365
|
+
return res.status(400).json({ error: 'Invalid jobId' });
|
|
4366
|
+
}
|
|
4367
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
4368
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
4369
|
+
: this.defaultProjectPath();
|
|
4370
|
+
return res.json(await (0, job_visualization_1.buildJobVisualization)(jobId, projectPath));
|
|
4371
|
+
});
|
|
4349
4372
|
// #533: read the PRESERVED learnings for a section + storage level so the
|
|
4350
4373
|
// Company/Manager sections (machine level) and the project workspace (project
|
|
4351
4374
|
// level) can DISPLAY and edit them.
|
|
@@ -6788,14 +6811,25 @@ class AiHubServer {
|
|
|
6788
6811
|
return;
|
|
6789
6812
|
const classification = classifyExit(preExit, exitCode);
|
|
6790
6813
|
if (classification.action === 'resume') {
|
|
6791
|
-
// Auto-continue: keep run in 'running'
|
|
6814
|
+
// Auto-continue: keep run in 'running'.
|
|
6815
|
+
// background_task resumes must NOT increment recoveryAttempts (the error-recovery
|
|
6816
|
+
// budget must not be consumed by normal async-bash continuations — #1275).
|
|
6817
|
+
// clearBackgroundTaskLifecycle fires before the continue so stale 'active'
|
|
6818
|
+
// entries from the dead Claude Code process don't re-trigger hasActiveLocalBashTask
|
|
6819
|
+
// on the next exit, causing an infinite loop.
|
|
6792
6820
|
this.runRegistry.update(runId, (current) => {
|
|
6793
|
-
|
|
6821
|
+
if (classification.recoveryKind === 'background_task') {
|
|
6822
|
+
clearBackgroundTaskLifecycle(current);
|
|
6823
|
+
current.events.push(createHubBackgroundTaskContinueEvent(current));
|
|
6824
|
+
}
|
|
6825
|
+
else {
|
|
6826
|
+
current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
|
|
6827
|
+
current.events.push(classification.recoveryKind === 'compaction'
|
|
6828
|
+
? createHubCompactionRecoveryEvent(current, exitCode, current.recoveryAttempts)
|
|
6829
|
+
: createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
|
|
6830
|
+
}
|
|
6794
6831
|
current.lastRecoveryAt = new Date().toISOString();
|
|
6795
6832
|
current.pauseReason = 'working';
|
|
6796
|
-
current.events.push(classification.recoveryKind === 'compaction'
|
|
6797
|
-
? createHubCompactionRecoveryEvent(current, exitCode, current.recoveryAttempts)
|
|
6798
|
-
: createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
|
|
6799
6833
|
});
|
|
6800
6834
|
const refreshed = this.runRegistry.get(runId);
|
|
6801
6835
|
if (!refreshed?.sessionId) {
|
|
@@ -6818,7 +6852,9 @@ class AiHubServer {
|
|
|
6818
6852
|
return;
|
|
6819
6853
|
const message = classification.recoveryKind === 'compaction'
|
|
6820
6854
|
? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
|
|
6821
|
-
:
|
|
6855
|
+
: classification.recoveryKind === 'background_task'
|
|
6856
|
+
? buildHubBackgroundTaskContinueMessage(current)
|
|
6857
|
+
: buildHubRecoveryContinueMessage(current, exitCode, attempt);
|
|
6822
6858
|
// Issue #1150: recovery must relaunch as the SAME configured agent. Its setup
|
|
6823
6859
|
// script supplies the env that selects the profile the host session lives in
|
|
6824
6860
|
// (CODEX_HOME for Codex), and resuming without it sends `codex exec resume`
|
|
@@ -7,6 +7,7 @@ exports.packsDir = packsDir;
|
|
|
7
7
|
exports.defaultCloneDir = defaultCloneDir;
|
|
8
8
|
exports.resolvePackHome = resolvePackHome;
|
|
9
9
|
exports.packReadRoots = packReadRoots;
|
|
10
|
+
exports.personalizedCapabilityReadRoots = personalizedCapabilityReadRoots;
|
|
10
11
|
exports.migrateStrandedContent = migrateStrandedContent;
|
|
11
12
|
exports.findStrandedLegacyContent = findStrandedLegacyContent;
|
|
12
13
|
exports.gitUrlHasUserinfo = gitUrlHasUserinfo;
|
|
@@ -173,6 +174,45 @@ function resolvePackHome(layer) {
|
|
|
173
174
|
function packReadRoots(layer) {
|
|
174
175
|
return [resolvePackHome(layer).contentRoot];
|
|
175
176
|
}
|
|
177
|
+
function displayPathForContentRoot(contentRoot) {
|
|
178
|
+
const userRoot = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
179
|
+
const rel = path_1.default.relative(userRoot, contentRoot);
|
|
180
|
+
if (rel === '' || (rel && !rel.startsWith('..') && !path_1.default.isAbsolute(rel))) {
|
|
181
|
+
return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
|
|
182
|
+
}
|
|
183
|
+
return contentRoot.replace(/\\/g, '/');
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Return personalized capability roots in merge order: org, manager, project.
|
|
187
|
+
*
|
|
188
|
+
* Callers that de-duplicate by key can iterate this array directly and let
|
|
189
|
+
* later entries overwrite earlier ones, matching capability resolution
|
|
190
|
+
* precedence where project overrides manager and manager overrides org.
|
|
191
|
+
*/
|
|
192
|
+
function personalizedCapabilityReadRoots(projectRoot, capabilityDir) {
|
|
193
|
+
const roots = [];
|
|
194
|
+
for (const layer of ['org', 'manager']) {
|
|
195
|
+
const contentRoot = resolvePackHome(layer).contentRoot;
|
|
196
|
+
roots.push({
|
|
197
|
+
scope: layer,
|
|
198
|
+
contentRoot,
|
|
199
|
+
capabilityRoot: path_1.default.join(contentRoot, capabilityDir),
|
|
200
|
+
displayRoot: contentRoot,
|
|
201
|
+
displayPrefix: displayPathForContentRoot(contentRoot),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (projectRoot) {
|
|
205
|
+
const contentRoot = (0, project_fraim_paths_1.getWorkspaceFraimPath)(projectRoot, 'personalized-employee');
|
|
206
|
+
roots.push({
|
|
207
|
+
scope: 'project',
|
|
208
|
+
contentRoot,
|
|
209
|
+
capabilityRoot: path_1.default.join(contentRoot, capabilityDir),
|
|
210
|
+
displayRoot: contentRoot,
|
|
211
|
+
displayPrefix: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee'),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return roots;
|
|
215
|
+
}
|
|
176
216
|
/**
|
|
177
217
|
* Auto-migrate content stranded at the legacy standard path to the configured
|
|
178
218
|
* contentRoot. Runs at most once per process per (layer + contentRoot) pair.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.277",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"electron-updater": "^6.8.9",
|
|
169
169
|
"express": "^5.2.1",
|
|
170
170
|
"extract-zip": "^2.0.1",
|
|
171
|
-
"fraim": "2.0.
|
|
171
|
+
"fraim": "2.0.277",
|
|
172
172
|
"mongodb": "^7.0.0",
|
|
173
173
|
"node-cron": "4.2.1",
|
|
174
174
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -826,6 +826,25 @@
|
|
|
826
826
|
</div>
|
|
827
827
|
</div>
|
|
828
828
|
|
|
829
|
+
<!-- Issue #1278: Job visualization modal — phases + skills per job -->
|
|
830
|
+
<div id="job-viz-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="jv-title" hidden>
|
|
831
|
+
<div class="modal-card jv-modal-card">
|
|
832
|
+
<div class="modal-hdr">
|
|
833
|
+
<div class="jv-header">
|
|
834
|
+
<div class="jv-header-text">
|
|
835
|
+
<h3 id="jv-title"></h3>
|
|
836
|
+
<p id="jv-intent"></p>
|
|
837
|
+
</div>
|
|
838
|
+
<span class="jv-personalized-badge" id="jv-badge" hidden>✦ Personalized</span>
|
|
839
|
+
</div>
|
|
840
|
+
<button class="modal-close" id="jv-close" type="button" aria-label="Close job visualization">×</button>
|
|
841
|
+
</div>
|
|
842
|
+
<div class="modal-body" id="jv-body">
|
|
843
|
+
<div id="jv-phases" class="jv-phases"></div>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
829
848
|
<!-- Issue #512: Add-employee modal (preselect-aware) -->
|
|
830
849
|
<div id="add-emp-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="ae-title" hidden>
|
|
831
850
|
<div class="modal-card">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -7373,6 +7373,19 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
7373
7373
|
lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
|
|
7374
7374
|
btn.appendChild(lockBadge);
|
|
7375
7375
|
}
|
|
7376
|
+
// Issue #1278 R1/R2/R14: ⓘ button — skip ad-hoc row, stop propagation so job is not selected.
|
|
7377
|
+
if (job.id !== '__freeform__') {
|
|
7378
|
+
const vizBtn = document.createElement('button');
|
|
7379
|
+
vizBtn.type = 'button';
|
|
7380
|
+
vizBtn.className = 'job-viz-btn';
|
|
7381
|
+
vizBtn.textContent = 'ⓘ';
|
|
7382
|
+
vizBtn.setAttribute('aria-label', `Visualize ${job.title}`);
|
|
7383
|
+
vizBtn.addEventListener('click', (e) => {
|
|
7384
|
+
e.stopPropagation();
|
|
7385
|
+
tfOpenJobViz(job);
|
|
7386
|
+
});
|
|
7387
|
+
btn.appendChild(vizBtn);
|
|
7388
|
+
}
|
|
7376
7389
|
btn.addEventListener('click', () => {
|
|
7377
7390
|
if (isLocked) {
|
|
7378
7391
|
// Issue #540 R10: locked jobs are now non-blocking. Allow selection and proceed
|
|
@@ -15185,6 +15198,173 @@ function tfCloseAssignJob() {
|
|
|
15185
15198
|
const m = document.getElementById('assign-job-modal');
|
|
15186
15199
|
if (m) m.hidden = true;
|
|
15187
15200
|
}
|
|
15201
|
+
|
|
15202
|
+
// Issue #1278 — Job Visualization Modal
|
|
15203
|
+
function tfOpenJobViz(job) {
|
|
15204
|
+
const modal = document.getElementById('job-viz-modal');
|
|
15205
|
+
if (!modal) return;
|
|
15206
|
+
const titleEl = document.getElementById('jv-title');
|
|
15207
|
+
const intentEl = document.getElementById('jv-intent');
|
|
15208
|
+
const badgeEl = document.getElementById('jv-badge');
|
|
15209
|
+
const phasesEl = document.getElementById('jv-phases');
|
|
15210
|
+
if (!phasesEl) return;
|
|
15211
|
+
if (titleEl) titleEl.textContent = job.title || job.id;
|
|
15212
|
+
if (intentEl) intentEl.textContent = '';
|
|
15213
|
+
if (badgeEl) badgeEl.hidden = true;
|
|
15214
|
+
phasesEl.innerHTML = '<div class="jv-loading">Loading…</div>';
|
|
15215
|
+
modal.hidden = false;
|
|
15216
|
+
|
|
15217
|
+
const projectPath = state.projectPath || '';
|
|
15218
|
+
const url = '/api/ai-hub/job/' + encodeURIComponent(job.id) + '/visualization' +
|
|
15219
|
+
(projectPath ? '?projectPath=' + encodeURIComponent(projectPath) : '');
|
|
15220
|
+
|
|
15221
|
+
fetch(url)
|
|
15222
|
+
.then((r) => r.json())
|
|
15223
|
+
.then((data) => {
|
|
15224
|
+
if (titleEl) titleEl.textContent = data.title || job.title || job.id;
|
|
15225
|
+
if (intentEl) intentEl.textContent = data.intent || '';
|
|
15226
|
+
if (badgeEl) { badgeEl.hidden = !data.personalized; }
|
|
15227
|
+
phasesEl.innerHTML = '';
|
|
15228
|
+
if (!data.phases || data.phases.length === 0) {
|
|
15229
|
+
phasesEl.innerHTML = '<div class="jv-loading">No phase data available for this job.</div>';
|
|
15230
|
+
return;
|
|
15231
|
+
}
|
|
15232
|
+
// Scroll wrapper separates horizontal scrolling from the overflow context
|
|
15233
|
+
// so absolutely-positioned popovers are not clipped by overflow-x:auto.
|
|
15234
|
+
const scrollWrap = document.createElement('div');
|
|
15235
|
+
scrollWrap.className = 'jv-phases-scroll';
|
|
15236
|
+
phasesEl.appendChild(scrollWrap);
|
|
15237
|
+
data.phases.forEach((phase, idx) => {
|
|
15238
|
+
const wrap = document.createElement('div');
|
|
15239
|
+
wrap.className = 'jv-phase-wrap';
|
|
15240
|
+
|
|
15241
|
+
const phaseEl = document.createElement('div');
|
|
15242
|
+
phaseEl.className = 'jv-phase';
|
|
15243
|
+
phaseEl.setAttribute('data-phase-id', phase.id);
|
|
15244
|
+
|
|
15245
|
+
const card = document.createElement('div');
|
|
15246
|
+
card.className = 'jv-phase-card';
|
|
15247
|
+
const numEl = document.createElement('div');
|
|
15248
|
+
numEl.className = 'jv-phase-number';
|
|
15249
|
+
numEl.textContent = String(idx + 1);
|
|
15250
|
+
const labelEl = document.createElement('div');
|
|
15251
|
+
labelEl.className = 'jv-phase-label';
|
|
15252
|
+
labelEl.textContent = phase.label || phase.id;
|
|
15253
|
+
card.appendChild(numEl);
|
|
15254
|
+
card.appendChild(labelEl);
|
|
15255
|
+
phaseEl.appendChild(card);
|
|
15256
|
+
|
|
15257
|
+
// Store tooltip content in data attributes (rendered via #jv-tt portal)
|
|
15258
|
+
phaseEl.dataset.jvIntent = phase.intent || '';
|
|
15259
|
+
phaseEl.dataset.jvOutcome = phase.outcome || '';
|
|
15260
|
+
phaseEl.addEventListener('mouseenter', jvShowPhaseTooltip);
|
|
15261
|
+
phaseEl.addEventListener('mouseleave', jvHideTooltip);
|
|
15262
|
+
|
|
15263
|
+
// Skill chips
|
|
15264
|
+
if (phase.skills && phase.skills.length > 0) {
|
|
15265
|
+
const skillsRow = document.createElement('div');
|
|
15266
|
+
skillsRow.className = 'jv-skills';
|
|
15267
|
+
phase.skills.forEach((skill) => {
|
|
15268
|
+
const wrapper = document.createElement('div');
|
|
15269
|
+
wrapper.className = 'jv-skill-wrap';
|
|
15270
|
+
wrapper.setAttribute('data-skill-id', skill.id);
|
|
15271
|
+
const chip = document.createElement('span');
|
|
15272
|
+
chip.className = 'jv-skill';
|
|
15273
|
+
chip.textContent = skill.label || skill.id;
|
|
15274
|
+
chip.dataset.jvInput = skill.input || '';
|
|
15275
|
+
chip.dataset.jvOutput = skill.output || '';
|
|
15276
|
+
chip.addEventListener('mouseenter', jvShowSkillTooltip);
|
|
15277
|
+
chip.addEventListener('mouseleave', jvHideTooltip);
|
|
15278
|
+
wrapper.appendChild(chip);
|
|
15279
|
+
skillsRow.appendChild(wrapper);
|
|
15280
|
+
});
|
|
15281
|
+
phaseEl.appendChild(skillsRow);
|
|
15282
|
+
}
|
|
15283
|
+
|
|
15284
|
+
wrap.appendChild(phaseEl);
|
|
15285
|
+
const arrow = document.createElement('span');
|
|
15286
|
+
arrow.className = 'jv-arrow';
|
|
15287
|
+
arrow.textContent = '→';
|
|
15288
|
+
wrap.appendChild(arrow);
|
|
15289
|
+
scrollWrap.appendChild(wrap);
|
|
15290
|
+
});
|
|
15291
|
+
})
|
|
15292
|
+
.catch(() => {
|
|
15293
|
+
if (phasesEl) phasesEl.innerHTML = '<div class="jv-loading">Could not load job details.</div>';
|
|
15294
|
+
});
|
|
15295
|
+
}
|
|
15296
|
+
function tfCloseJobViz() {
|
|
15297
|
+
const m = document.getElementById('job-viz-modal');
|
|
15298
|
+
if (m) m.hidden = true;
|
|
15299
|
+
jvHideTooltip();
|
|
15300
|
+
}
|
|
15301
|
+
|
|
15302
|
+
// Issue #1278 — fixed-position tooltip portal, escapes scroll-container clip
|
|
15303
|
+
function jvGetOrCreateTooltip() {
|
|
15304
|
+
let tt = document.getElementById('jv-tt');
|
|
15305
|
+
if (!tt) {
|
|
15306
|
+
tt = document.createElement('div');
|
|
15307
|
+
tt.id = 'jv-tt';
|
|
15308
|
+
tt.setAttribute('role', 'tooltip');
|
|
15309
|
+
tt.hidden = true;
|
|
15310
|
+
document.body.appendChild(tt);
|
|
15311
|
+
}
|
|
15312
|
+
return tt;
|
|
15313
|
+
}
|
|
15314
|
+
|
|
15315
|
+
function jvPositionTooltip(anchor) {
|
|
15316
|
+
const tt = jvGetOrCreateTooltip();
|
|
15317
|
+
tt.hidden = false;
|
|
15318
|
+
const r = anchor.getBoundingClientRect();
|
|
15319
|
+
const ttW = tt.offsetWidth || 220;
|
|
15320
|
+
const ttH = tt.offsetHeight || 80;
|
|
15321
|
+
let top = r.bottom + 8;
|
|
15322
|
+
let left = r.left + r.width / 2 - ttW / 2;
|
|
15323
|
+
// Clamp to viewport
|
|
15324
|
+
if (left + ttW > window.innerWidth - 8) left = window.innerWidth - ttW - 8;
|
|
15325
|
+
if (left < 8) left = 8;
|
|
15326
|
+
if (top + ttH > window.innerHeight - 8) top = r.top - ttH - 8;
|
|
15327
|
+
tt.style.top = top + 'px';
|
|
15328
|
+
tt.style.left = left + 'px';
|
|
15329
|
+
}
|
|
15330
|
+
|
|
15331
|
+
function jvShowPhaseTooltip(e) {
|
|
15332
|
+
const phase = e.currentTarget;
|
|
15333
|
+
const intent = phase.dataset.jvIntent;
|
|
15334
|
+
const outcome = phase.dataset.jvOutcome;
|
|
15335
|
+
const tt = jvGetOrCreateTooltip();
|
|
15336
|
+
if (!intent && !outcome) {
|
|
15337
|
+
tt.innerHTML = '<div class="jv-popover-row">Ask your agent for full details.</div>';
|
|
15338
|
+
} else {
|
|
15339
|
+
let html = '';
|
|
15340
|
+
if (intent) html += '<div class="jv-popover-row"><div class="jv-popover-label">Intent</div>' + tfEscape(intent) + '</div>';
|
|
15341
|
+
if (outcome) html += '<div class="jv-popover-row"><div class="jv-popover-label">Outcome</div>' + tfEscape(outcome) + '</div>';
|
|
15342
|
+
tt.innerHTML = html;
|
|
15343
|
+
}
|
|
15344
|
+
jvPositionTooltip(phase.querySelector('.jv-phase-card') || phase);
|
|
15345
|
+
}
|
|
15346
|
+
|
|
15347
|
+
function jvShowSkillTooltip(e) {
|
|
15348
|
+
const chip = e.currentTarget;
|
|
15349
|
+
const input = chip.dataset.jvInput;
|
|
15350
|
+
const output = chip.dataset.jvOutput;
|
|
15351
|
+
const tt = jvGetOrCreateTooltip();
|
|
15352
|
+
if (!input && !output) {
|
|
15353
|
+
tt.innerHTML = '<div class="jv-popover-row">Ask your agent for full details.</div>';
|
|
15354
|
+
} else {
|
|
15355
|
+
let html = '';
|
|
15356
|
+
if (input) html += '<div class="jv-popover-row"><div class="jv-popover-label">Input</div>' + tfEscape(input) + '</div>';
|
|
15357
|
+
if (output) html += '<div class="jv-popover-row"><div class="jv-popover-label">Output</div>' + tfEscape(output) + '</div>';
|
|
15358
|
+
tt.innerHTML = html;
|
|
15359
|
+
}
|
|
15360
|
+
jvPositionTooltip(chip);
|
|
15361
|
+
}
|
|
15362
|
+
|
|
15363
|
+
function jvHideTooltip() {
|
|
15364
|
+
const tt = document.getElementById('jv-tt');
|
|
15365
|
+
if (tt) tt.hidden = true;
|
|
15366
|
+
}
|
|
15367
|
+
|
|
15188
15368
|
function tfAssignJobToEmployee(employeeKey, job) {
|
|
15189
15369
|
const projectId = tf.activeProjectId;
|
|
15190
15370
|
if (!projectId) { tfCloseAssignJob(); return; }
|
|
@@ -15487,7 +15667,7 @@ function tfWireShell() {
|
|
|
15487
15667
|
if (aomStart) aomStart.addEventListener('click', tfSubmitAreaOnboardModal);
|
|
15488
15668
|
const aomModal = document.getElementById('area-onboard-modal');
|
|
15489
15669
|
if (aomModal) aomModal.addEventListener('click', (e) => { if (e.target === aomModal) tfCloseAreaOnboardModal(); });
|
|
15490
|
-
const closers = [['aj-close', tfCloseAssignJob], ['ae-close', tfCloseAddEmp], ['pr-close', tfClosePricing], ['hm-close', tfCloseHireManager]];
|
|
15670
|
+
const closers = [['aj-close', tfCloseAssignJob], ['ae-close', tfCloseAddEmp], ['pr-close', tfClosePricing], ['hm-close', tfCloseHireManager], ['jv-close', tfCloseJobViz]];
|
|
15491
15671
|
for (const [id, fn] of closers) {
|
|
15492
15672
|
const el = document.getElementById(id);
|
|
15493
15673
|
if (el) el.addEventListener('click', fn);
|
|
@@ -15513,7 +15693,7 @@ function tfWireShell() {
|
|
|
15513
15693
|
const q = document.getElementById('hm-query');
|
|
15514
15694
|
if (q && navigator.clipboard) navigator.clipboard.writeText(q.textContent || '');
|
|
15515
15695
|
});
|
|
15516
|
-
const backdrops = [['assign-job-modal', tfCloseAssignJob], ['add-emp-modal', tfCloseAddEmp], ['pricing-modal', tfClosePricing], ['hm-modal', tfCloseHireManager]];
|
|
15696
|
+
const backdrops = [['assign-job-modal', tfCloseAssignJob], ['add-emp-modal', tfCloseAddEmp], ['pricing-modal', tfClosePricing], ['hm-modal', tfCloseHireManager], ['job-viz-modal', tfCloseJobViz]];
|
|
15517
15697
|
for (const [id, fn] of backdrops) {
|
|
15518
15698
|
const el = document.getElementById(id);
|
|
15519
15699
|
if (el) el.addEventListener('click', (e) => { if (e.target === el) fn(); });
|
|
@@ -15523,6 +15703,7 @@ function tfWireShell() {
|
|
|
15523
15703
|
// Escape — unlike the legacy conversation modal. Add an Escape handler that
|
|
15524
15704
|
// dismisses whichever team-flow modal is open (top-most first).
|
|
15525
15705
|
const escClosers = [
|
|
15706
|
+
['job-viz-modal', tfCloseJobViz],
|
|
15526
15707
|
['hm-modal', tfCloseHireManager],
|
|
15527
15708
|
['pricing-modal', tfClosePricing],
|
|
15528
15709
|
['add-emp-modal', tfCloseAddEmp],
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -6359,3 +6359,80 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
6359
6359
|
/* ─── End Issue #1065 ────────────────────────────────────────────────────── */
|
|
6360
6360
|
|
|
6361
6361
|
/* ─── End Issue #1178 ────────────────────────────────────────────────────── */
|
|
6362
|
+
|
|
6363
|
+
/* ─── Issue #1278: Job Visualization Modal ──────────────────────────────── */
|
|
6364
|
+
/* Must sit above .modal-backdrop (z-index:100) so clicks reach #jv-close. */
|
|
6365
|
+
#job-viz-modal { z-index: 110; }
|
|
6366
|
+
.jv-modal-card { max-width: 860px; }
|
|
6367
|
+
.jv-header { display: flex; align-items: flex-start; gap: 10px; }
|
|
6368
|
+
.jv-header-text { flex: 1; min-width: 0; }
|
|
6369
|
+
.jv-header-text h3 { margin: 0 0 4px; font-size: 17px; font-weight: 700; }
|
|
6370
|
+
.jv-header-text p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
|
6371
|
+
.jv-personalized-badge {
|
|
6372
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
6373
|
+
padding: 2px 8px; border-radius: 20px; flex-shrink: 0; margin-top: 2px;
|
|
6374
|
+
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
|
6375
|
+
background: var(--accent-soft); color: var(--accent);
|
|
6376
|
+
border: 1px solid var(--accent); white-space: nowrap;
|
|
6377
|
+
}
|
|
6378
|
+
.jv-phases { position: relative; overflow: visible; padding: 8px 0 0; }
|
|
6379
|
+
.jv-phases-scroll {
|
|
6380
|
+
display: flex; align-items: flex-start; gap: 0;
|
|
6381
|
+
overflow-x: auto; padding-bottom: 16px;
|
|
6382
|
+
scrollbar-width: thin; scrollbar-color: rgba(0,0,0,.15) transparent;
|
|
6383
|
+
}
|
|
6384
|
+
.jv-phase-wrap { display: flex; align-items: flex-start; gap: 0; flex-shrink: 0; }
|
|
6385
|
+
.jv-phase-wrap:last-child .jv-arrow { display: none; }
|
|
6386
|
+
.jv-arrow { align-self: flex-start; margin-top: 14px; color: var(--muted); font-size: 14px; padding: 0 4px; flex-shrink: 0; }
|
|
6387
|
+
.jv-phase { display: flex; flex-direction: column; align-items: center; min-width: 120px; max-width: 148px; position: relative; }
|
|
6388
|
+
.jv-phase-card {
|
|
6389
|
+
width: 100%; background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
|
|
6390
|
+
padding: 8px 10px; text-align: center; font-size: 12px; font-weight: 600; color: var(--text);
|
|
6391
|
+
transition: border-color 120ms, background 120ms; user-select: none; cursor: default;
|
|
6392
|
+
}
|
|
6393
|
+
.jv-phase-number {
|
|
6394
|
+
width: 20px; height: 20px; border-radius: 50%; margin: 0 auto 5px;
|
|
6395
|
+
background: var(--accent-soft); border: 1.5px solid var(--accent);
|
|
6396
|
+
color: var(--accent); font-size: 10px; font-weight: 700;
|
|
6397
|
+
display: flex; align-items: center; justify-content: center;
|
|
6398
|
+
}
|
|
6399
|
+
.jv-phase-label { line-height: 1.3; }
|
|
6400
|
+
.jv-skills { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; padding: 6px 2px 0; max-width: 148px; }
|
|
6401
|
+
.jv-skill {
|
|
6402
|
+
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 12px;
|
|
6403
|
+
font-size: 10px; font-weight: 500; background: var(--soft); color: var(--muted);
|
|
6404
|
+
border: 1px solid var(--line); cursor: default; white-space: nowrap;
|
|
6405
|
+
max-width: 140px; overflow: hidden; text-overflow: ellipsis;
|
|
6406
|
+
transition: background 120ms, color 120ms, border-color 120ms;
|
|
6407
|
+
}
|
|
6408
|
+
.jv-skill:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
|
6409
|
+
/* #jv-tt: fixed-position tooltip portal; appended to body, escapes scroll-container clip */
|
|
6410
|
+
#jv-tt {
|
|
6411
|
+
position: fixed; z-index: 9000;
|
|
6412
|
+
background: var(--surface); border: 1px solid var(--line);
|
|
6413
|
+
border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
|
6414
|
+
padding: 10px 12px; min-width: 180px; max-width: 260px;
|
|
6415
|
+
font-size: 12px; line-height: 1.5; color: var(--text); pointer-events: none;
|
|
6416
|
+
white-space: normal; text-align: left;
|
|
6417
|
+
}
|
|
6418
|
+
.jv-phase:hover .jv-phase-card { border-color: var(--accent); background: var(--accent-soft); }
|
|
6419
|
+
.jv-skill:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
|
6420
|
+
.jv-popover-row { margin-bottom: 6px; }
|
|
6421
|
+
.jv-popover-row:last-child { margin-bottom: 0; }
|
|
6422
|
+
.jv-popover-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 2px; }
|
|
6423
|
+
.jv-skill-wrap { display: inline-flex; }
|
|
6424
|
+
.jv-loading { color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center; }
|
|
6425
|
+
/* ⓘ button on job cards in the assign-job picker */
|
|
6426
|
+
.job-option { position: relative; display: grid; grid-template-columns: 1fr auto; align-items: start; gap: 2px 6px; }
|
|
6427
|
+
.job-option > strong { grid-column: 1; }
|
|
6428
|
+
.job-option > span:not(.lock-badge) { grid-column: 1; }
|
|
6429
|
+
.job-viz-btn {
|
|
6430
|
+
grid-column: 2; grid-row: 1;
|
|
6431
|
+
background: transparent; border: none; padding: 2px 4px;
|
|
6432
|
+
color: var(--muted); font-size: 13px; cursor: pointer;
|
|
6433
|
+
border-radius: 4px; line-height: 1;
|
|
6434
|
+
transition: background 120ms, color 120ms;
|
|
6435
|
+
}
|
|
6436
|
+
.job-viz-btn:hover { background: var(--soft); color: var(--accent); }
|
|
6437
|
+
.job-viz-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
6438
|
+
/* ─── End Issue #1278 ────────────────────────────────────────────────────── */
|