fraim-hub 2.0.232 → 2.0.234

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.
@@ -154,6 +154,14 @@ function getHubPersonaForJob(jobName) {
154
154
  return null;
155
155
  return getProtectedPersonaForHubJob(jobName) ?? DEFAULT_UNASSIGNED_PERSONA_KEY;
156
156
  }
157
+ // Issue #991: consult custom employee records before falling back to the catalog.
158
+ // Returns the key of the first custom employee whose jobIds array includes jobId,
159
+ // or null if no custom employee owns it.
160
+ function getCustomPersonaForJob(projectPath, jobId) {
161
+ const employees = (0, custom_employees_1.readCustomEmployees)(projectPath);
162
+ const match = employees.find((e) => Array.isArray(e.jobIds) && e.jobIds.includes(jobId));
163
+ return match ? match.key : null;
164
+ }
157
165
  const FRAIM_INTERNAL_JOB_IDS = new Set([
158
166
  'contribute-to-fraim',
159
167
  'create-registry-asset',
@@ -2452,7 +2460,7 @@ class AiHubServer {
2452
2460
  id: employeeJob.id,
2453
2461
  title: employeeJob.title,
2454
2462
  stubPath: employeeJob.stubPath,
2455
- personaKey: employeeJob.requiredPersonaKey ?? getHubPersonaForJob(employeeJob.id),
2463
+ personaKey: employeeJob.requiredPersonaKey ?? getCustomPersonaForJob(projectPath, employeeJob.id) ?? getHubPersonaForJob(employeeJob.id),
2456
2464
  };
2457
2465
  }
2458
2466
  const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.232",
3
+ "version": "2.0.234",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js"
@@ -158,7 +158,7 @@
158
158
  "electron": "^41.2.2",
159
159
  "electron-updater": "^6.8.9",
160
160
  "express": "^5.2.1",
161
- "fraim": "2.0.232",
161
+ "fraim": "2.0.234",
162
162
  "mongodb": "^7.0.0",
163
163
  "node-cron": "4.2.1",
164
164
  "node-edge-tts": "^1.2.10",
@@ -73,6 +73,8 @@ const state = {
73
73
  // the active employee; the user never sees the raw invocation syntax.
74
74
  pendingCoachingJobId: null,
75
75
  pendingCoachingLabel: null,
76
+ // Issue #989 R1-R6: area preserved when a next-job chip opens step-2 pre-filled.
77
+ pendingNextJobArea: null,
76
78
  conversationPersistTimer: null,
77
79
  conversationRefreshHandle: null,
78
80
  conversationDiskAvailable: true,
@@ -300,7 +302,11 @@ function tfRefreshPersonaDependentSurfaces() {
300
302
  else if (tf.area === 'brain') tfRenderBrain();
301
303
  if (tf.area === 'projects') {
302
304
  if (tf.projectView === 'overview') tfRenderOverview();
303
- else tfRenderTree();
305
+ else {
306
+ tfRenderTree();
307
+ // tfRenderTree rebuilds tree chrome only; renderRail rebuilds per-employee accordion groups.
308
+ if (typeof renderRail === 'function') renderRail();
309
+ }
304
310
  }
305
311
  }
306
312
 
@@ -1305,6 +1311,19 @@ function stripHubInjectedPromptBlocks(text) {
1305
1311
  .trim();
1306
1312
  }
1307
1313
 
1314
+ // Returns a validated http/https URL string suitable for use in an href, or null if unsafe.
1315
+ function _safeHref(raw) {
1316
+ try {
1317
+ const url = new URL(raw);
1318
+ return (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : null;
1319
+ } catch (_) { return null; }
1320
+ }
1321
+
1322
+ // Escape & and " in a URL for safe insertion into an href="..." attribute.
1323
+ function _escapeHref(href) {
1324
+ return href.replace(/&/g, '&').replace(/"/g, '"');
1325
+ }
1326
+
1308
1327
  // R8: render markdown subset safely. HTML is escaped first.
1309
1328
  function formatEmployeeText(text) {
1310
1329
  if (!text) return '';
@@ -1320,6 +1339,27 @@ function formatEmployeeText(text) {
1320
1339
  s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
1321
1340
  // 3. Inline code (single backtick, non-greedy, no newlines).
1322
1341
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
1342
+ // 3.5. Markdown links [text](url) — only http/https hrefs allowed; link text is already HTML-escaped.
1343
+ // Empty-text links and non-absolute URLs fall back to plain text.
1344
+ s = s.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (match, linkText, rawUrl) => {
1345
+ const trimmed = linkText.trim();
1346
+ // Empty link text: render as plain text; sentinel prevents step 3.6 auto-linkify.
1347
+ if (!trimmed) return `\x00NOLINK\x00${rawUrl.trim()}\x00ENDNOLINK\x00`;
1348
+ // Reverse HTML entities step 1 introduced inside the URL before structural validation.
1349
+ const decodedUrl = rawUrl.trim().replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
1350
+ const href = _safeHref(decodedUrl);
1351
+ if (!href) return trimmed;
1352
+ return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${trimmed}</a>`;
1353
+ });
1354
+ // 3.6. Auto-linkify bare https:// / http:// URLs not already inside href="...".
1355
+ // Sentinel prefix prevents double-linkifying empty-text [](url) patterns.
1356
+ s = s.replace(/(?<!href=")(?<!href=')(?<!\x00NOLINK\x00)(https?:\/\/[^\s<>"'\x00]+)/g, (url) => {
1357
+ const href = _safeHref(url);
1358
+ if (!href) return url;
1359
+ return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${_escapeHref(href)}</a>`;
1360
+ });
1361
+ // Strip sentinels: emit the URL as plain text (no anchor).
1362
+ s = s.replace(/\x00NOLINK\x00(.*?)\x00ENDNOLINK\x00/g, (_, url) => url);
1323
1363
  // 4. Bold (**text**).
1324
1364
  s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1325
1365
  // 5–7. Process line by line for lists and paragraphs.
@@ -1664,7 +1704,7 @@ function renderRail() {
1664
1704
  titleSpan.className = 'conv-title';
1665
1705
  titleSpan.textContent = conv.title || '';
1666
1706
  bodyDiv.appendChild(titleSpan);
1667
- appendHubTimestamp(bodyDiv, conv.lastUpdatedAt, 'conv-time', 'Run last updated');
1707
+ appendHubTimestamp(bodyDiv, conversationStartTimestamp(conv), 'conv-time', 'Job started');
1668
1708
  btn.appendChild(bodyDiv);
1669
1709
  // Issue #566 R7: mark runs of a personalized (taught/customized) job.
1670
1710
  if (isTaughtJob(conv.jobId)) {
@@ -4851,7 +4891,14 @@ function renderNextJobRecommendations(conv) {
4851
4891
  : conv.scope === 'company' ? 'company'
4852
4892
  : 'projects';
4853
4893
  const employeeId = conversationAgentName(conv) || (state.selectedEmployeeId) || 'claude';
4854
- startRun(job, instructions, employeeId, undefined, area);
4894
+ // Issue #989 R1-R6: open step-2 with job pre-selected and instructions pre-filled
4895
+ // so the manager can review/edit before starting, instead of calling startRun directly.
4896
+ state.selectedJob = job;
4897
+ state.selectedEmployeeId = employeeId;
4898
+ state.pendingNextJobArea = area;
4899
+ showStep2(job);
4900
+ els['instructions'].value = instructions;
4901
+ els['start'].disabled = instructions.trim().length === 0;
4855
4902
  });
4856
4903
  }
4857
4904
  chips.appendChild(btn);
@@ -6018,6 +6065,7 @@ function closeModal() {
6018
6065
  els['modal'].classList.remove('open');
6019
6066
  els['modal'].hidden = true;
6020
6067
  state.activeFilter = null;
6068
+ state.pendingNextJobArea = null;
6021
6069
  }
6022
6070
 
6023
6071
  // Build the unified picker list. Employee jobs are organized by their
@@ -6707,7 +6755,15 @@ function foldRunIntoConversation(conv, run) {
6707
6755
  if (run.configuredAgentLabel) conv.configuredAgentLabel = run.configuredAgentLabel;
6708
6756
  if (run.baseHostId) conv.baseHostId = run.baseHostId;
6709
6757
  if (run.personaKey !== undefined) {
6710
- conv.personaKey = run.personaKey;
6758
+ // Issue #991: do not let a server-side catalog/default key overwrite a custom:*
6759
+ // key the client already holds. The client stamps custom:* at kickoff from the
6760
+ // user's explicit action (cpPersonaOverride); the server resolves it via
6761
+ // getCustomPersonaForJob, but the client guard is defense-in-depth.
6762
+ const isClientCustom = conv.personaKey && conv.personaKey.startsWith('custom:');
6763
+ const isServerCustom = run.personaKey && run.personaKey.startsWith('custom:');
6764
+ if (!isClientCustom || isServerCustom) {
6765
+ conv.personaKey = run.personaKey;
6766
+ }
6711
6767
  }
6712
6768
  // Issue #347: keep the latest server snapshot under conv.run so the
6713
6769
  // tracker / totals renderers can read it without re-doing work. Stages,
@@ -7561,6 +7617,9 @@ function wireEvents() {
7561
7617
  if (!job || !text) return;
7562
7618
  const employeeId = els['employee-select'].value || state.selectedEmployeeId;
7563
7619
  state.selectedEmployeeId = employeeId;
7620
+ // Issue #989 R4: consume pendingNextJobArea so the run lands in the right tab.
7621
+ const invokedArea = state.pendingNextJobArea || undefined;
7622
+ state.pendingNextJobArea = null;
7564
7623
  closeModal();
7565
7624
  // R2: if no project path is set, show the inline project picker instead.
7566
7625
  if (!state.projectPath) {
@@ -7574,7 +7633,7 @@ function wireEvents() {
7574
7633
  showHireStrip(job, pendingConvId, text, employeeId);
7575
7634
  return;
7576
7635
  }
7577
- await startRun(job, text, employeeId);
7636
+ await startRun(job, text, employeeId, undefined, invokedArea);
7578
7637
  });
7579
7638
  els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
7580
7639
  els['modal'].addEventListener('click', (e) => {
@@ -10486,7 +10545,7 @@ function tfBuildManagerRunItem(conv) {
10486
10545
  const body = document.createElement('span'); body.className = 'conv-body';
10487
10546
  const title = document.createElement('span'); title.className = 'conv-title';
10488
10547
  title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
10489
- body.appendChild(title); appendHubTimestamp(body, conv.lastUpdatedAt, 'conv-time', 'Run last updated'); btn.appendChild(body);
10548
+ body.appendChild(title); appendHubTimestamp(body, conversationStartTimestamp(conv), 'conv-time', 'Job started'); btn.appendChild(body);
10490
10549
  const dotClass = conversationStateDotClass(conv);
10491
10550
  const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
10492
10551
  if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
@@ -1669,6 +1669,15 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1669
1669
  border: 1px solid var(--line);
1670
1670
  color: var(--text);
1671
1671
  }
1672
+ .message.employee .bubble a {
1673
+ color: var(--accent);
1674
+ text-decoration: underline;
1675
+ text-underline-offset: 2px;
1676
+ word-break: break-all;
1677
+ }
1678
+ .message.employee .bubble a:hover {
1679
+ color: var(--accent-strong);
1680
+ }
1672
1681
  .typing-indicator {
1673
1682
  animation: none;
1674
1683
  justify-items: start;