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.
@@ -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, userLevel) {
189
- if (!userLevel)
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((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
192
- return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
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, userLevel) {
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, userLevel),
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, userLevel) {
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, userLevel),
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
- userLevel: layer.base === 'user',
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.userLevel);
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.userLevel);
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)) {
@@ -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
- if (!line.includes('seekMentoring'))
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
- return canonicalToolName(rawName) === canonicalName;
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
- function applyReviewProjection(run, text) {
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
- applyReviewProjection(run, event.message);
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
- applyReviewProjection(run, last.text);
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' || task.status === 'killed');
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
- applyReviewProjection(run, event.raw);
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 note is agent-only.
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', increment recoveryAttempts.
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
- current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
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
- : buildHubRecoveryContinueMessage(current, exitCode, attempt);
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.276",
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.276",
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",
@@ -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">
@@ -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],
@@ -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 ────────────────────────────────────────────────────── */