fraim-framework 2.0.170 → 2.0.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/ai-hub/hosts.js +227 -6
  2. package/dist/src/ai-hub/server.js +1014 -35
  3. package/dist/src/cli/commands/add-ide.js +4 -2
  4. package/dist/src/cli/commands/cleanup-artifacts.js +38 -0
  5. package/dist/src/cli/commands/init-project.js +12 -5
  6. package/dist/src/cli/commands/setup.js +1 -1
  7. package/dist/src/cli/commands/sync.js +74 -7
  8. package/dist/src/cli/doctor/checks/ide-config-checks.js +2 -2
  9. package/dist/src/cli/fraim.js +2 -0
  10. package/dist/src/cli/mcp/ide-formats.js +10 -2
  11. package/dist/src/cli/setup/auto-mcp-setup.js +4 -2
  12. package/dist/src/cli/setup/ide-detector.js +26 -0
  13. package/dist/src/cli/setup/ide-global-integration.js +6 -2
  14. package/dist/src/cli/setup/ide-invocation-surfaces.js +12 -4
  15. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  16. package/dist/src/cli/utils/agent-adapters.js +42 -17
  17. package/dist/src/cli/utils/fraim-gitignore.js +13 -0
  18. package/dist/src/cli/utils/remote-sync.js +129 -53
  19. package/dist/src/cli/utils/user-config.js +12 -0
  20. package/dist/src/config/ai-manager-hiring.js +121 -0
  21. package/dist/src/config/compat.js +16 -0
  22. package/dist/src/config/feature-flags.js +25 -0
  23. package/dist/src/config/persona-capability-bundles.js +273 -0
  24. package/dist/src/config/persona-hiring.js +270 -0
  25. package/dist/src/config/portfolio-slug-overrides.js +17 -0
  26. package/dist/src/config/pricing.js +37 -0
  27. package/dist/src/config/stripe.js +43 -0
  28. package/dist/src/core/fraim-config-schema.generated.js +8 -2
  29. package/dist/src/core/utils/local-registry-resolver.js +26 -0
  30. package/dist/src/core/utils/project-fraim-paths.js +89 -2
  31. package/dist/src/first-run/session-service.js +12 -3
  32. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +255 -0
  33. package/dist/src/local-mcp-server/learning-context-builder.js +41 -81
  34. package/dist/src/local-mcp-server/stdio-server.js +42 -7
  35. package/package.json +5 -1
  36. package/public/ai-hub/index.html +205 -89
  37. package/public/ai-hub/review.css +12 -0
  38. package/public/ai-hub/script.js +1734 -253
  39. package/public/ai-hub/styles.css +473 -6
@@ -15,6 +15,8 @@ const TREE_WIDTH_MIN = 176;
15
15
  const TREE_WIDTH_MAX = 380;
16
16
  const TREE_WIDTH_DEFAULT = 216;
17
17
  const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis']);
18
+ // Jobs scoped to the Company/Manager area — never shown in the Projects workspace rail.
19
+ const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
18
20
 
19
21
  const state = {
20
22
  bootstrap: null,
@@ -36,6 +38,7 @@ const state = {
36
38
  pendingCoachingJobId: null,
37
39
  pendingCoachingLabel: null,
38
40
  conversationPersistTimer: null,
41
+ conversationRefreshHandle: null,
39
42
  conversationDiskAvailable: true,
40
43
  // Issue #539: command palette state
41
44
  cpSelectedJob: null, // {id, title, intent, requiredPersonaKey}
@@ -424,16 +427,92 @@ async function hydrateConversationsFromServer() {
424
427
  for (const conv of conversations) normalizeGeminiConversationMessages(conv);
425
428
  state.conversations[state.projectPath] = conversations;
426
429
  const activeCandidates = [state.activeId, payload.activeId].filter(Boolean);
427
- state.activeId = activeCandidates.find((id) => conversations.some((conv) => conv.id === id)) || (conversations[0] ? conversations[0].id : null);
430
+ // Area-scoped jobs must not become the Projects workspace active conversation
431
+ // they are shown in their own Company/Manager panels via tfActiveOrgConv/tfActiveMgrConv.
432
+ const projectConv = (id) => conversations.find((c) => c.id === id && !AREA_SCOPED_JOBS.has(c.jobId));
433
+ const firstProjectConv = conversations.find((c) => !AREA_SCOPED_JOBS.has(c.jobId));
434
+ const bestActive = activeCandidates.map(projectConv).find(Boolean) || firstProjectConv || null;
435
+ state.activeId = bestActive ? bestActive.id : null;
428
436
  state.conversationDiskAvailable = true;
429
437
  renderRail();
430
438
  renderActive();
439
+ syncConversationRefreshPolling();
431
440
  } catch (error) {
432
441
  state.conversationDiskAvailable = false;
433
442
  console.warn('Could not hydrate conversations from server:', error);
434
443
  }
435
444
  }
436
445
 
446
+ // Background poll: pick up conversations created by scheduled/webhook runs
447
+ // without requiring the user to reload. Merges new/updated conversations into
448
+ // the existing list, preserving the active selection.
449
+ async function bgRefreshConversations() {
450
+ if (!state.projectPath) return;
451
+ try {
452
+ const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
453
+ const incoming = Array.isArray(payload.conversations) ? payload.conversations : [];
454
+ if (!incoming.length) return;
455
+ const existing = state.conversations[state.projectPath] || [];
456
+ const existingIds = new Set(existing.map((c) => c.id));
457
+ let changed = false;
458
+ for (const conv of incoming) {
459
+ normalizeGeminiConversationMessages(conv);
460
+ if (!existingIds.has(conv.id)) {
461
+ existing.push(conv);
462
+ changed = true;
463
+ } else {
464
+ // Update status/messages on known conversations (e.g. running → completed)
465
+ const idx = existing.findIndex((c) => c.id === conv.id);
466
+ if (idx !== -1 && existing[idx].status !== conv.status) {
467
+ existing[idx] = conv;
468
+ changed = true;
469
+ }
470
+ }
471
+ }
472
+ if (changed) {
473
+ state.conversations[state.projectPath] = existing;
474
+ renderRail();
475
+ }
476
+ } catch (_) { /* silent — background poll, not critical */ }
477
+ }
478
+
479
+ let _bgConvPollHandle = null;
480
+ function startBgConvPoll() {
481
+ if (_bgConvPollHandle) return;
482
+ _bgConvPollHandle = window.setInterval(bgRefreshConversations, 30000);
483
+ }
484
+ function stopBgConvPoll() {
485
+ if (_bgConvPollHandle) { window.clearInterval(_bgConvPollHandle); _bgConvPollHandle = null; }
486
+ }
487
+
488
+ function needsConversationRefresh() {
489
+ const active = activeConversation();
490
+ if (!active) return false;
491
+ if (isManagedDelegationChild(active) && active.status === 'running') return true;
492
+ const ledger = normalizeDelegationLedger(active.delegation);
493
+ if (!ledger) return false;
494
+ return ledger.tasks.some((task) => {
495
+ const child = delegationTaskConversation(task, ledger);
496
+ if (!child) return true;
497
+ if (child.status === 'running') return true;
498
+ if (child.status === 'completed' && child.managedReviewStatus !== 'reviewed') return true;
499
+ return false;
500
+ });
501
+ }
502
+
503
+ function syncConversationRefreshPolling() {
504
+ const shouldPoll = needsConversationRefresh();
505
+ if (shouldPoll && !state.conversationRefreshHandle) {
506
+ state.conversationRefreshHandle = window.setInterval(() => {
507
+ hydrateConversationsFromServer().catch((error) =>
508
+ console.warn('Could not refresh delegated conversations:', error));
509
+ }, 1500);
510
+ } else if (!shouldPoll && state.conversationRefreshHandle) {
511
+ window.clearInterval(state.conversationRefreshHandle);
512
+ state.conversationRefreshHandle = null;
513
+ }
514
+ }
515
+
437
516
  function conversationTimestamp(conv) {
438
517
  const value = conv && conv.lastUpdatedAt;
439
518
  if (typeof value === 'number' && Number.isFinite(value)) return value;
@@ -476,6 +555,7 @@ function panelStateFor(convId) {
476
555
  }
477
556
 
478
557
  function defaultCoachOpen(conv) {
558
+ if (isManagerOversightConversation(conv)) return false;
479
559
  return true;
480
560
  }
481
561
 
@@ -535,6 +615,7 @@ function syncQuickCoachButtons(conv) {
535
615
  function stripReviewHandoffBlocks(text) {
536
616
  return String(text || '')
537
617
  .replace(/<review_handoff>\s*[\s\S]*?\s*<\/review_handoff>/gi, '')
618
+ .replace(/\[Thought:\s*true\]\s*/gi, '')
538
619
  .replace(/\n{3,}/g, '\n\n')
539
620
  .trim();
540
621
  }
@@ -707,11 +788,22 @@ function renderRail() {
707
788
  // #521: in the project workspace, project-lifecycle jobs (onboarding +
708
789
  // sleep-on-learnings) have their own "Project Updates" home, so keep them out
709
790
  // of the general Runs list to avoid showing the same run twice.
710
- const inWorkspace = !!document.querySelector('.workspace-conv');
791
+ const inWorkspace = !!document.querySelector('#proj-workspace .workspace-conv');
711
792
  const projectUpdateJobs = new Set(['project-onboarding', 'sleep-on-learnings']);
712
- const list = projectConversations().filter((conv) =>
793
+ const allProjectConversations = projectConversations();
794
+ const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
795
+ const childrenByManager = new Map();
796
+ for (const child of managedChildren) {
797
+ const parentId = child.managedByRunId;
798
+ if (!parentId) continue;
799
+ if (!childrenByManager.has(parentId)) childrenByManager.set(parentId, []);
800
+ childrenByManager.get(parentId).push(child);
801
+ }
802
+ const list = allProjectConversations.filter((conv) =>
713
803
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
714
- !(inWorkspace && projectUpdateJobs.has(conv.jobId))
804
+ !isManagedDelegationChild(conv) &&
805
+ !(inWorkspace && projectUpdateJobs.has(conv.jobId)) &&
806
+ !AREA_SCOPED_JOBS.has(conv.jobId)
715
807
  );
716
808
 
717
809
  // Issue #550: Two-path routing for ad-hoc (freeform) conversations.
@@ -740,50 +832,80 @@ function renderRail() {
740
832
  groups.get(key).conversations.push(conv);
741
833
  }
742
834
 
835
+ function buildRunButton(conv, options = {}) {
836
+ const btn = document.createElement('button');
837
+ btn.type = 'button';
838
+ btn.className = (options.child ? 'conv-item conv-item--delegated-child' : 'conv-item') + (state.activeId === conv.id ? ' active' : '');
839
+ btn.dataset.conv = conv.id;
840
+ const bodyDiv = document.createElement('span');
841
+ bodyDiv.className = 'conv-body';
842
+ const titleSpan = document.createElement('span');
843
+ titleSpan.className = 'conv-title';
844
+ titleSpan.textContent = conv.title || '';
845
+ bodyDiv.appendChild(titleSpan);
846
+ btn.appendChild(bodyDiv);
847
+ // Issue #566 R7: mark runs of a personalized (taught/customized) job.
848
+ if (isTaughtJob(conv.jobId)) {
849
+ const taught = document.createElement('span');
850
+ taught.className = 'taught-badge';
851
+ taught.textContent = 'Personalized';
852
+ taught.title = 'Personalized for this project (taught or customized in your fraim/personalized-employee layer)';
853
+ btn.appendChild(taught);
854
+ }
855
+ const dotClass = conversationStateDotClass(conv);
856
+ const statusDot = document.createElement('span');
857
+ statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
858
+ statusDot.title = tfDotTitle(dotClass);
859
+ btn.appendChild(statusDot);
860
+ const statusSpan = document.createElement('span');
861
+ statusSpan.className = 'conv-status';
862
+ statusSpan.textContent = conversationStateLabel(conv);
863
+ statusSpan.classList.add(conversationUiState(conv));
864
+ btn.appendChild(statusSpan);
865
+ // Issue #578: sourceTrigger chip - show scheduled/webhook badge on automated runs.
866
+ if (conv.sourceTrigger && conv.sourceTrigger !== 'manager') {
867
+ const trigBadge = document.createElement('span');
868
+ trigBadge.className = 'trigger-badge trigger-badge--' + conv.sourceTrigger;
869
+ trigBadge.textContent = conv.sourceTrigger === 'scheduled' ? 'Scheduled' : 'Webhook';
870
+ trigBadge.title = conv.sourceTrigger === 'scheduled'
871
+ ? 'This run was started by the built-in cron scheduler'
872
+ : 'This run was triggered by an inbound webhook';
873
+ btn.appendChild(trigBadge);
874
+ }
875
+ if (conv.compareMode === 'ab') {
876
+ const badge = document.createElement('span');
877
+ badge.className = 'ab-badge';
878
+ badge.textContent = 'A/B';
879
+ btn.appendChild(badge);
880
+ }
881
+ if (!options.child) tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
882
+ btn.addEventListener('click', () => switchToConversation(conv.id));
883
+ return btn;
884
+ }
885
+
743
886
  // Helper: build the run-item buttons shared by employee groups and Watercooler.
744
887
  function buildGroupList(conversations) {
745
888
  const groupList = document.createElement('div');
746
889
  groupList.className = 'conv-employee-list';
747
890
  for (const conv of conversations) {
748
- const btn = document.createElement('button');
749
- btn.type = 'button';
750
- btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
751
- btn.dataset.conv = conv.id;
752
- const bodyDiv = document.createElement('span');
753
- bodyDiv.className = 'conv-body';
754
- const titleSpan = document.createElement('span');
755
- titleSpan.className = 'conv-title';
756
- titleSpan.textContent = conv.title || '';
757
- bodyDiv.appendChild(titleSpan);
758
- btn.appendChild(bodyDiv);
759
- // Issue #566 R7: mark runs of a personalized (taught/customized) job.
760
- if (isTaughtJob(conv.jobId)) {
761
- const taught = document.createElement('span');
762
- taught.className = 'taught-badge';
763
- taught.textContent = 'Personalized';
764
- taught.title = 'Personalized for this project (taught or customized in your fraim/personalized-employee layer)';
765
- btn.appendChild(taught);
766
- }
767
- const dotClass = conversationStateDotClass(conv);
768
- const statusDot = document.createElement('span');
769
- statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
770
- statusDot.title = tfDotTitle(dotClass);
771
- btn.appendChild(statusDot);
772
- const statusSpan = document.createElement('span');
773
- statusSpan.className = 'conv-status';
774
- statusSpan.textContent = conversationStateLabel(conv);
775
- statusSpan.classList.add(conversationUiState(conv));
776
- btn.appendChild(statusSpan);
777
- // Issue #442: A/B badge on rail entry.
778
- if (conv.compareMode === 'ab') {
779
- const badge = document.createElement('span');
780
- badge.className = 'ab-badge';
781
- badge.textContent = 'A/B';
782
- btn.appendChild(badge);
783
- }
784
- tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
785
- btn.addEventListener('click', () => switchToConversation(conv.id));
891
+ const btn = buildRunButton(conv);
786
892
  groupList.appendChild(btn);
893
+ const children = [
894
+ ...(childrenByManager.get(conv.id) || []),
895
+ ...(conv.runId && conv.runId !== conv.id ? (childrenByManager.get(conv.runId) || []) : []),
896
+ ];
897
+ if (children.length) {
898
+ const delegatedList = document.createElement('div');
899
+ delegatedList.className = 'conv-delegated-list';
900
+ const label = document.createElement('div');
901
+ label.className = 'conv-delegated-label';
902
+ label.textContent = 'Mandy-managed workstreams';
903
+ delegatedList.appendChild(label);
904
+ for (const child of children) {
905
+ delegatedList.appendChild(buildRunButton(child, { child: true }));
906
+ }
907
+ groupList.appendChild(delegatedList);
908
+ }
787
909
  }
788
910
  return groupList;
789
911
  }
@@ -887,8 +1009,18 @@ function conversationUiState(conv) {
887
1009
  // not stay stuck as stopped even if the stopped flag was not cleared.
888
1010
  if (conv.stopped && conv.status === 'failed') return 'stopped';
889
1011
  if (conv.status === 'failed') return 'waiting';
1012
+ if (isManagedDelegationChild(conv) && conv.status === 'completed' && conv.managedReviewStatus === 'reviewed') return 'complete';
890
1013
  if (conv.status === 'completed' && conv.reviewApproved) return 'complete';
891
- if (conv.status === 'completed') return 'waiting';
1014
+ if (conv.status === 'completed') {
1015
+ // For fully-delegate runs: if child workstreams are still active, the manager
1016
+ // is waiting for them — show 'working' not 'waiting' so the user isn't prompted
1017
+ // to act when the Hub will auto-route child deliverables back to Mandy.
1018
+ if (isManagerOversightConversation(conv)) {
1019
+ const hasActiveChildren = projectConversations().some(c => c.managedByRunId === conv.runId && c.status === 'running');
1020
+ if (hasActiveChildren) return 'working';
1021
+ }
1022
+ return 'waiting';
1023
+ }
892
1024
  return 'idle';
893
1025
  }
894
1026
 
@@ -903,6 +1035,12 @@ function conversationStateDotClass(conv) {
903
1035
  }
904
1036
 
905
1037
  function conversationStateLabel(conv) {
1038
+ if (isManagedDelegationChild(conv)) {
1039
+ if (conv.status === 'running') return 'Working for Mandy';
1040
+ if (conv.status === 'completed' && conv.managedReviewStatus === 'reviewed') return 'Reviewed by Mandy';
1041
+ if (conv.status === 'completed') return 'Submitted to Mandy';
1042
+ if (conv.status === 'failed') return 'Needs Mandy';
1043
+ }
906
1044
  const uiState = conversationUiState(conv);
907
1045
  if (uiState === 'working') return 'Working';
908
1046
  if (uiState === 'waiting') return 'Waiting on you';
@@ -912,6 +1050,17 @@ function conversationStateLabel(conv) {
912
1050
  return 'Idle';
913
1051
  }
914
1052
 
1053
+ function isManagedDelegationChild(conv) {
1054
+ return !!(conv && (conv.managedByRunId || conv.delegationTaskId || conv.humanCoachingDisabled));
1055
+ }
1056
+
1057
+ function managedByPersonaLabel(conv) {
1058
+ const key = conv && (conv.managedByPersonaKey || 'mandy');
1059
+ if (key === 'mandy') return 'Mandy';
1060
+ const persona = key ? personaMap().get(key) : null;
1061
+ return persona ? persona.displayName : 'Mandy';
1062
+ }
1063
+
915
1064
  function personaMap() {
916
1065
  const map = new Map();
917
1066
  for (const persona of state.bootstrap?.personas || []) {
@@ -1183,7 +1332,7 @@ function showHireStrip(job, conversationId, instructions, employeeId) {
1183
1332
  els['hire-strip'].hidden = false;
1184
1333
  // Switch workspace to conv mode so the hire strip (inside .page) is visible
1185
1334
  // even when there is no active conversation (which normally keeps it in mode-brief).
1186
- const wc = document.querySelector('.workspace-conv');
1335
+ const wc = document.querySelector('#proj-workspace .workspace-conv');
1187
1336
  if (wc) { wc.classList.add('mode-conv'); wc.classList.remove('mode-brief'); }
1188
1337
  }
1189
1338
 
@@ -1289,7 +1438,7 @@ let threadMessageViewportObserver = null;
1289
1438
  // coach, and micro-manage all have room. Toggle the mode class the CSS keys off,
1290
1439
  // and reflect the state in the sidebar's Brief nav entry.
1291
1440
  function tfApplyWorkspaceMode() {
1292
- const wc = document.querySelector('.workspace-conv');
1441
+ const wc = document.querySelector('#proj-workspace .workspace-conv');
1293
1442
  if (!wc) return;
1294
1443
  const hasConv = typeof activeConversation === 'function' && !!activeConversation();
1295
1444
  wc.classList.toggle('mode-conv', hasConv);
@@ -1317,12 +1466,15 @@ function renderActive() {
1317
1466
  renderedEventCount = 0;
1318
1467
  renderedStatus = null;
1319
1468
  renderedDirectEventCount = 0;
1469
+ syncConversationRefreshPolling();
1320
1470
  return;
1321
1471
  }
1322
1472
  els['empty'].hidden = true;
1323
1473
  els['active-conv'].hidden = false;
1324
1474
  els['active-conv'].dataset.runId = conv.runId || '';
1325
1475
  els['active-title'].textContent = conversationTitle(conv);
1476
+ els['active-conv'].classList.toggle('has-delegation-board', !!normalizeDelegationLedger(conv.delegation));
1477
+ els['active-conv'].classList.toggle('has-manager-oversight', isManagerOversightConversation(conv));
1326
1478
  renderConversationIdentity(conv);
1327
1479
  renderRunStatePill(conv);
1328
1480
  syncCoachEmployeeLabel(conv);
@@ -1348,6 +1500,7 @@ function renderActive() {
1348
1500
  syncThreadUiState(conv);
1349
1501
  syncConversationPanels(conv, switchedConv);
1350
1502
  syncQuickCoachButtons(conv);
1503
+ syncManagedDelegationAccess(conv);
1351
1504
  ensureThreadMessageViewportObserver();
1352
1505
 
1353
1506
  // Messages — append only the rows that aren't already in the DOM, so
@@ -1431,6 +1584,7 @@ function renderActive() {
1431
1584
  // Issue #566 R4 — after the manager confirms success, offer to teach this as a
1432
1585
  // job (ad-hoc) or remember the coached steps for the job (structured).
1433
1586
  renderGrowthOffer(conv);
1587
+ syncManagedDelegationAccess(conv);
1434
1588
  syncThreadMessageViewport();
1435
1589
  const m = els['messages'];
1436
1590
  const runningShouldStickToBottom = !!m && conv.status === 'running'
@@ -1456,6 +1610,9 @@ function renderActive() {
1456
1610
  // simply hide the surfaces.
1457
1611
  renderTracker(conv);
1458
1612
  renderTotals(conv);
1613
+ renderManagerOversightBoard(conv);
1614
+ renderDelegationLedger(conv);
1615
+ syncConversationRefreshPolling();
1459
1616
  syncTemplatePickerVisibility();
1460
1617
  // Issue #442: Direct panel status pill, simple progress indicator, and totals.
1461
1618
  if (isABConv) {
@@ -1903,6 +2060,371 @@ function renderTotals(conv) {
1903
2060
  pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
1904
2061
  }
1905
2062
 
2063
+ function ensureDelegationHost() {
2064
+ const active = els['active-conv'];
2065
+ if (!active) return null;
2066
+ let host = document.getElementById('delegation-ledger');
2067
+ if (host) return host;
2068
+ host = document.createElement('details');
2069
+ host.id = 'delegation-ledger';
2070
+ host.className = 'delegation-ledger panel-details';
2071
+ host.addEventListener('toggle', () => {
2072
+ const conv = activeConversation();
2073
+ if (!conv) return;
2074
+ panelStateFor(conv.id).delegation = host.open;
2075
+ });
2076
+ const status = active.querySelector('.conversation-status');
2077
+ const oversight = document.getElementById('manager-oversight-board');
2078
+ if (oversight && oversight.parentNode) {
2079
+ oversight.parentNode.insertBefore(host, oversight.nextSibling);
2080
+ } else if (status && status.parentNode) {
2081
+ status.parentNode.insertBefore(host, status.nextSibling);
2082
+ } else {
2083
+ active.insertBefore(host, active.firstChild);
2084
+ }
2085
+ return host;
2086
+ }
2087
+
2088
+ const MANAGER_OVERSIGHT_JOB_IDS = new Set([
2089
+ 'fully-delegate',
2090
+ 'delivery-governance-review',
2091
+ 'project-plan-creation',
2092
+ 'stakeholder-status-reporting',
2093
+ 'experiment-tracking',
2094
+ 'cross-functional-dependency-management',
2095
+ ]);
2096
+
2097
+ function isManagerOversightConversation(conv) {
2098
+ return !!conv && (conv.personaKey === 'mandy' || MANAGER_OVERSIGHT_JOB_IDS.has(conv.jobId) || !!delegationLedgerForConversation(conv));
2099
+ }
2100
+
2101
+ function ensureManagerOversightHost() {
2102
+ const active = els['active-conv'];
2103
+ if (!active) return null;
2104
+ let host = document.getElementById('manager-oversight-board');
2105
+ if (host) return host;
2106
+ host = document.createElement('details');
2107
+ host.id = 'manager-oversight-board';
2108
+ host.className = 'manager-oversight-board panel-details';
2109
+ host.addEventListener('toggle', () => {
2110
+ const conv = activeConversation();
2111
+ if (!conv) return;
2112
+ panelStateFor(conv.id).managerOversight = host.open;
2113
+ });
2114
+ const status = active.querySelector('.conversation-status');
2115
+ if (status && status.parentNode) {
2116
+ status.parentNode.insertBefore(host, status.nextSibling);
2117
+ } else {
2118
+ active.insertBefore(host, active.firstChild);
2119
+ }
2120
+ return host;
2121
+ }
2122
+
2123
+ function renderManagerOversightBoard(conv) {
2124
+ const host = ensureManagerOversightHost();
2125
+ if (!host) return;
2126
+ if (!isManagerOversightConversation(conv)) {
2127
+ host.hidden = true;
2128
+ host.innerHTML = '';
2129
+ return;
2130
+ }
2131
+ const ledger = delegationLedgerForConversation(conv);
2132
+ const panelState = panelStateFor(conv.id);
2133
+ const shouldOpen = panelState.managerOversight ?? true;
2134
+ if (host.open !== shouldOpen) host.open = shouldOpen;
2135
+ const childCount = ledger?.tasks?.length || 0;
2136
+ const completedChildren = ledger?.tasks?.filter((task) => {
2137
+ const child = delegationTaskConversation(task, ledger);
2138
+ return child?.status === 'completed' || task.status === 'submitted' || task.status === 'reviewed' || task.status === 'completed';
2139
+ }).length || 0;
2140
+ const reviewedChildren = ledger?.tasks?.filter((task) => {
2141
+ const child = delegationTaskConversation(task, ledger);
2142
+ return child?.managedReviewStatus === 'reviewed' || task.status === 'reviewed';
2143
+ }).length || 0;
2144
+ const artifactCount = Array.isArray(conv.artifacts) ? conv.artifacts.length : 0;
2145
+ const reviewReady = !!reviewHandoffForConversation(conv) || artifactCount > 0;
2146
+ const planRows = ledger?.tasks?.map((task, index) => managerPlanRow(task, ledger, index)) || [];
2147
+
2148
+ host.hidden = false;
2149
+ host.innerHTML = '';
2150
+ const summary = document.createElement('summary');
2151
+ const header = document.createElement('div');
2152
+ header.className = 'manager-oversight-header';
2153
+ const copy = document.createElement('div');
2154
+ copy.className = 'manager-oversight-copy';
2155
+ const kicker = document.createElement('span');
2156
+ kicker.className = 'manager-oversight-kicker';
2157
+ kicker.textContent = 'Execution plan';
2158
+ const title = document.createElement('strong');
2159
+ title.textContent = childCount
2160
+ ? `${childCount} delegated job${childCount === 1 ? '' : 's'} with Mandy review gates`
2161
+ : conv.status === 'running' ? 'Mandy is planning the delegation' : `${conv.jobTitle || conv.title || 'Manager job'} — no delegation emitted`;
2162
+ copy.appendChild(kicker);
2163
+ copy.appendChild(title);
2164
+ const meta = document.createElement('span');
2165
+ meta.className = 'manager-oversight-meta';
2166
+ meta.textContent = childCount
2167
+ ? `${completedChildren}/${childCount} submitted, ${reviewedChildren}/${childCount} reviewed`
2168
+ : conv.status === 'running' ? 'setting up team…' : reviewReady ? `${artifactCount} artifact${artifactCount === 1 ? '' : 's'} surfaced, no delegation graph` : 'delegation graph not returned';
2169
+ header.appendChild(copy);
2170
+ header.appendChild(meta);
2171
+ summary.appendChild(header);
2172
+ host.appendChild(summary);
2173
+
2174
+ const list = document.createElement('div');
2175
+ list.className = 'manager-oversight-list';
2176
+ if (planRows.length) {
2177
+ for (const item of planRows) {
2178
+ const row = document.createElement('div');
2179
+ row.className = `manager-plan-row status-${item.status}`;
2180
+ const number = document.createElement('span');
2181
+ number.className = 'manager-plan-step';
2182
+ number.textContent = String(item.index + 1);
2183
+ const body = document.createElement('div');
2184
+ body.className = 'manager-plan-body';
2185
+ const top = document.createElement('div');
2186
+ top.className = 'manager-plan-top';
2187
+ const titleEl = document.createElement('strong');
2188
+ titleEl.textContent = item.title;
2189
+ const status = document.createElement('span');
2190
+ status.className = `manager-plan-status status-${item.status}`;
2191
+ status.textContent = item.statusLabel;
2192
+ top.appendChild(titleEl);
2193
+ top.appendChild(status);
2194
+ const metaLine = document.createElement('div');
2195
+ metaLine.className = 'manager-plan-meta';
2196
+ metaLine.textContent = `${item.employee} • ${item.dependencyText}`;
2197
+ const review = document.createElement('div');
2198
+ review.className = 'manager-plan-review';
2199
+ review.textContent = item.reviewText;
2200
+ body.appendChild(top);
2201
+ body.appendChild(metaLine);
2202
+ body.appendChild(review);
2203
+ if (item.instructions) {
2204
+ const instructions = document.createElement('div');
2205
+ instructions.className = 'manager-plan-instructions';
2206
+ instructions.textContent = item.instructions;
2207
+ body.appendChild(instructions);
2208
+ }
2209
+ row.appendChild(number);
2210
+ row.appendChild(body);
2211
+ list.appendChild(row);
2212
+ }
2213
+ } else {
2214
+ const empty = document.createElement('div');
2215
+ empty.className = 'manager-plan-empty';
2216
+ const isRunning = conv.status === 'running';
2217
+ const titleEl = document.createElement('strong');
2218
+ titleEl.textContent = isRunning ? 'Delegation plan incoming…' : 'No delegation sequence returned';
2219
+ const detail = document.createElement('span');
2220
+ detail.textContent = isRunning
2221
+ ? 'Mandy is working through the task and will lay out the employee assignments and review gates here once the plan is ready.'
2222
+ : reviewReady
2223
+ ? `This run completed with ${artifactCount} surfaced artifact${artifactCount === 1 ? '' : 's'} but did not emit a delegation sequence. Start a new run to retry.`
2224
+ : 'This run finished without returning a delegation sequence. Start a new run to retry.';
2225
+ empty.appendChild(titleEl);
2226
+ empty.appendChild(detail);
2227
+ list.appendChild(empty);
2228
+ }
2229
+ host.appendChild(list);
2230
+ }
2231
+
2232
+ function managerPlanRow(task, ledger, index) {
2233
+ const child = delegationTaskConversation(task, ledger);
2234
+ const status = delegatedTaskStatus(task, child);
2235
+ const dependsOn = Array.isArray(task.dependsOn) ? task.dependsOn.filter(Boolean) : [];
2236
+ const dependencyText = dependsOn.length
2237
+ ? `starts after ${dependsOn.join(', ')}`
2238
+ : 'can start immediately';
2239
+ const reviewJob = task.reviewJobId ? ` via ${task.reviewJobId}` : '';
2240
+ const reviewText = task.reviewType || task.reviewHandoff?.reviewRequired || child?.reviewHandoff?.reviewRequired
2241
+ ? `Mandy validates this output${reviewJob} before synthesis`
2242
+ : 'Mandy validates this output before synthesis';
2243
+ return {
2244
+ index,
2245
+ title: task.title || task.jobId || task.taskId || `Delegated job ${index + 1}`,
2246
+ employee: delegationPersonaLabel(task.personaKey),
2247
+ dependencyText,
2248
+ reviewText,
2249
+ instructions: task.instructions || task.latestSummary || '',
2250
+ status,
2251
+ statusLabel: statusLabel(status),
2252
+ };
2253
+ }
2254
+
2255
+ function delegationPersonaLabel(personaKey) {
2256
+ if (!personaKey) return 'Unassigned';
2257
+ const persona = personaMap().get(personaKey);
2258
+ return persona ? persona.displayName : personaKey;
2259
+ }
2260
+
2261
+ function delegationTaskConversation(task, ledger) {
2262
+ if (!task || !ledger) return null;
2263
+ const conversations = projectConversations();
2264
+ const ids = new Set([task.conversationId, task.runId].filter(Boolean));
2265
+ const managerIds = new Set([ledger.managerRunId, ledger.rootRunId].filter(Boolean));
2266
+ return conversations.find((conv) =>
2267
+ ids.has(conv.id) ||
2268
+ ids.has(conv.runId) ||
2269
+ (conv.delegationTaskId === task.taskId && (!conv.managedByRunId || managerIds.has(conv.managedByRunId)))
2270
+ ) || null;
2271
+ }
2272
+
2273
+ function delegatedTaskStatus(task, childConv) {
2274
+ if (!childConv) return task.status;
2275
+ if (childConv.status === 'running') return 'running';
2276
+ if (childConv.status === 'failed') return 'blocked';
2277
+ if (childConv.status === 'completed' && childConv.managedReviewStatus === 'reviewed') return 'reviewed';
2278
+ if (childConv.status === 'completed') return 'submitted';
2279
+ return task.status || 'planned';
2280
+ }
2281
+
2282
+ function delegatedTaskSummary(task, childConv) {
2283
+ if (childConv) {
2284
+ const latest = latestEmployeeSurfaceText(childConv);
2285
+ if (latest) return latest;
2286
+ }
2287
+ return task.latestSummary || task.instructions || 'Waiting for an update.';
2288
+ }
2289
+
2290
+ function delegatedTaskArtifacts(task, childConv) {
2291
+ if (childConv?.reviewHandoff?.artifacts?.length) return childConv.reviewHandoff.artifacts;
2292
+ if (childConv?.artifacts?.length) return childConv.artifacts;
2293
+ if (task.reviewHandoff?.artifacts?.length) return task.reviewHandoff.artifacts;
2294
+ return task.artifacts || [];
2295
+ }
2296
+
2297
+ function renderDelegationLedger(conv) {
2298
+ const host = ensureDelegationHost();
2299
+ if (!host) return;
2300
+ const ledger = delegationLedgerForConversation(conv);
2301
+ if (!ledger) {
2302
+ host.hidden = true;
2303
+ host.innerHTML = '';
2304
+ return;
2305
+ }
2306
+
2307
+ host.hidden = false;
2308
+ const panelState = panelStateFor(conv.id);
2309
+ const shouldOpen = panelState.delegation ?? !isManagerOversightConversation(conv);
2310
+ if (host.open !== shouldOpen) host.open = shouldOpen;
2311
+ host.innerHTML = '';
2312
+ const summaryRow = document.createElement('summary');
2313
+ const header = document.createElement('div');
2314
+ header.className = 'delegation-ledger-header';
2315
+ const copy = document.createElement('div');
2316
+ copy.className = 'delegation-ledger-copy';
2317
+ const kicker = document.createElement('span');
2318
+ kicker.className = 'delegation-ledger-kicker';
2319
+ kicker.textContent = 'Delegation board';
2320
+ const title = document.createElement('strong');
2321
+ title.textContent = ledger.objective;
2322
+ copy.appendChild(kicker);
2323
+ copy.appendChild(title);
2324
+ const meta = document.createElement('span');
2325
+ meta.className = 'delegation-ledger-meta';
2326
+ const orchestrator = delegationPersonaLabel(ledger.orchestratorPersonaKey || conv.personaKey);
2327
+ meta.textContent = `${orchestrator} coordinating ${ledger.tasks.length} workstream${ledger.tasks.length === 1 ? '' : 's'}`;
2328
+ header.appendChild(copy);
2329
+ header.appendChild(meta);
2330
+ summaryRow.appendChild(header);
2331
+ host.appendChild(summaryRow);
2332
+
2333
+ const body = document.createElement('div');
2334
+ body.className = 'delegation-ledger-body';
2335
+
2336
+ if (ledger.latestSummary) {
2337
+ const summary = document.createElement('p');
2338
+ summary.className = 'delegation-ledger-summary';
2339
+ summary.textContent = ledger.latestSummary;
2340
+ body.appendChild(summary);
2341
+ }
2342
+
2343
+ const list = document.createElement('div');
2344
+ list.className = 'delegation-task-list';
2345
+ for (const task of ledger.tasks) {
2346
+ const childConv = delegationTaskConversation(task, ledger);
2347
+ const taskStatus = delegatedTaskStatus(task, childConv);
2348
+ const row = document.createElement('article');
2349
+ row.className = `delegation-task delegation-task--${taskStatus}`;
2350
+ const top = document.createElement('div');
2351
+ top.className = 'delegation-task-top';
2352
+ const assignee = document.createElement('span');
2353
+ assignee.className = 'delegation-task-assignee';
2354
+ assignee.textContent = delegationPersonaLabel(task.personaKey);
2355
+ const status = document.createElement('span');
2356
+ status.className = `delegation-task-status status-${taskStatus}`;
2357
+ status.textContent = childConv ? conversationStateLabel(childConv) : taskStatus;
2358
+ top.appendChild(assignee);
2359
+ top.appendChild(status);
2360
+
2361
+ const taskTitle = document.createElement('strong');
2362
+ taskTitle.className = 'delegation-task-title';
2363
+ taskTitle.textContent = task.title;
2364
+ const detail = document.createElement('div');
2365
+ detail.className = 'delegation-task-detail';
2366
+ detail.textContent = [
2367
+ childConv?.jobId || task.jobId,
2368
+ childConv?.sessionId || task.hostSessionId ? `session ${childConv?.sessionId || task.hostSessionId}` : '',
2369
+ task.hostThreadId ? `thread ${task.hostThreadId}` : '',
2370
+ ].filter(Boolean).join(' · ');
2371
+ const summary = document.createElement('p');
2372
+ summary.className = 'delegation-task-summary';
2373
+ summary.textContent = delegatedTaskSummary(task, childConv);
2374
+
2375
+ row.appendChild(top);
2376
+ row.appendChild(taskTitle);
2377
+ if (detail.textContent) row.appendChild(detail);
2378
+ row.appendChild(summary);
2379
+
2380
+ const artifacts = delegatedTaskArtifacts(task, childConv);
2381
+ if (artifacts.length) {
2382
+ const artifactStrip = document.createElement('div');
2383
+ artifactStrip.className = 'delegation-artifacts';
2384
+ for (const artifact of artifacts) {
2385
+ const chip = document.createElement('button');
2386
+ chip.type = 'button';
2387
+ chip.className = 'delegation-artifact';
2388
+ chip.textContent = artifactLabel(artifact);
2389
+ chip.title = artifactLocalPath(artifact) || artifactExternalUrl(artifact) || artifactLabel(artifact);
2390
+ chip.addEventListener('click', async () => {
2391
+ try {
2392
+ await openReviewArtifact(artifact);
2393
+ } catch (err) {
2394
+ showStatus(err instanceof Error ? err.message : 'Could not open artifact.', true);
2395
+ }
2396
+ });
2397
+ artifactStrip.appendChild(chip);
2398
+ }
2399
+ row.appendChild(artifactStrip);
2400
+ } else if (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed') {
2401
+ const inline = document.createElement('div');
2402
+ inline.className = 'delegation-review-note';
2403
+ inline.textContent = 'Inline deliverable; no file artifact reported';
2404
+ row.appendChild(inline);
2405
+ }
2406
+
2407
+ if (childConv) {
2408
+ const view = document.createElement('button');
2409
+ view.type = 'button';
2410
+ view.className = 'delegation-view-work';
2411
+ view.textContent = 'View work';
2412
+ view.addEventListener('click', () => switchToConversation(childConv.id));
2413
+ row.appendChild(view);
2414
+ }
2415
+
2416
+ if (task.reviewHandoff?.reviewRequired || childConv?.reviewHandoff?.reviewRequired) {
2417
+ const review = document.createElement('div');
2418
+ review.className = 'delegation-review-note';
2419
+ review.textContent = 'Ready for Mandy review';
2420
+ row.appendChild(review);
2421
+ }
2422
+ list.appendChild(row);
2423
+ }
2424
+ body.appendChild(list);
2425
+ host.appendChild(body);
2426
+ }
2427
+
1906
2428
  // Issue #442: render Direct totals row using the same pushTotalsSpan helper as FRAIM.
1907
2429
  function renderDirectTotals(conv) {
1908
2430
  const dtotals = els['ab-direct-totals'];
@@ -2098,6 +2620,7 @@ function humanizeSlug(slug) {
2098
2620
  function stripStubReference(text) {
2099
2621
  return String(text || '')
2100
2622
  .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
2623
+ .replace(/\[Thought:\s*true\]\s*/gi, '')
2101
2624
  .replace(/\s+/g, ' ')
2102
2625
  .trim();
2103
2626
  }
@@ -2278,6 +2801,10 @@ function scrollThreadForReview(conv) {
2278
2801
  function syncSendButton() {
2279
2802
  const conv = activeConversation();
2280
2803
  const hasText = els['coach-text'].value.trim().length > 0;
2804
+ if (isManagedDelegationChild(conv)) {
2805
+ els['send'].disabled = true;
2806
+ return;
2807
+ }
2281
2808
  // Send is enabled as soon as the host session exists. We deliberately
2282
2809
  // do NOT gate on status='running' — the manager should be able to
2283
2810
  // coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
@@ -2291,6 +2818,9 @@ function syncSendButton() {
2291
2818
  }
2292
2819
 
2293
2820
  function defaultCoachNote(conv) {
2821
+ if (isManagedDelegationChild(conv)) {
2822
+ return `${managedByPersonaLabel(conv)} manages coaching and review for this delegated workstream. You can inspect the work, but coach the manager job instead.`;
2823
+ }
2294
2824
  return conv && conv.status === 'running'
2295
2825
  ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
2296
2826
  : 'The employee is waiting on you. Send the next instruction to continue this run.';
@@ -2308,6 +2838,34 @@ function syncCoachNote(conv = activeConversation()) {
2308
2838
  els['coach-note'].textContent = defaultCoachNote(conv);
2309
2839
  }
2310
2840
 
2841
+ function syncManagedDelegationAccess(conv = activeConversation()) {
2842
+ const managed = isManagedDelegationChild(conv);
2843
+ const textarea = els['coach-text'];
2844
+ if (textarea) {
2845
+ textarea.disabled = managed;
2846
+ textarea.placeholder = managed
2847
+ ? `${managedByPersonaLabel(conv)} owns coaching for this delegated workstream`
2848
+ : 'Tell the employee what to do next...';
2849
+ }
2850
+ if (els['send']) els['send'].disabled = managed || els['send'].disabled;
2851
+ if (els['coach-panel']) els['coach-panel'].classList.toggle('coach-panel--managed-readonly', managed);
2852
+ for (const selector of ['#quick-coach-btns button', '#mark-complete-btn', '#other-manager-jobs-btn', '#review-actions button']) {
2853
+ for (const button of document.querySelectorAll(selector)) {
2854
+ if (managed) {
2855
+ if (button.dataset.managedPrevDisabled === undefined) {
2856
+ button.dataset.managedPrevDisabled = button.disabled ? '1' : '0';
2857
+ }
2858
+ button.disabled = true;
2859
+ } else if (button.dataset.managedPrevDisabled !== undefined) {
2860
+ button.disabled = button.dataset.managedPrevDisabled === '1';
2861
+ delete button.dataset.managedPrevDisabled;
2862
+ }
2863
+ button.classList.toggle('managed-disabled', managed);
2864
+ }
2865
+ }
2866
+ if (managed) closeTemplatePopover();
2867
+ }
2868
+
2311
2869
  function appendEditableCoachingPrompt(promptText) {
2312
2870
  const textarea = els['coach-text'];
2313
2871
  if (!textarea) return;
@@ -2366,6 +2924,11 @@ function convAwaitingReview(conv) {
2366
2924
  const handoff = reviewHandoffForConversation(conv);
2367
2925
  if (handoff) return handoff.reviewRequired === true;
2368
2926
  if (reviewHandoffIssueForConversation(conv)) return true;
2927
+ // Delegation orchestration runs (fully-delegate with an active ledger) only enter review
2928
+ // via an explicit review_handoff — Mandy's delegation artifacts are orchestration working
2929
+ // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
2930
+ // stakeholder-status-reporting) may legitimately surface artifacts for human review.
2931
+ if (delegationLedgerForConversation(conv)) return false;
2369
2932
  if (conv.status === 'completed' && Array.isArray(conv.artifacts) && conv.artifacts.length > 0) return true;
2370
2933
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
2371
2934
  // only belongs when the employee actually submits for review (a review_handoff,
@@ -2475,6 +3038,98 @@ function extractReviewHandoffFromText(text) {
2475
3038
  return null;
2476
3039
  }
2477
3040
 
3041
+ const DELEGATION_TASK_STATUSES = new Set(['planned', 'running', 'submitted', 'reviewed', 'blocked', 'completed', 'failed']);
3042
+
3043
+ function cleanString(value) {
3044
+ return typeof value === 'string' ? value.trim() : '';
3045
+ }
3046
+
3047
+ function cleanNullableString(value) {
3048
+ const cleaned = cleanString(value);
3049
+ return cleaned ? cleaned : null;
3050
+ }
3051
+
3052
+ function normalizeDelegationLedger(raw) {
3053
+ if (!raw || typeof raw !== 'object' || raw.delegationRequired !== true) return null;
3054
+ const objective = cleanString(raw.objective);
3055
+ if (!objective) return null;
3056
+ const tasks = Array.isArray(raw.tasks)
3057
+ ? raw.tasks.map((task, index) => {
3058
+ if (!task || typeof task !== 'object') return null;
3059
+ const title = cleanString(task.title);
3060
+ if (!title) return null;
3061
+ const fallbackTaskId = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || `task-${index + 1}`;
3062
+ const status = DELEGATION_TASK_STATUSES.has(cleanString(task.status)) ? cleanString(task.status) : 'planned';
3063
+ const artifacts = Array.isArray(task.artifacts)
3064
+ ? task.artifacts.map(normalizeReviewArtifact).filter(Boolean)
3065
+ : [];
3066
+ return {
3067
+ taskId: cleanString(task.taskId) || fallbackTaskId,
3068
+ title,
3069
+ status,
3070
+ personaKey: cleanNullableString(task.personaKey),
3071
+ jobId: cleanNullableString(task.jobId),
3072
+ instructions: cleanString(task.instructions),
3073
+ latestSummary: cleanString(task.latestSummary),
3074
+ hostThreadId: cleanNullableString(task.hostThreadId),
3075
+ hostSessionId: cleanNullableString(task.hostSessionId),
3076
+ runId: cleanNullableString(task.runId),
3077
+ conversationId: cleanNullableString(task.conversationId),
3078
+ dependsOn: Array.isArray(task.dependsOn) ? task.dependsOn.map(cleanString).filter(Boolean) : [],
3079
+ artifacts,
3080
+ reviewHandoff: normalizeReviewHandoff(task.reviewHandoff),
3081
+ };
3082
+ }).filter(Boolean)
3083
+ : [];
3084
+ if (!tasks.length) return null;
3085
+ return {
3086
+ delegationRequired: true,
3087
+ objective,
3088
+ orchestratorPersonaKey: cleanNullableString(raw.orchestratorPersonaKey),
3089
+ rootRunId: cleanNullableString(raw.rootRunId),
3090
+ managerRunId: cleanNullableString(raw.managerRunId),
3091
+ latestSummary: cleanString(raw.latestSummary),
3092
+ tasks,
3093
+ };
3094
+ }
3095
+
3096
+ function extractDelegationLedgerFromText(text) {
3097
+ if (!text || !/delegationRequired|delegation_ledger|delegationLedger/i.test(text)) return null;
3098
+ const candidates = [];
3099
+ const fenced = String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi);
3100
+ for (const match of fenced) candidates.push(match[1]);
3101
+ const tagged = String(text).match(/<delegation_ledger>\s*([\s\S]*?)\s*<\/delegation_ledger>/i);
3102
+ if (tagged) candidates.push(tagged[1]);
3103
+ const inline = String(text).match(/(\{\s*"delegationRequired"[\s\S]*\})/i);
3104
+ if (inline) candidates.push(inline[1]);
3105
+
3106
+ for (const candidate of candidates) {
3107
+ try {
3108
+ const ledger = normalizeDelegationLedger(JSON.parse(candidate.trim()));
3109
+ if (ledger) return ledger;
3110
+ } catch {
3111
+ // Ignore malformed snippets; the raw transcript is still available.
3112
+ }
3113
+ }
3114
+ return null;
3115
+ }
3116
+
3117
+ function delegationLedgerForConversation(conv) {
3118
+ if (!conv) return null;
3119
+ const existing = normalizeDelegationLedger(conv.delegation);
3120
+ if (existing) return existing;
3121
+ const messages = conv.messages || [];
3122
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
3123
+ if (messages[i].role !== 'employee') continue;
3124
+ const parsed = extractDelegationLedgerFromText(messages[i].text);
3125
+ if (parsed) {
3126
+ conv.delegation = parsed;
3127
+ return parsed;
3128
+ }
3129
+ }
3130
+ return null;
3131
+ }
3132
+
2478
3133
  function hasReviewHandoffSignal(text) {
2479
3134
  return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
2480
3135
  }
@@ -2622,8 +3277,22 @@ function deriveDeliverableFormat(conv) {
2622
3277
 
2623
3278
  const hay = ((conv && (conv.jobId || '')) + ' ' + (conv && (conv.jobTitle || ''))).toLowerCase();
2624
3279
  // Prefer an explicit artifact path captured from the run when present.
2625
- const artifact = (conv && conv.artifacts && conv.artifacts[0]) || null;
2626
- const artifactName = artifact ? artifact.name : '';
3280
+ const artifacts = Array.isArray(conv && conv.artifacts) ? conv.artifacts : [];
3281
+ if (artifacts.length > 1) {
3282
+ return {
3283
+ key: 'artifact_set',
3284
+ label: `${artifacts.length} artifacts`,
3285
+ actions: artifacts.map((artifact, index) => ({
3286
+ id: `open-artifact-${index}`,
3287
+ label: artifactLabel(artifact),
3288
+ primary: index === 0,
3289
+ kind: artifactActionKind(artifact),
3290
+ artifact,
3291
+ })),
3292
+ };
3293
+ }
3294
+ const artifact = artifacts[0] || null;
3295
+ const artifactName = artifact ? (artifact.name || artifact.path || artifact.url || artifact.label || '') : '';
2627
3296
  const ext = artifactName.includes('.') ? artifactName.split('.').pop().toLowerCase() : '';
2628
3297
 
2629
3298
  const isCode = /implementation|fix|refactor|\bpr\b|pull-request|code|bug|engineering/.test(hay)
@@ -3059,7 +3728,7 @@ function tfShowDoneReviewing(conv, docxPath) {
3059
3728
  wrap.className = 'rc-done-reviewing';
3060
3729
  const label = document.createElement('div');
3061
3730
  label.className = 'rdr-label';
3062
- label.textContent = 'Open the .docx in Word, add your comments, then save. When ready:';
3731
+ label.textContent = 'Open the doc in your preferred editor (Word, Google Docs, etc.), add comments, then save. When ready:';
3063
3732
  const btn = document.createElement('button');
3064
3733
  btn.type = 'button';
3065
3734
  btn.className = 'rdr-done-btn';
@@ -3073,15 +3742,15 @@ function tfShowDoneReviewing(conv, docxPath) {
3073
3742
  ? docxPath.replace((state.projectPath || '').replace(/\\/g, '/').replace(/\/$/, '') + '/', '')
3074
3743
  : '';
3075
3744
  const message = rel
3076
- ? `I've reviewed the document. My feedback is in the Word comments and tracked changes inside \`${rel}\`. Please read every one and address them all in a revision.`
3077
- : `I've finished reviewing the .docx. My feedback is in the Word comments and tracked changes — please read them all and address them in a revision.`;
3745
+ ? `I've reviewed the document. My feedback is in the doc comments and tracked changes inside \`${rel}\`. Please read every one and address them all in a revision.`
3746
+ : `I've finished reviewing the doc. My feedback is in the doc comments and tracked changes — please read them all and address them in a revision.`;
3078
3747
  conv.pendingDocxReview = docxPath || null;
3079
3748
  conv.reviewApproved = false;
3080
3749
  upsertConversation(conv);
3081
3750
  try {
3082
3751
  await continueRun(message);
3083
3752
  wrap.remove();
3084
- showStatus('Comments sent — the employee will read your Word annotations and come back with a revision.', false);
3753
+ showStatus('Comments sent — the employee will read your doc annotations and come back with a revision.', false);
3085
3754
  } catch (err) {
3086
3755
  btn.disabled = false;
3087
3756
  btn.textContent = '✓ Done reviewing — send my comments to the employee';
@@ -3105,7 +3774,7 @@ function tfShowDocxUpload(conv) {
3105
3774
  // is exactly why "Mark complete" left the Project Onboarding dot stale.
3106
3775
  function refreshStatusSurfaces() {
3107
3776
  if (typeof renderRail === 'function') renderRail();
3108
- if (document.querySelector('.workspace-conv')) {
3777
+ if (document.querySelector('#proj-workspace .workspace-conv')) {
3109
3778
  if (typeof tfRenderTree === 'function') tfRenderTree();
3110
3779
  if (typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
3111
3780
  }
@@ -3225,8 +3894,17 @@ function wirePopovers() {
3225
3894
 
3226
3895
  if (e.key === 'Escape') {
3227
3896
  const cpModal = document.getElementById('cp-modal');
3228
- if (cpModal && !cpModal.hidden) {
3897
+ const schedModal = document.getElementById('dep-schedule-modal');
3898
+ const webhookModal = document.getElementById('dep-webhook-modal');
3899
+ const aomModal = document.getElementById('area-onboard-modal');
3900
+ if (aomModal && !aomModal.hidden) {
3901
+ tfCloseAreaOnboardModal();
3902
+ } else if (cpModal && !cpModal.hidden) {
3229
3903
  closePalette();
3904
+ } else if (schedModal && !schedModal.hidden) {
3905
+ schedModal.hidden = true;
3906
+ } else if (webhookModal && !webhookModal.hidden) {
3907
+ webhookModal.hidden = true;
3230
3908
  } else if (els['modal'].classList.contains('open')) {
3231
3909
  closeModal();
3232
3910
  } else {
@@ -3529,8 +4207,9 @@ function rerunLastJob() {
3529
4207
  openPalette();
3530
4208
  return;
3531
4209
  }
3532
- showRerunToast('Re-running: ' + (state.lastRun.job.title || state.lastRun.job.id));
3533
- startRun(state.lastRun.job, state.lastRun.instructions, state.lastRun.employeeId);
4210
+ const lastRun = state.lastRun;
4211
+ startRun(lastRun.job, lastRun.instructions, lastRun.employeeId);
4212
+ showRerunToast('Re-running: ' + (lastRun.job.title || lastRun.job.id));
3534
4213
  }
3535
4214
 
3536
4215
  function showRerunToast(msg) {
@@ -3538,7 +4217,7 @@ function showRerunToast(msg) {
3538
4217
  t.className = 'cp-rerun-toast';
3539
4218
  t.textContent = msg;
3540
4219
  document.body.appendChild(t);
3541
- setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 2500);
4220
+ setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
3542
4221
  }
3543
4222
 
3544
4223
  // ---------------------------------------------------------------------------
@@ -3976,14 +4655,14 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
3976
4655
  // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
3977
4656
  wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
3978
4657
  };
3979
- upsertConversation(conv);
3980
- state.activeId = conv.id;
3981
- // Issue #539: make Cmd/Ctrl+Shift+R available as soon as the run is queued,
3982
- // not only after the server responds with the run id.
3983
- state.lastRun = { job, instructions, employeeId };
3984
- persistConversations();
3985
- renderRail();
3986
- renderActive();
4658
+ upsertConversation(conv);
4659
+ state.activeId = conv.id;
4660
+ // Issue #539: make Cmd/Ctrl+Shift+R available as soon as the run is queued,
4661
+ // not only after the server responds with the run id.
4662
+ state.lastRun = { job, instructions, employeeId };
4663
+ persistConversations();
4664
+ renderRail();
4665
+ renderActive();
3987
4666
 
3988
4667
  try {
3989
4668
  const run = await requestJson('/api/ai-hub/runs', {
@@ -4011,8 +4690,8 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
4011
4690
  renderRail();
4012
4691
  renderActive();
4013
4692
  startPolling();
4014
- // Issue #539: update in-memory preferences so the next palette open shows
4015
- // the correct recent section.
4693
+ // Issue #539: update in-memory preferences so the next palette open shows
4694
+ // the correct recent section.
4016
4695
  if (state.bootstrap && state.bootstrap.preferences) {
4017
4696
  const prefs = state.bootstrap.preferences;
4018
4697
  const nextIds = [job.id, ...(prefs.recentJobIds || []).filter((id) => id !== job.id)].slice(0, 8);
@@ -4174,6 +4853,9 @@ function foldRunIntoConversation(conv, run) {
4174
4853
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
4175
4854
  if (runHandoff) conv.reviewHandoff = runHandoff;
4176
4855
  else reviewHandoffForConversation(conv);
4856
+ const runDelegation = normalizeDelegationLedger(run.delegation);
4857
+ if (runDelegation) conv.delegation = runDelegation;
4858
+ else delegationLedgerForConversation(conv);
4177
4859
  if (Array.isArray(run.artifacts)) {
4178
4860
  conv.artifacts = run.artifacts;
4179
4861
  }
@@ -4185,9 +4867,10 @@ function foldRunIntoConversation(conv, run) {
4185
4867
  // The browser only parses output text when that structured field is absent.
4186
4868
  if (!Array.isArray(run.artifacts)) {
4187
4869
  for (const e of conv.events) {
4188
- const found = extractArtifact(e.text);
4189
- if (found && !conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
4190
- conv.artifacts.push(found);
4870
+ for (const found of extractArtifacts(e.text)) {
4871
+ if (!conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
4872
+ conv.artifacts.push(found);
4873
+ }
4191
4874
  }
4192
4875
  }
4193
4876
  }
@@ -4209,27 +4892,40 @@ function foldCompareRunIntoConversation(conv, compareRun) {
4209
4892
  };
4210
4893
  }
4211
4894
 
4212
- const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
4213
4895
  // Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
4214
4896
  // raw learnings, evidence dumps, mock files), not deliverables the
4215
4897
  // manager should be drawn to. Excluding them keeps the artifact callout
4216
4898
  // meaningful — it should mean "the employee produced this file for you".
4217
4899
  const ARTIFACT_EXCLUDE_RE = /(^|\/)(retrospectives|evidence|learnings|mocks|raw|archive)\//i;
4218
4900
  function extractArtifact(text) {
4219
- if (!text) return null;
4220
- const match = text.match(ARTIFACT_PATH_RE);
4221
- if (!match) return null;
4222
- const fullPath = match[1];
4223
- if (ARTIFACT_EXCLUDE_RE.test(fullPath)) return null;
4224
- const segments = fullPath.split('/');
4225
- const name = segments[segments.length - 1];
4226
- const where = segments.slice(0, -1).join('/') + '/';
4227
- // Store the absolute path so the agent can re-read the artifact (and any
4228
- // .docx export written alongside it) without guessing the project root.
4229
- const absPath = state.projectPath
4230
- ? state.projectPath.replace(/\\/g, '/').replace(/\/$/, '') + '/' + fullPath
4231
- : null;
4232
- return { name, where, path: absPath };
4901
+ return extractArtifacts(text)[0] || null;
4902
+ }
4903
+
4904
+ function extractArtifacts(text) {
4905
+ if (!text) return [];
4906
+ const artifacts = [];
4907
+ const seen = new Set();
4908
+ const matches = String(text)
4909
+ .split(/[\s`"'()<>{}\[\],;:]+/)
4910
+ .filter((token) => /^(docs|public|src|tests)\//.test(token));
4911
+ for (const candidate of matches) {
4912
+ const fullPath = candidate.replace(/[.)]+$/g, '');
4913
+ if (!/\.[A-Za-z0-9]+$/.test(fullPath)) continue;
4914
+ if (ARTIFACT_EXCLUDE_RE.test(fullPath)) continue;
4915
+ const segments = fullPath.split('/');
4916
+ const name = segments[segments.length - 1];
4917
+ const where = segments.slice(0, -1).join('/') + '/';
4918
+ // Store the absolute path so the agent can re-read the artifact (and any
4919
+ // .docx export written alongside it) without guessing the project root.
4920
+ const absPath = state.projectPath
4921
+ ? state.projectPath.replace(/\\/g, '/').replace(/\/$/, '') + '/' + fullPath
4922
+ : null;
4923
+ const key = absPath || fullPath;
4924
+ if (seen.has(key)) continue;
4925
+ seen.add(key);
4926
+ artifacts.push({ name, where, path: absPath });
4927
+ }
4928
+ return artifacts;
4233
4929
  }
4234
4930
 
4235
4931
  function startPolling() {
@@ -4247,7 +4943,18 @@ function startPolling() {
4247
4943
  }
4248
4944
  try {
4249
4945
  if (!fraimDone) {
4250
- const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
4946
+ const runResp = await fetch(`/api/ai-hub/runs/${conv.runId}`, { headers: { 'x-api-key': 'local-dev' } });
4947
+ if (runResp.status === 404) {
4948
+ // Run no longer in server registry (e.g. after restart). Stop polling.
4949
+ window.clearInterval(state.pollHandle);
4950
+ state.pollHandle = null;
4951
+ conv.status = 'done';
4952
+ upsertConversation(conv);
4953
+ renderRail();
4954
+ renderActive();
4955
+ return;
4956
+ }
4957
+ const run = await runResp.json();
4251
4958
  foldRunIntoConversation(conv, run);
4252
4959
  }
4253
4960
  // Issue #442: also poll the compare run when present and not yet terminal.
@@ -4258,6 +4965,9 @@ function startPolling() {
4258
4965
  upsertConversation(conv);
4259
4966
  renderRail();
4260
4967
  renderActive();
4968
+ // #594 R2: keep the Company/Manager area conv panel in sync while polling.
4969
+ if (tf.area === 'company') tfRenderCompany();
4970
+ else if (tf.area === 'manager') tfRenderManager();
4261
4971
  // #521: when a project-onboarding run posts its understanding (reaches the
4262
4972
  // review gate) or completes, refresh the Brief once so the team's captured
4263
4973
  // context appears there for the manager to review/edit and then approve.
@@ -4310,7 +5020,7 @@ async function tryWordWriteBack(conv) {
4310
5020
  const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
4311
5021
  await requestWordContext(action, { text: outputText });
4312
5022
  await requestWordContext('track-changes-off');
4313
- showStatus('Result written to document — review tracked changes in Word.');
5023
+ showStatus('Result written to document — review tracked changes.');
4314
5024
  } catch (e) {
4315
5025
  console.warn('Word write-back failed:', e);
4316
5026
  }
@@ -4326,6 +5036,15 @@ function convNeedsPolling(conv) {
4326
5036
  function switchToConversation(id) {
4327
5037
  state.activeId = id;
4328
5038
  persistConversations();
5039
+ // For Company/Manager: move the shared .page into this area's conv-host and
5040
+ // switch from the info view to the full coaching panel.
5041
+ if (tf.area === 'company' || tf.area === 'manager') {
5042
+ tfEnsurePageInArea(tf.area);
5043
+ const host = document.getElementById(tf.area + '-conv-host');
5044
+ const info = document.getElementById(tf.area + '-info-view');
5045
+ if (host) host.hidden = false;
5046
+ if (info) info.hidden = true;
5047
+ }
4329
5048
  renderRail();
4330
5049
  renderActive();
4331
5050
  const conv = activeConversation();
@@ -4466,9 +5185,22 @@ function renderManagerTeamPool() {
4466
5185
  const personas = (state.bootstrap?.personas || []).filter((p) => (p.seatCount || 0) > 0);
4467
5186
  const managerTeamKeys = new Set((state.bootstrap?.managerTeam || []).map((e) => e.personaKey));
4468
5187
  container.innerHTML = '';
5188
+ if (personas.length === 0) {
5189
+ const msg = document.createElement('p');
5190
+ msg.className = 'manager-no-team-msg';
5191
+ msg.textContent = "You haven't hired any specialists yet. Go to Company to see the full roster and hire the team you need.";
5192
+ container.appendChild(msg);
5193
+ const btn = document.createElement('button');
5194
+ btn.className = 'manager-go-company-btn';
5195
+ btn.type = 'button';
5196
+ btn.textContent = 'Go to Company to hire';
5197
+ btn.addEventListener('click', () => tfShowArea('company'));
5198
+ container.appendChild(btn);
5199
+ return;
5200
+ }
4469
5201
  for (const persona of personas) {
4470
5202
  const row = document.createElement('div');
4471
- row.className = 'team-row';
5203
+ row.className = 'team-row emp-tile';
4472
5204
 
4473
5205
  const dot = document.createElement('span');
4474
5206
  const oos = (persona.seatsInUse || 0) >= (persona.seatCount || 0);
@@ -4921,6 +5653,7 @@ function wireEvents() {
4921
5653
  try {
4922
5654
  await loadBootstrap(null, docUrl);
4923
5655
  await hydrateConversationsFromServer();
5656
+ startBgConvPoll();
4924
5657
  } catch (error) {
4925
5658
  showStatus(error.message, true);
4926
5659
  return;
@@ -5201,7 +5934,6 @@ function renderFirstRunLanding(mode) {
5201
5934
 
5202
5935
  const STORAGE_KEY_PROJECTS_512 = 'fraim.aiHub.projects.v1';
5203
5936
  const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
5204
- const STORAGE_KEY_RAIL_512 = 'fraim.aiHub.railCollapsed.v1';
5205
5937
 
5206
5938
  const tf = {
5207
5939
  enabled: false,
@@ -5214,8 +5946,6 @@ const tf = {
5214
5946
  npSelectedEmployees: [],
5215
5947
  };
5216
5948
 
5217
- const TF_STEP_LABELS = { install: 'Install', company: 'Set up company', hire: 'Hire your team', project: 'Start a project' };
5218
- const TF_STEP_ORDER = ['install', 'company', 'hire', 'project'];
5219
5949
  const TF_AVATAR_COLORS = ['#4a7fd4', '#7c6f61', '#5a9e6e', '#b06a4f', '#6b4c92', '#b07a2e', '#2e8b78', '#a24747'];
5220
5950
  function tfAvatarFor(key, index) {
5221
5951
  const color = TF_AVATAR_COLORS[(index || 0) % TF_AVATAR_COLORS.length];
@@ -5298,7 +6028,25 @@ function tfEmployeeDot(projectId, employeeKey) {
5298
6028
  // Area + sub-tab routing
5299
6029
  // ---------------------------------------------------------------------------
5300
6030
  function tfShowArea(area) {
6031
+ if (!state.areaActiveId) state.areaActiveId = {};
6032
+ // Save Projects' active conv when leaving it, so we can restore on return.
6033
+ if (tf.area === 'projects') state.areaActiveId.projects = state.activeId;
6034
+
5301
6035
  tf.area = area;
6036
+
6037
+ if (area === 'projects') {
6038
+ // Restore the saved Projects conv; discard it if it turned out to be area-scoped.
6039
+ const savedId = state.areaActiveId.projects || null;
6040
+ const savedConv = savedId && Object.values(state.conversations || {}).flat().find((c) => c.id === savedId);
6041
+ state.activeId = (savedConv && !AREA_SCOPED_JOBS.has(savedConv.jobId)) ? savedId : null;
6042
+ // Return the shared .page to the Projects workspace and reset Company/Manager hosts.
6043
+ tfEnsurePageInArea('projects');
6044
+ for (const el of document.querySelectorAll('.area-conv-host')) el.hidden = true;
6045
+ for (const el of document.querySelectorAll('.area-info-view')) el.hidden = false;
6046
+ if (typeof renderRail === 'function') renderRail();
6047
+ if (typeof renderActive === 'function') renderActive();
6048
+ }
6049
+
5302
6050
  for (const el of document.querySelectorAll('.hub-area')) {
5303
6051
  const match = el.id === 'area-' + area;
5304
6052
  el.classList.toggle('on', match);
@@ -5353,6 +6101,8 @@ function tfSelectProjectView(view, projectId) {
5353
6101
  tfRenderTree();
5354
6102
  tfRenderProjectContextTop();
5355
6103
  tfApplyWorkspaceMode();
6104
+ // Issue #578: reload deployment roster when entering workspace.
6105
+ loadDeployments();
5356
6106
  }
5357
6107
  }
5358
6108
 
@@ -5834,6 +6584,393 @@ function tfShowProjectBrief() { const a = document.getElementById('proj-brief-ac
5834
6584
  function tfShowProjectLearnings() { const a = document.getElementById('proj-learnings-acc'); if (a) a.open = true; }
5835
6585
  function tfHideProjectLearnings() { /* panels removed in #521; brief/learnings are inline top sections */ }
5836
6586
 
6587
+ // ---------------------------------------------------------------------------
6588
+ // Issue #578: Deployment roster (scheduled + webhook triggers)
6589
+ // ---------------------------------------------------------------------------
6590
+
6591
+ async function loadDeployments() {
6592
+ const list = document.getElementById('proj-deployments-list');
6593
+ if (!list) return;
6594
+ try {
6595
+ const [schResp, whResp] = await Promise.all([
6596
+ fetch('/api/ai-hub/schedules'),
6597
+ fetch('/api/ai-hub/webhooks'),
6598
+ ]);
6599
+ const schedules = schResp.ok ? await schResp.json() : [];
6600
+ const webhooks = whResp.ok ? await whResp.json() : [];
6601
+ renderDeploymentList(list, [...schedules, ...webhooks]);
6602
+ } catch {
6603
+ list.textContent = 'Could not load deployments.';
6604
+ }
6605
+ }
6606
+
6607
+ let _editingDepId = null;
6608
+ let _activeSchPreset = null;
6609
+
6610
+ function detectPreset(expr) {
6611
+ if (!expr) return null;
6612
+ const parts = expr.trim().split(/\s+/);
6613
+ if (parts.length !== 5) return 'custom';
6614
+ const [min, hour, dom, month, dow] = parts;
6615
+ if (dom !== '*' || month !== '*') return 'custom';
6616
+ if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
6617
+ if (dow === '*') return 'daily';
6618
+ if (dow === '1-5') return 'weekdays';
6619
+ if (/^\d+$/.test(dow)) return 'weekly';
6620
+ }
6621
+ return 'custom';
6622
+ }
6623
+
6624
+ function updateSchCronFromPreset(preset, timeValue, dayValue) {
6625
+ const [hStr, mStr] = (timeValue || '09:00').split(':');
6626
+ const h = parseInt(hStr, 10) || 9;
6627
+ const m = parseInt(mStr, 10) || 0;
6628
+ if (preset === 'daily') return `${m} ${h} * * *`;
6629
+ if (preset === 'weekdays') return `${m} ${h} * * 1-5`;
6630
+ if (preset === 'weekly') return `${m} ${h} * * ${dayValue || 1}`;
6631
+ return '';
6632
+ }
6633
+
6634
+ function applySchPreset(preset) {
6635
+ _activeSchPreset = preset;
6636
+ document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(c => {
6637
+ c.classList.toggle('active', c.dataset.preset === preset);
6638
+ });
6639
+ const timeRow = document.getElementById('dep-sch-time-row');
6640
+ const dayField = document.getElementById('dep-sch-day-field');
6641
+ const customRow = document.getElementById('dep-sch-custom-row');
6642
+ const timeInput = document.getElementById('dep-sch-time');
6643
+ const dayInput = document.getElementById('dep-sch-day');
6644
+ const cronPresetInput = document.getElementById('dep-sch-cron-preset');
6645
+ const preview = document.getElementById('dep-sch-cron-preview');
6646
+ const isCustom = preset === 'custom';
6647
+ const hasTime = preset !== null && !isCustom;
6648
+ if (timeRow) timeRow.hidden = !hasTime;
6649
+ if (dayField) dayField.hidden = preset !== 'weekly';
6650
+ if (customRow) customRow.hidden = !isCustom;
6651
+ if (hasTime && cronPresetInput && timeInput) {
6652
+ const cron = updateSchCronFromPreset(preset, timeInput.value, dayInput ? dayInput.value : '1');
6653
+ cronPresetInput.value = cron;
6654
+ if (preview) preview.textContent = cronToHuman(cron);
6655
+ }
6656
+ }
6657
+
6658
+ function cronToHuman(expr) {
6659
+ if (!expr) return '';
6660
+ const parts = expr.trim().split(/\s+/);
6661
+ if (parts.length < 5) return expr;
6662
+ const [min, hour, dom, month, dow] = parts;
6663
+ if (min.startsWith('*/') && hour === '*' && dom === '*' && month === '*' && dow === '*') {
6664
+ const n = parseInt(min.slice(2), 10);
6665
+ return 'Every ' + n + ' minute' + (n === 1 ? '' : 's');
6666
+ }
6667
+ if (!min.includes('/') && !min.includes(',') && hour === '*' && dom === '*' && month === '*' && dow === '*') {
6668
+ return 'Every hour at :' + min.padStart(2, '0');
6669
+ }
6670
+ const minuteN = parseInt(min, 10);
6671
+ const hourN = parseInt(hour, 10);
6672
+ const hasSpecificTime = !isNaN(minuteN) && !isNaN(hourN) && !min.includes('/') && !min.includes(',') && !hour.includes('/') && !hour.includes(',');
6673
+ if (!hasSpecificTime) return expr;
6674
+ const ampm = hourN >= 12 ? 'PM' : 'AM';
6675
+ const h12 = hourN % 12 === 0 ? 12 : hourN % 12;
6676
+ const timeStr = h12 + ':' + String(minuteN).padStart(2, '0') + ' ' + ampm;
6677
+ const dayNames = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
6678
+ if (dom === '*' && month === '*') {
6679
+ if (dow === '*') return 'Daily at ' + timeStr;
6680
+ if (dow === '1-5' || dow === 'MON-FRI' || dow === 'mon-fri') return 'Weekdays at ' + timeStr;
6681
+ if (dow === '0,6' || dow === '6,0' || dow === 'SAT,SUN' || dow === 'SUN,SAT') return 'Weekends at ' + timeStr;
6682
+ const dowN = parseInt(dow, 10);
6683
+ if (!isNaN(dowN) && dowN >= 0 && dowN <= 6) return dayNames[dowN] + ' at ' + timeStr;
6684
+ return 'On ' + dow + ' at ' + timeStr;
6685
+ }
6686
+ return expr;
6687
+ }
6688
+
6689
+ function renderDeploymentList(container, deployments) {
6690
+ container.innerHTML = '';
6691
+ if (!deployments.length) {
6692
+ const empty = document.createElement('p');
6693
+ empty.className = 'dep-empty';
6694
+ empty.textContent = 'No assignments yet. Use + Schedule or + Trigger above to set up scheduled and triggered assignments.';
6695
+ container.appendChild(empty);
6696
+ return;
6697
+ }
6698
+ for (const dep of deployments) {
6699
+ const card = document.createElement('div');
6700
+ card.className = 'dep-card';
6701
+ const top = document.createElement('div');
6702
+ top.className = 'dep-card-top';
6703
+ const icon = document.createElement('span');
6704
+ icon.className = 'dep-type-icon';
6705
+ icon.textContent = dep.type === 'scheduled' ? '⏱' : '🔗';
6706
+ const label = document.createElement('span');
6707
+ label.className = 'dep-label';
6708
+ label.textContent = dep.label;
6709
+ const typeBadge = document.createElement('span');
6710
+ typeBadge.className = 'dep-type-badge dep-type-badge--' + dep.type;
6711
+ typeBadge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
6712
+ top.appendChild(icon);
6713
+ top.appendChild(label);
6714
+ top.appendChild(typeBadge);
6715
+ card.appendChild(top);
6716
+
6717
+ if (dep.cronExpr) {
6718
+ const cron = document.createElement('div');
6719
+ cron.className = 'dep-detail';
6720
+ const human = cronToHuman(dep.cronExpr);
6721
+ cron.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
6722
+ if (human !== dep.cronExpr) cron.title = dep.cronExpr;
6723
+ card.appendChild(cron);
6724
+ }
6725
+
6726
+ const employee = (state.bootstrap?.employees || []).find((e) => e.id === dep.hostId);
6727
+ if (employee) {
6728
+ const empRow = document.createElement('div');
6729
+ empRow.className = 'dep-detail dep-detail--emp';
6730
+ empRow.textContent = employee.label;
6731
+ card.appendChild(empRow);
6732
+ }
6733
+
6734
+ if (dep.inboundUrl) {
6735
+ const urlRow = document.createElement('div');
6736
+ urlRow.className = 'dep-detail dep-detail--url';
6737
+ const urlCode = document.createElement('code');
6738
+ urlCode.textContent = dep.inboundUrl;
6739
+ urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
6740
+ const copyBtn = document.createElement('button');
6741
+ copyBtn.type = 'button';
6742
+ copyBtn.className = 'hm-copy-btn';
6743
+ copyBtn.textContent = 'Copy';
6744
+ copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
6745
+ urlRow.appendChild(urlCode);
6746
+ urlRow.appendChild(copyBtn);
6747
+ card.appendChild(urlRow);
6748
+ }
6749
+
6750
+ const cardActions = document.createElement('div');
6751
+ cardActions.className = 'dep-card-actions';
6752
+
6753
+ const editBtn = document.createElement('button');
6754
+ editBtn.type = 'button';
6755
+ editBtn.className = 'dep-edit-btn';
6756
+ editBtn.title = 'Edit this assignment';
6757
+ editBtn.textContent = 'Edit';
6758
+ editBtn.addEventListener('click', () => { openDeploymentModal(dep.type === 'scheduled' ? 'schedule' : 'webhook', dep); });
6759
+ cardActions.appendChild(editBtn);
6760
+
6761
+ const delBtn = document.createElement('button');
6762
+ delBtn.type = 'button';
6763
+ delBtn.className = 'dep-del-btn';
6764
+ delBtn.title = 'Remove this assignment';
6765
+ delBtn.textContent = 'Remove';
6766
+ delBtn.addEventListener('click', async () => {
6767
+ const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
6768
+ await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
6769
+ await loadDeployments();
6770
+ });
6771
+ cardActions.appendChild(delBtn);
6772
+ card.appendChild(cardActions);
6773
+ container.appendChild(card);
6774
+ }
6775
+ }
6776
+
6777
+ function initDeploymentButtons() {
6778
+ const addSchBtn = document.getElementById('dep-add-schedule-btn');
6779
+ const addWhBtn = document.getElementById('dep-add-webhook-btn');
6780
+ if (addSchBtn) addSchBtn.addEventListener('click', () => openDeploymentModal('schedule'));
6781
+ if (addWhBtn) addWhBtn.addEventListener('click', () => openDeploymentModal('webhook'));
6782
+
6783
+ // Close buttons
6784
+ const schClose = document.getElementById('dep-schedule-close');
6785
+ const whClose = document.getElementById('dep-webhook-close');
6786
+ if (schClose) schClose.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
6787
+ if (whClose) whClose.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
6788
+
6789
+ // Cancel buttons
6790
+ const schCancel = document.getElementById('dep-sch-cancel-btn');
6791
+ const whCancel = document.getElementById('dep-wh-cancel-btn');
6792
+ if (schCancel) schCancel.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
6793
+ if (whCancel) whCancel.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
6794
+
6795
+ // Save buttons
6796
+ const schSave = document.getElementById('dep-sch-save-btn');
6797
+ if (schSave) schSave.addEventListener('click', saveScheduleDeployment);
6798
+ const whSave = document.getElementById('dep-wh-save-btn');
6799
+ if (whSave) whSave.addEventListener('click', saveWebhookDeployment);
6800
+
6801
+ // Preset chips
6802
+ document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(chip => {
6803
+ chip.addEventListener('click', () => applySchPreset(chip.dataset.preset));
6804
+ });
6805
+ const schTimeInput = document.getElementById('dep-sch-time');
6806
+ const schDayInput = document.getElementById('dep-sch-day');
6807
+ const cronPresetInput = document.getElementById('dep-sch-cron-preset');
6808
+ if (schTimeInput) {
6809
+ schTimeInput.addEventListener('change', () => {
6810
+ if (_activeSchPreset && _activeSchPreset !== 'custom') {
6811
+ const cron = updateSchCronFromPreset(_activeSchPreset, schTimeInput.value, schDayInput ? schDayInput.value : '1');
6812
+ if (cronPresetInput) cronPresetInput.value = cron;
6813
+ const prev = document.getElementById('dep-sch-cron-preview');
6814
+ if (prev) prev.textContent = cronToHuman(cron);
6815
+ }
6816
+ });
6817
+ }
6818
+ if (schDayInput) {
6819
+ schDayInput.addEventListener('change', () => {
6820
+ if (_activeSchPreset === 'weekly') {
6821
+ const cron = updateSchCronFromPreset('weekly', schTimeInput ? schTimeInput.value : '09:00', schDayInput.value);
6822
+ if (cronPresetInput) cronPresetInput.value = cron;
6823
+ const prev = document.getElementById('dep-sch-cron-preview');
6824
+ if (prev) prev.textContent = cronToHuman(cron);
6825
+ }
6826
+ });
6827
+ }
6828
+
6829
+ // Live cron preview (custom mode)
6830
+ const cronInput = document.getElementById('dep-sch-cron');
6831
+ const cronPreview = document.getElementById('dep-sch-cron-preview');
6832
+ if (cronInput && cronPreview) {
6833
+ cronInput.addEventListener('input', () => {
6834
+ const human = cronToHuman(cronInput.value.trim());
6835
+ cronPreview.textContent = human && human !== cronInput.value.trim() ? human : '';
6836
+ });
6837
+ }
6838
+
6839
+ // Copy inbound URL button
6840
+ const copyBtn = document.getElementById('dep-wh-copy-btn');
6841
+ if (copyBtn) copyBtn.addEventListener('click', () => {
6842
+ const url = document.getElementById('dep-wh-inbound-url').textContent;
6843
+ navigator.clipboard.writeText(url).catch(() => {});
6844
+ });
6845
+ }
6846
+
6847
+ function openDeploymentModal(type, dep) {
6848
+ const jobs = state.bootstrap?.jobs ?? [];
6849
+ const employees = state.bootstrap?.employees ?? [];
6850
+ const editing = dep != null;
6851
+ _editingDepId = editing ? dep.id : null;
6852
+
6853
+ function populateJobs(sel, currentJobId) {
6854
+ sel.innerHTML = '';
6855
+ for (const j of jobs) {
6856
+ const opt = document.createElement('option');
6857
+ opt.value = j.id;
6858
+ opt.textContent = j.title;
6859
+ sel.appendChild(opt);
6860
+ }
6861
+ if (currentJobId) sel.value = currentJobId;
6862
+ }
6863
+
6864
+ function populateEmployees(sel, currentHostId) {
6865
+ sel.innerHTML = '';
6866
+ const fallback = [{ id: 'claude', label: 'Claude' }, { id: 'codex', label: 'Codex' }, { id: 'gemini', label: 'Gemini' }, { id: 'copilot', label: 'Copilot' }];
6867
+ const list = employees.length ? employees : fallback;
6868
+ for (const e of list) {
6869
+ const opt = document.createElement('option');
6870
+ opt.value = e.id;
6871
+ opt.textContent = e.label;
6872
+ sel.appendChild(opt);
6873
+ }
6874
+ if (currentHostId) sel.value = currentHostId;
6875
+ }
6876
+
6877
+ if (type === 'schedule') {
6878
+ const modal = document.getElementById('dep-schedule-modal');
6879
+ document.getElementById('dep-sch-modal-title').textContent = editing ? 'Edit Scheduled Assignment' : 'New Scheduled Assignment';
6880
+ document.getElementById('dep-sch-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
6881
+ populateJobs(document.getElementById('dep-sch-job'), dep?.jobId);
6882
+ populateEmployees(document.getElementById('dep-sch-host'), dep?.hostId || 'claude');
6883
+ document.getElementById('dep-sch-error').hidden = true;
6884
+ document.getElementById('dep-sch-label').value = dep?.label ?? '';
6885
+ document.getElementById('dep-sch-instructions').value = dep?.instructions ?? '';
6886
+ // Detect preset and pre-fill time/day
6887
+ const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
6888
+ if (preset && preset !== 'custom' && dep?.cronExpr) {
6889
+ const parts = dep.cronExpr.trim().split(/\s+/);
6890
+ const m = parseInt(parts[0], 10) || 0;
6891
+ const h = parseInt(parts[1], 10) || 9;
6892
+ document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
6893
+ if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
6894
+ document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
6895
+ } else if (preset === 'custom' && dep?.cronExpr) {
6896
+ document.getElementById('dep-sch-cron').value = dep.cronExpr;
6897
+ const cronPreview = document.getElementById('dep-sch-cron-preview');
6898
+ if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
6899
+ } else {
6900
+ document.getElementById('dep-sch-cron').value = '';
6901
+ document.getElementById('dep-sch-time').value = '09:00';
6902
+ }
6903
+ applySchPreset(preset || 'daily');
6904
+ modal.hidden = false;
6905
+ } else {
6906
+ const modal = document.getElementById('dep-webhook-modal');
6907
+ document.getElementById('dep-wh-modal-title').textContent = editing ? 'Edit Triggered Assignment' : 'New Triggered Assignment';
6908
+ document.getElementById('dep-wh-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
6909
+ populateJobs(document.getElementById('dep-wh-job'), dep?.jobId);
6910
+ populateEmployees(document.getElementById('dep-wh-host'), dep?.hostId || 'claude');
6911
+ document.getElementById('dep-wh-error').hidden = true;
6912
+ document.getElementById('dep-wh-inbound-row').hidden = true;
6913
+ document.getElementById('dep-wh-label').value = dep?.label ?? '';
6914
+ document.getElementById('dep-wh-instructions').value = dep?.instructions ?? '';
6915
+ modal.hidden = false;
6916
+ }
6917
+ }
6918
+
6919
+ async function saveScheduleDeployment() {
6920
+ const label = document.getElementById('dep-sch-label').value.trim();
6921
+ const jobId = document.getElementById('dep-sch-job').value;
6922
+ const hostId = document.getElementById('dep-sch-host').value;
6923
+ const isCustom = _activeSchPreset === 'custom';
6924
+ const cronExpr = isCustom
6925
+ ? document.getElementById('dep-sch-cron').value.trim()
6926
+ : (document.getElementById('dep-sch-cron-preset').value.trim() || '');
6927
+ const instructions = document.getElementById('dep-sch-instructions').value.trim();
6928
+ const errEl = document.getElementById('dep-sch-error');
6929
+ if (!label || !cronExpr) { errEl.textContent = 'Label and schedule are required.'; errEl.hidden = false; return; }
6930
+ const isEdit = _editingDepId !== null;
6931
+ const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
6932
+ const method = isEdit ? 'PUT' : 'POST';
6933
+ try {
6934
+ const resp = await fetch(url, {
6935
+ method,
6936
+ headers: { 'Content-Type': 'application/json' },
6937
+ body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
6938
+ });
6939
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
6940
+ _editingDepId = null;
6941
+ document.getElementById('dep-schedule-modal').hidden = true;
6942
+ await loadDeployments();
6943
+ } catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
6944
+ }
6945
+
6946
+ async function saveWebhookDeployment() {
6947
+ const label = document.getElementById('dep-wh-label').value.trim();
6948
+ const jobId = document.getElementById('dep-wh-job').value;
6949
+ const hostId = document.getElementById('dep-wh-host').value;
6950
+ const instructions = document.getElementById('dep-wh-instructions').value.trim();
6951
+ const errEl = document.getElementById('dep-wh-error');
6952
+ if (!label) { errEl.textContent = 'Label is required.'; errEl.hidden = false; return; }
6953
+ const isEdit = _editingDepId !== null;
6954
+ const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
6955
+ const method = isEdit ? 'PUT' : 'POST';
6956
+ try {
6957
+ const resp = await fetch(url, {
6958
+ method,
6959
+ headers: { 'Content-Type': 'application/json' },
6960
+ body: JSON.stringify({ label, jobId, hostId, instructions: instructions || undefined }),
6961
+ });
6962
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
6963
+ if (!isEdit) {
6964
+ const dep = await resp.json();
6965
+ document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
6966
+ document.getElementById('dep-wh-inbound-row').hidden = false;
6967
+ }
6968
+ _editingDepId = null;
6969
+ if (isEdit) document.getElementById('dep-webhook-modal').hidden = true;
6970
+ await loadDeployments();
6971
+ } catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
6972
+ }
6973
+
5837
6974
  // ---------------------------------------------------------------------------
5838
6975
  // Company / Manager / Brain content
5839
6976
  // ---------------------------------------------------------------------------
@@ -5902,6 +7039,54 @@ async function tfSaveContext(key, content) {
5902
7039
  return data;
5903
7040
  }
5904
7041
 
7042
+ // #594 R3/R4: GitHub push helpers.
7043
+ function tfIsGithubConfigured() {
7044
+ return !!(state.bootstrap && state.bootstrap.repository && state.bootstrap.repository.provider === 'github');
7045
+ }
7046
+
7047
+ // #594 R4: Render a dismissable inline push bar into `container` when GitHub is
7048
+ // configured. Called after a successful inline context save so the manager can
7049
+ // push the change to the team repo with one click.
7050
+ function tfMaybeShowPushPrompt(key, container) {
7051
+ if (!tfIsGithubConfigured()) return;
7052
+ const existing = container && container.querySelector('.ctx-push-bar');
7053
+ if (existing) existing.remove();
7054
+ const bar = document.createElement('div');
7055
+ bar.className = 'ctx-push-bar';
7056
+ const msg = document.createElement('span');
7057
+ msg.className = 'ctx-push-msg';
7058
+ msg.textContent = 'Change saved locally. Push to share with your team?';
7059
+ const btn = document.createElement('button');
7060
+ btn.className = 'ctx-push-btn';
7061
+ btn.type = 'button';
7062
+ btn.textContent = 'Push to team';
7063
+ btn.addEventListener('click', async () => {
7064
+ btn.disabled = true;
7065
+ btn.textContent = 'Pushing...';
7066
+ try {
7067
+ await requestJson('/api/ai-hub/context/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
7068
+ bar.remove();
7069
+ } catch {
7070
+ btn.disabled = false;
7071
+ btn.textContent = 'Push to team';
7072
+ }
7073
+ });
7074
+ const dismiss = document.createElement('button');
7075
+ dismiss.className = 'ctx-push-dismiss';
7076
+ dismiss.type = 'button';
7077
+ dismiss.textContent = 'Skip';
7078
+ dismiss.addEventListener('click', () => bar.remove());
7079
+ const autoDismiss = document.createElement('span');
7080
+ autoDismiss.className = 'ctx-push-auto';
7081
+ autoDismiss.textContent = 'Auto-dismisses in 30s';
7082
+ bar.appendChild(msg);
7083
+ bar.appendChild(btn);
7084
+ bar.appendChild(dismiss);
7085
+ bar.appendChild(autoDismiss);
7086
+ if (container) container.appendChild(bar);
7087
+ setTimeout(() => { if (bar.isConnected) bar.remove(); }, 30000);
7088
+ }
7089
+
5905
7090
  function tfContextKind(key) {
5906
7091
  return (key === 'orgRules' || key === 'managerRules' || key === 'projectRules') ? 'rules' : 'context';
5907
7092
  }
@@ -6277,6 +7462,7 @@ function tfContextRow(key, label, placeholder, rerender) {
6277
7462
  await tfSaveContext(key, tfContextContentForSave(key, label, readValue()));
6278
7463
  renderView();
6279
7464
  if (typeof rerender === 'function') rerender();
7465
+ tfMaybeShowPushPrompt(key, row);
6280
7466
  } catch (e) {
6281
7467
  save.disabled = false;
6282
7468
  save.textContent = 'Save';
@@ -6424,6 +7610,125 @@ function tfProjectUpdatesSection() {
6424
7610
  return details;
6425
7611
  }
6426
7612
 
7613
+ // #594 R2: render a conversation panel (topline + messages + coach input) into an
7614
+ // area-level container so org/manager jobs are viewable inside Company/Manager tabs.
7615
+ function tfRenderAreaConvPanel(el, conv) {
7616
+ const prevTaVal = (el.querySelector('.area-conv-coach-ta') || {}).value || '';
7617
+ el.innerHTML = '';
7618
+ const topline = document.createElement('div');
7619
+ topline.className = 'conv-topline area-conv-topline';
7620
+ const titleEl = document.createElement('span');
7621
+ titleEl.className = 'conv-job-title';
7622
+ titleEl.textContent = conv.title || conv.jobTitle || '';
7623
+ const dotCls = conversationStateDotClass(conv);
7624
+ const pill = document.createElement('span');
7625
+ pill.className = 'run-state-pill ' + dotCls;
7626
+ pill.textContent = conversationStateLabel(conv);
7627
+ topline.appendChild(titleEl);
7628
+ topline.appendChild(pill);
7629
+ el.appendChild(topline);
7630
+ const thread = document.createElement('div');
7631
+ thread.className = 'area-conv-thread';
7632
+ const msgs = (conv.messages || []).filter((m) => m.role !== 'system');
7633
+ if (msgs.length === 0) {
7634
+ const empty = document.createElement('p');
7635
+ empty.className = 'area-conv-empty';
7636
+ empty.textContent = conv.status === 'running' ? 'Working…' : 'No messages yet.';
7637
+ thread.appendChild(empty);
7638
+ } else {
7639
+ for (const msg of msgs) {
7640
+ const row = document.createElement('div');
7641
+ row.className = 'area-conv-msg area-conv-msg--' + (msg.role || 'employee');
7642
+ const txt = document.createElement('p');
7643
+ txt.className = 'area-conv-msg-text';
7644
+ txt.textContent = msg.text || '';
7645
+ row.appendChild(txt);
7646
+ thread.appendChild(row);
7647
+ }
7648
+ }
7649
+ el.appendChild(thread);
7650
+ // Show the coach input while the job is active — covers 'running' (working)
7651
+ // and 'waiting' uiState (review gate: employee posted and needs manager reply).
7652
+ const uiSt = conversationUiState(conv);
7653
+ const canCoach = uiSt === 'working' || uiSt === 'waiting';
7654
+ if (canCoach) {
7655
+ const coach = document.createElement('div');
7656
+ coach.className = 'area-conv-coach';
7657
+ const ta = document.createElement('textarea');
7658
+ ta.className = 'area-conv-coach-ta';
7659
+ ta.placeholder = 'Coach the team…';
7660
+ ta.rows = 2;
7661
+ if (prevTaVal) ta.value = prevTaVal;
7662
+ const sendBtn = document.createElement('button');
7663
+ sendBtn.type = 'button';
7664
+ sendBtn.className = 'area-conv-coach-send';
7665
+ sendBtn.textContent = 'Send';
7666
+ sendBtn.addEventListener('click', () => {
7667
+ const val = ta.value.trim();
7668
+ if (!val) return;
7669
+ state.activeId = conv.id;
7670
+ continueRun(val);
7671
+ ta.value = '';
7672
+ });
7673
+ coach.appendChild(ta);
7674
+ coach.appendChild(sendBtn);
7675
+ el.appendChild(coach);
7676
+ }
7677
+ }
7678
+
7679
+ // #594 R2: return the most-relevant org-scoped conversation to show in the Company area.
7680
+ // state.conversations is { [projectPath]: ConversationSummary[] } — flatten all.
7681
+ function tfActiveOrgConv() {
7682
+ const orgJobs = new Set(['organization-onboarding', 'organizational-learning-synthesis']);
7683
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => orgJobs.has(c.jobId));
7684
+ // Prefer running > waiting (needs coaching) > most recently updated completed.
7685
+ return (
7686
+ convs.find((c) => c.status === 'running') ||
7687
+ convs.find((c) => c.status === 'waiting') ||
7688
+ convs.slice().sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0))[0] ||
7689
+ null
7690
+ );
7691
+ }
7692
+
7693
+ // #594 R2: return the org-scoped manager-agreements conversation to show in Manager area.
7694
+ function tfActiveMgrConv() {
7695
+ const mgrJobs = new Set(['manager-agreements']);
7696
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => mgrJobs.has(c.jobId));
7697
+ return (
7698
+ convs.find((c) => c.status === 'running') ||
7699
+ convs.find((c) => c.status === 'waiting') ||
7700
+ convs.slice().sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0))[0] ||
7701
+ null
7702
+ );
7703
+ }
7704
+
7705
+ // Move the shared .page conversation panel into the specified area's workspace-conv host.
7706
+ // All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
7707
+ // because the area-conv-host elements carry the workspace-conv class.
7708
+ function tfEnsurePageInArea(area) {
7709
+ const page = document.getElementById('hub-conv-page');
7710
+ if (!page) return;
7711
+ const target = area === 'projects'
7712
+ ? document.querySelector('#proj-workspace .workspace-conv')
7713
+ : document.getElementById(area + '-conv-host');
7714
+ if (target && page.parentElement !== target) {
7715
+ target.appendChild(page);
7716
+ }
7717
+ }
7718
+
7719
+ // Toggle a Company/Manager area between its info view (accordions) and conversation panel.
7720
+ function tfToggleAreaView(area, view) {
7721
+ const host = document.getElementById(area + '-conv-host');
7722
+ const info = document.getElementById(area + '-info-view');
7723
+ if (view === 'info') {
7724
+ if (host) host.hidden = true;
7725
+ if (info) info.hidden = false;
7726
+ tfEnsurePageInArea('projects');
7727
+ state.activeId = null;
7728
+ renderActive();
7729
+ }
7730
+ }
7731
+
6427
7732
  function tfRenderCompany() {
6428
7733
  // #521 R4/R6: company-level jobs live ONLY in this left rail (organization-onboarding
6429
7734
  // + organizational-learning-synthesis) — not in any project's job list, not as
@@ -6431,6 +7736,13 @@ function tfRenderCompany() {
6431
7736
  const rail = document.getElementById('company-rail');
6432
7737
  if (rail) {
6433
7738
  rail.innerHTML = '';
7739
+ const orgConvActive = !!tfActiveOrgConv();
7740
+ const infoBtn = document.createElement('button');
7741
+ infoBtn.type = 'button';
7742
+ infoBtn.className = 'area-rail-info-btn' + (orgConvActive ? '' : ' info-active');
7743
+ infoBtn.textContent = '📋 Company Info';
7744
+ infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
7745
+ rail.appendChild(infoBtn);
6434
7746
  const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Company jobs';
6435
7747
  rail.appendChild(head);
6436
7748
  rail.appendChild(tfAreaRailJob('🏢', 'organization-onboarding', 'Organization onboarding', 'Set up / update org context & rules', () => tfStartOrgOnboarding()));
@@ -6443,32 +7755,145 @@ function tfRenderCompany() {
6443
7755
  const learn = document.getElementById('company-learnings');
6444
7756
  if (profile) {
6445
7757
  profile.innerHTML = '';
6446
- // Real org_context.md + org_rules.md content, edited inline.
6447
- profile.appendChild(tfContextRow(
6448
- 'org',
6449
- 'What you do',
6450
- 'Not set up yet — run onboarding to teach your team about your organization, or click Edit to write it directly.',
6451
- tfRenderCompany
6452
- ));
6453
- profile.appendChild(tfContextRow(
6454
- 'orgRules',
6455
- 'Guardrails',
6456
- 'Org-wide rules every employee respects before starting any job. Click Edit to add them.',
6457
- tfRenderCompany
6458
- ));
6459
- // Re-run onboarding moved to the Company jobs rail (#521 R4).
7758
+ const orgCache = tfCtxCache()['org'];
7759
+ if (orgCache && !orgCache.present) {
7760
+ // Context is confirmed absent: show CTA and open the accordion so it is
7761
+ // immediately visible without requiring a manual expand.
7762
+ const ctxAcc = document.getElementById('company-ctx-acc');
7763
+ if (ctxAcc) ctxAcc.open = true;
7764
+ const cta = document.createElement('div');
7765
+ cta.className = 'ctx-cta';
7766
+ const ico = document.createElement('div');
7767
+ ico.className = 'ctx-cta-ico';
7768
+ ico.textContent = '🏢';
7769
+ const title = document.createElement('p');
7770
+ title.className = 'ctx-cta-title';
7771
+ title.textContent = "Your team hasn't been onboarded yet";
7772
+ const msg = document.createElement('p');
7773
+ msg.className = 'ctx-cta-msg';
7774
+ msg.textContent = "Run organization onboarding to teach your employees what you do, how you operate, and the rules they must follow on every job.";
7775
+ const btn = document.createElement('button');
7776
+ btn.className = 'ctx-cta-btn';
7777
+ btn.type = 'button';
7778
+ btn.textContent = 'Run organization onboarding →';
7779
+ btn.addEventListener('click', () => tfStartOrgOnboarding());
7780
+ cta.appendChild(ico);
7781
+ cta.appendChild(title);
7782
+ cta.appendChild(msg);
7783
+ cta.appendChild(btn);
7784
+ profile.appendChild(cta);
7785
+ } else {
7786
+ // Context is present or not yet fetched: render rows (they load themselves).
7787
+ // If not yet fetched, start a background fetch and re-render to possibly switch to CTA.
7788
+ profile.appendChild(tfContextRow(
7789
+ 'org',
7790
+ 'What you do',
7791
+ 'Not set up yet — run onboarding to teach your team about your organization, or click Edit to write it directly.',
7792
+ tfRenderCompany
7793
+ ));
7794
+ profile.appendChild(tfContextRow(
7795
+ 'orgRules',
7796
+ 'Guardrails',
7797
+ 'Org-wide rules every employee respects before starting any job. Click Edit to add them.',
7798
+ tfRenderCompany
7799
+ ));
7800
+ if (!orgCache) {
7801
+ tfFetchContext('org').then(() => tfRenderCompany()).catch(() => {});
7802
+ }
7803
+ }
6460
7804
  }
6461
7805
  if (learn) {
6462
7806
  tfRenderLearningsList(learn, 'org', 'org', 'Nothing here yet — org-wide learnings that apply on every project. Run organizational-learning-synthesis (in Company jobs, left), or add one with + Add learning.');
6463
7807
  }
6464
- // Issue #540 R1-R3: render persona grid on every company-tab activation.
6465
- renderPersonaGrid();
7808
+ tfMaybeRenderOrgPushBanner();
7809
+ // Show the full coaching conversation panel when there is an active org conv;
7810
+ // otherwise show the info view (accordions). This uses the shared .page element
7811
+ // moved via tfEnsurePageInArea() so renderActive() renders the full panel.
7812
+ const orgConv = tfActiveOrgConv();
7813
+ const host = document.getElementById('company-conv-host');
7814
+ const info = document.getElementById('company-info-view');
7815
+ if (orgConv && tf.area === 'company') {
7816
+ state.activeId = orgConv.id;
7817
+ tfEnsurePageInArea('company');
7818
+ if (host) host.hidden = false;
7819
+ if (info) info.hidden = true;
7820
+ renderActive();
7821
+ } else {
7822
+ if (host) host.hidden = true;
7823
+ if (info) info.hidden = false;
7824
+ if (tf.area === 'company') tfEnsurePageInArea('projects');
7825
+ }
6466
7826
  }
7827
+
7828
+ // #594 R3: show push banner in Company area after an org onboarding run completes
7829
+ // and GitHub is configured, so the manager can push the new context to the team repo.
7830
+ function tfMaybeRenderOrgPushBanner() {
7831
+ const host = document.getElementById('company-push-banner');
7832
+ if (!host) return;
7833
+ if (!tfIsGithubConfigured()) { host.innerHTML = ''; return; }
7834
+ const orgJobs = ['organization-onboarding', 'organizational-learning-synthesis'];
7835
+ const hasCompleted = Object.values(state.conversations || {}).flat().some(
7836
+ (c) => orgJobs.includes(c.jobId) && c.status === 'completed'
7837
+ );
7838
+ if (!hasCompleted) { host.innerHTML = ''; return; }
7839
+ const dismissed = window.localStorage.getItem('fraim.aiHub.orgPushDismissed');
7840
+ if (dismissed === '1') { host.innerHTML = ''; return; }
7841
+ if (host.querySelector('.push-to-team-banner')) return;
7842
+ host.innerHTML = '';
7843
+ const banner = document.createElement('div');
7844
+ banner.className = 'push-to-team-banner';
7845
+ banner.id = 'company-push-banner-inner';
7846
+ const ico = document.createElement('span');
7847
+ ico.className = 'ptb-ico';
7848
+ ico.textContent = '📤';
7849
+ const copy = document.createElement('div');
7850
+ copy.className = 'ptb-copy';
7851
+ const repo = state.bootstrap && state.bootstrap.repository;
7852
+ const repoLabel = (repo && repo.owner && repo.name) ? (repo.owner + '/' + repo.name) : 'your team repo';
7853
+ copy.innerHTML = '<strong>Push to team?</strong> Your org context was updated locally. Push to <code>' + repoLabel + '</code> so teammates pick it up on their next pull.';
7854
+ const pushBtn = document.createElement('button');
7855
+ pushBtn.className = 'ptb-push-btn';
7856
+ pushBtn.type = 'button';
7857
+ pushBtn.textContent = 'Push to team';
7858
+ pushBtn.addEventListener('click', async () => {
7859
+ pushBtn.disabled = true;
7860
+ pushBtn.textContent = 'Pushing...';
7861
+ try {
7862
+ await requestJson('/api/ai-hub/context/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'org' }) });
7863
+ window.localStorage.setItem('fraim.aiHub.orgPushDismissed', '1');
7864
+ host.innerHTML = '';
7865
+ } catch {
7866
+ pushBtn.disabled = false;
7867
+ pushBtn.textContent = 'Push to team';
7868
+ }
7869
+ });
7870
+ const dismissBtn = document.createElement('button');
7871
+ dismissBtn.className = 'ptb-dismiss';
7872
+ dismissBtn.type = 'button';
7873
+ dismissBtn.textContent = 'Keep local';
7874
+ dismissBtn.addEventListener('click', () => {
7875
+ window.localStorage.setItem('fraim.aiHub.orgPushDismissed', '1');
7876
+ host.innerHTML = '';
7877
+ });
7878
+ banner.appendChild(ico);
7879
+ banner.appendChild(copy);
7880
+ banner.appendChild(pushBtn);
7881
+ banner.appendChild(dismissBtn);
7882
+ host.appendChild(banner);
7883
+ }
7884
+
6467
7885
  function tfRenderManager() {
6468
7886
  // #521 R6: manager-level job (manager-agreements) lives ONLY in this left rail.
6469
7887
  const rail = document.getElementById('manager-rail');
6470
7888
  if (rail) {
6471
7889
  rail.innerHTML = '';
7890
+ const mgrConvActive = !!tfActiveMgrConv();
7891
+ const infoBtn = document.createElement('button');
7892
+ infoBtn.type = 'button';
7893
+ infoBtn.className = 'area-rail-info-btn' + (mgrConvActive ? '' : ' info-active');
7894
+ infoBtn.textContent = '📋 Manager Info';
7895
+ infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
7896
+ rail.appendChild(infoBtn);
6472
7897
  const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Manager jobs';
6473
7898
  rail.appendChild(head);
6474
7899
  rail.appendChild(tfAreaRailJob('🤝', 'manager-agreements', 'manager-agreements', 'Set up / update how you work', () => tfStartManagerOnboarding()));
@@ -6481,21 +7906,51 @@ function tfRenderManager() {
6481
7906
  const reverse = document.getElementById('reverse-mentoring');
6482
7907
  if (profile) {
6483
7908
  profile.innerHTML = '';
6484
- // manager_context.md holds feedback style, priorities, and presenting prefs.
6485
- // We edit it as a whole (honest, single-file) rather than parsing facets.
6486
- profile.appendChild(tfContextRow(
6487
- 'manager',
6488
- 'How you work',
6489
- 'Not set up yet — run onboarding to teach your team how you like direction, feedback, priorities, and hand-off. Click Edit to write it directly.',
6490
- tfRenderManager
6491
- ));
6492
- profile.appendChild(tfContextRow(
6493
- 'managerRules',
6494
- 'Rules',
6495
- 'Standing rules for how employees should work with you. Click Edit to add them.',
6496
- tfRenderManager
6497
- ));
6498
- // Re-run onboarding moved to the Manager jobs rail (#521 R3).
7909
+ const mgrCache = tfCtxCache()['manager'];
7910
+ if (mgrCache && !mgrCache.present) {
7911
+ // Context is confirmed absent: show CTA and open the accordion.
7912
+ const mgrCtxAcc = document.getElementById('manager-ctx-acc');
7913
+ if (mgrCtxAcc) mgrCtxAcc.open = true;
7914
+ const cta = document.createElement('div');
7915
+ cta.className = 'ctx-cta';
7916
+ const ico = document.createElement('div');
7917
+ ico.className = 'ctx-cta-ico';
7918
+ ico.textContent = '🤝';
7919
+ const title = document.createElement('p');
7920
+ title.className = 'ctx-cta-title';
7921
+ title.textContent = 'Set up your manager profile';
7922
+ const msg = document.createElement('p');
7923
+ msg.className = 'ctx-cta-msg';
7924
+ msg.textContent = "Run manager onboarding so your team knows how you like to work, give feedback, and delegate. Your preferences become standing rules every employee respects.";
7925
+ const btn = document.createElement('button');
7926
+ btn.className = 'ctx-cta-btn';
7927
+ btn.type = 'button';
7928
+ btn.textContent = 'Run manager onboarding →';
7929
+ btn.addEventListener('click', () => tfStartManagerOnboarding());
7930
+ cta.appendChild(ico);
7931
+ cta.appendChild(title);
7932
+ cta.appendChild(msg);
7933
+ cta.appendChild(btn);
7934
+ profile.appendChild(cta);
7935
+ } else {
7936
+ // Context is present or not yet fetched: render rows (they load themselves).
7937
+ // If not yet fetched, start a background fetch and re-render to possibly switch to CTA.
7938
+ profile.appendChild(tfContextRow(
7939
+ 'manager',
7940
+ 'How you work',
7941
+ 'Not set up yet — run onboarding to teach your team how you like direction, feedback, priorities, and hand-off. Click Edit to write it directly.',
7942
+ tfRenderManager
7943
+ ));
7944
+ profile.appendChild(tfContextRow(
7945
+ 'managerRules',
7946
+ 'Rules',
7947
+ 'Standing rules for how employees should work with you. Click Edit to add them.',
7948
+ tfRenderManager
7949
+ ));
7950
+ if (!mgrCache) {
7951
+ tfFetchContext('manager').then(() => tfRenderManager()).catch(() => {});
7952
+ }
7953
+ }
6499
7954
  }
6500
7955
  if (learn) {
6501
7956
  // #533: Manager learnings = "how your team adapts to you" = your work patterns
@@ -6507,42 +7962,25 @@ function tfRenderManager() {
6507
7962
  // the manager-coaching learnings the team has captured for you.
6508
7963
  tfRenderLearningsList(reverse, 'reverse', 'machine', 'Nothing here yet — as your team works with you, coaching on what you can improve on as a manager appears here. Add one with + Add learning.');
6509
7964
  }
6510
- tfRenderManagerTeam();
6511
7965
  // Issue #540 R4-R7: render manager team pool on every manager-tab activation.
6512
7966
  renderManagerTeamPool();
6513
- }
6514
- function tfRenderManagerTeam() {
6515
- const section = document.getElementById('manager-team-section');
6516
- if (!section) return;
6517
- section.innerHTML = '';
6518
- const hired = tfHiredPersonas();
6519
- if (hired.length === 0) {
6520
- const msg = document.createElement('p');
6521
- msg.className = 'manager-no-team-msg';
6522
- msg.textContent = "You haven't hired any specialists yet. Go to Company to see the full roster and hire the team you need.";
6523
- section.appendChild(msg);
6524
- const btn = document.createElement('button');
6525
- btn.className = 'manager-go-company-btn';
6526
- btn.type = 'button';
6527
- btn.textContent = 'Go to Company to hire';
6528
- btn.addEventListener('click', () => tfShowArea('company'));
6529
- section.appendChild(btn);
6530
- return;
7967
+ // Show the full coaching conversation panel when there is an active manager conv;
7968
+ // otherwise show the info view (accordions). Uses the shared .page element.
7969
+ const mgrConv = tfActiveMgrConv();
7970
+ const mgrHost = document.getElementById('manager-conv-host');
7971
+ const mgrInfo = document.getElementById('manager-info-view');
7972
+ if (mgrConv && tf.area === 'manager') {
7973
+ state.activeId = mgrConv.id;
7974
+ tfEnsurePageInArea('manager');
7975
+ if (mgrHost) mgrHost.hidden = false;
7976
+ if (mgrInfo) mgrInfo.hidden = true;
7977
+ renderActive();
7978
+ } else {
7979
+ if (mgrHost) mgrHost.hidden = true;
7980
+ if (mgrInfo) mgrInfo.hidden = false;
7981
+ if (tf.area === 'manager') tfEnsurePageInArea('projects');
6531
7982
  }
6532
- const grid = document.createElement('div');
6533
- grid.className = 'emp-grid';
6534
- hired.forEach((p, i) => {
6535
- const av = tfAvatarFor(p.displayName, i);
6536
- const tile = document.createElement('div');
6537
- tile.className = 'emp-tile';
6538
- tile.innerHTML = '<div class="et-av" style="background:' + av.color + ';">' + av.badge + '</div>' +
6539
- '<div class="et-name">' + tfEscape(p.displayName) + '</div>' +
6540
- '<div class="et-role">' + tfEscape(p.role || '') + '</div>';
6541
- grid.appendChild(tile);
6542
- });
6543
- section.appendChild(grid);
6544
7983
  }
6545
-
6546
7984
  // #533: Reverse mentoring now renders the real manager-coaching learnings via
6547
7985
  // tfRenderLearningsList(scope='reverse') — consistent with the other learning
6548
7986
  // sections — replacing the earlier representative-seed feed (tfReverseItems /
@@ -6674,7 +8112,7 @@ async function tfFetchPreservedLearnings(scope, level) {
6674
8112
  // cross-surface render churn that left the project tree unstable. #533)
6675
8113
  function tfReRenderScope(scope, level) {
6676
8114
  if (level === 'project') {
6677
- if (document.querySelector('.workspace-conv') && typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
8115
+ if (document.querySelector('#proj-workspace .workspace-conv') && typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
6678
8116
  return;
6679
8117
  }
6680
8118
  if (scope === 'org') { if (typeof tfRenderCompany === 'function') tfRenderCompany(); return; }
@@ -7032,76 +8470,84 @@ function tfCloseAccountMenu() {
7032
8470
  if (menu) menu.classList.remove('open');
7033
8471
  }
7034
8472
 
7035
- // ---------------------------------------------------------------------------
7036
- // Get-started rail (driven by bootstrap.firstRun {install,company,hire,project})
7037
- // ---------------------------------------------------------------------------
7038
- function tfFirstRunActiveStep(fr) {
7039
- for (const id of TF_STEP_ORDER) { if (!fr[id]) return id; }
7040
- return null;
7041
- }
7042
- function tfRenderRail() {
7043
- const rail = document.getElementById('gs-rail');
7044
- const pill = document.getElementById('gs-pill');
7045
- const steps = document.getElementById('gs-steps');
7046
- if (!rail || !pill || !steps) return;
7047
- const fr = state.bootstrap && state.bootstrap.firstRun;
7048
- const allComplete = fr && TF_STEP_ORDER.every((id) => fr[id]);
7049
- if (!fr || allComplete) { rail.hidden = true; pill.hidden = true; return; }
7050
- const activeStep = tfFirstRunActiveStep(fr);
7051
- const collapsed = window.localStorage.getItem(STORAGE_KEY_RAIL_512) === '1';
7052
- steps.innerHTML = '';
7053
- TF_STEP_ORDER.forEach((id, i) => {
7054
- const btn = document.createElement('button');
7055
- btn.type = 'button';
7056
- btn.className = 'gs-step' + (fr[id] ? ' done' : (id === activeStep ? ' current' : ''));
7057
- btn.textContent = TF_STEP_LABELS[id];
7058
- btn.addEventListener('click', () => tfRailGo(id));
7059
- steps.appendChild(btn);
7060
- if (i < TF_STEP_ORDER.length - 1) {
7061
- const sep = document.createElement('span');
7062
- sep.className = 'gs-sep';
7063
- sep.textContent = '›';
7064
- steps.appendChild(sep);
7065
- }
7066
- });
7067
- const remaining = TF_STEP_ORDER.filter((id) => !fr[id]).length;
7068
- pill.textContent = '🚀 Finish setup · ' + remaining + ' step' + (remaining === 1 ? '' : 's') + ' left';
7069
- rail.hidden = collapsed;
7070
- pill.hidden = !collapsed;
8473
+ // Per-job config for the area onboarding pre-flight modal.
8474
+ const AREA_ONBOARD_CONFIG = {
8475
+ 'organization-onboarding': {
8476
+ title: 'Organization onboarding',
8477
+ desc: 'FRAIM will learn what your company does and write org_context.md and org_rules.md. Add any specific focus areas or context below.',
8478
+ },
8479
+ 'organizational-learning-synthesis': {
8480
+ title: 'Learning synthesis',
8481
+ desc: 'FRAIM will consolidate recurring patterns across projects into org-wide learnings. Add any specific focus areas below.',
8482
+ },
8483
+ 'manager-agreements': {
8484
+ title: 'Manager agreements',
8485
+ desc: 'FRAIM will learn how you work and write manager_context.md and manager_rules.md. Add any specific direction below.',
8486
+ },
8487
+ };
8488
+
8489
+ function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
8490
+ tf.aomJob = { jobId, baseMessage, area };
8491
+ const cfg = AREA_ONBOARD_CONFIG[jobId] || { title: jobId, desc: 'Add any specific context for this run.' };
8492
+ const titleEl = document.getElementById('aom-title');
8493
+ const descEl = document.getElementById('aom-desc');
8494
+ const ctx = document.getElementById('aom-context');
8495
+ if (titleEl) titleEl.textContent = cfg.title;
8496
+ if (descEl) descEl.textContent = cfg.desc;
8497
+ if (ctx) ctx.value = '';
8498
+ const modal = document.getElementById('area-onboard-modal');
8499
+ if (modal) modal.hidden = false;
8500
+ if (ctx) ctx.focus();
8501
+ }
8502
+
8503
+ function tfCloseAreaOnboardModal() {
8504
+ const modal = document.getElementById('area-onboard-modal');
8505
+ if (modal) modal.hidden = true;
8506
+ tf.aomJob = null;
7071
8507
  }
7072
- function tfRailGo(step) {
7073
- if (step === 'install') { window.location.href = '/account'; return; }
7074
- if (step === 'company' || step === 'hire') { tfStartOrgOnboarding(); return; }
7075
- if (step === 'project') { tfShowArea('projects'); tfOpenNewProject(); }
8508
+
8509
+ async function tfSubmitAreaOnboardModal() {
8510
+ const { jobId, baseMessage, area } = tf.aomJob || {};
8511
+ if (!jobId) return;
8512
+ const ctx = document.getElementById('aom-context');
8513
+ const userContext = (ctx && ctx.value.trim()) || '';
8514
+ tfCloseAreaOnboardModal();
8515
+ await tfStartOnboardingJob(jobId, baseMessage, area, userContext);
7076
8516
  }
7077
8517
 
7078
- async function tfStartOnboardingJob(jobId, message, targetArea) {
8518
+ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
7079
8519
  const employeeId = state.selectedEmployeeId || 'claude';
7080
8520
  const allJobs = [
7081
8521
  ...((state.bootstrap && state.bootstrap.jobs) || []),
7082
8522
  ...((state.bootstrap && state.bootstrap.managerTemplates) || []),
7083
8523
  ];
7084
8524
  const job = allJobs.find((j) => j.id === jobId) || { id: jobId, title: jobId };
8525
+ // If the user typed a direction in the pre-flight modal, send only that — the
8526
+ // job already knows what to do from its FRAIM instructions. Fall back to the
8527
+ // job's registry intent (or the base message string) when no context is given.
8528
+ const jobMessage = (job && job.intent && job.intent.trim()) ? job.intent : message;
8529
+ const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
7085
8530
  if (job && typeof startRun === 'function') {
7086
8531
  if (targetArea) tfShowArea(targetArea);
7087
- await startRun(job, message, employeeId);
7088
- tfShowArea('projects');
7089
- tfSelectProjectView('workspace', tf.activeProjectId);
8532
+ await startRun(job, finalMessage, employeeId);
8533
+ // After run creation refresh the area panel so the conversation becomes visible.
8534
+ if (targetArea === 'company') tfRenderCompany();
8535
+ else if (targetArea === 'manager') tfRenderManager();
7090
8536
  } else if (typeof showStatus === 'function') {
7091
8537
  showStatus('Onboarding job not found in this project yet. Pick a project folder first.', true);
7092
8538
  }
7093
8539
  }
7094
8540
 
7095
- async function tfStartOrgOnboarding() {
7096
- await tfStartOnboardingJob(
8541
+ function tfStartOrgOnboarding() {
8542
+ tfOpenAreaOnboardModal(
7097
8543
  'organization-onboarding',
7098
8544
  'Onboard my organization: learn what we do and the guardrails every employee should follow, then write org_context.md and org_rules.md.',
7099
8545
  'company'
7100
8546
  );
7101
8547
  }
7102
8548
 
7103
- async function tfStartManagerOnboarding() {
7104
- await tfStartOnboardingJob(
8549
+ function tfStartManagerOnboarding() {
8550
+ tfOpenAreaOnboardModal(
7105
8551
  'manager-agreements',
7106
8552
  'Set up my manager agreements: learn how I like to work, how I give feedback, and the standing rules employees should follow with me, then write manager_context.md and manager_rules.md.',
7107
8553
  'manager'
@@ -7110,8 +8556,8 @@ async function tfStartManagerOnboarding() {
7110
8556
 
7111
8557
  // #521 R4: company-level learning synthesis — wired to the real
7112
8558
  // organizational-learning-synthesis job, launched only from the Company rail.
7113
- async function tfStartOrgLearningSynthesis() {
7114
- await tfStartOnboardingJob(
8559
+ function tfStartOrgLearningSynthesis() {
8560
+ tfOpenAreaOnboardModal(
7115
8561
  'organizational-learning-synthesis',
7116
8562
  'Synthesize our company learnings: consolidate the recurring patterns across projects into preserved org-wide learnings, then update org memory.',
7117
8563
  'company'
@@ -7137,36 +8583,59 @@ async function tfStartProjectOnboarding(userInput) {
7137
8583
  await tfStartOnboardingJob('project-onboarding', parts.join('\n\n'), 'projects');
7138
8584
  }
7139
8585
 
7140
- // #521: collect the manager's direction before a (re)processing run so they can
7141
- // steer it. mode 'run' = first onboarding (no context yet); 'reprocess' = refine
7142
- // existing context/rules. The input is optional Start runs with or without it.
7143
- function tfOpenOnboardInput(mode) {
7144
- const modal = document.getElementById('onboard-input-modal');
7145
- const title = document.getElementById('obi-title');
7146
- const sub = document.getElementById('obi-sub');
7147
- const input = document.getElementById('obi-input');
7148
- if (!modal) { tfStartProjectOnboarding(); return; }
7149
- const reprocess = mode === 'reprocess';
7150
- if (title) title.textContent = reprocess ? 'Reprocess project onboarding' : 'Run project onboarding';
7151
- if (sub) {
7152
- sub.textContent = reprocess
7153
- ? "Your team re-reads the current context & rules (including your edits) and refines them. Add anything you want them to focus on or incorporate — optional."
7154
- : "Your team explores the project and captures its context and rules. Tell them what this project is or what to focus on — optional.";
7155
- }
7156
- if (input) input.value = '';
7157
- modal.hidden = false;
7158
- if (input) input.focus();
8586
+ // #594 R7: #onboard-input-modal retired. tfOpenOnboardInput now opens #np-modal
8587
+ // with the folder pre-filled and locked so the manager can only supply a direction
8588
+ // message (the intent field) before starting the re-run.
8589
+ function tfOpenOnboardInput() {
8590
+ tfOpenNewProjectRerun();
7159
8591
  }
7160
8592
  function tfCloseOnboardInput() {
7161
- const m = document.getElementById('onboard-input-modal');
7162
- if (m) m.hidden = true;
8593
+ tfCloseNewProject();
7163
8594
  }
7164
8595
  function tfSubmitOnboardInput() {
7165
- const input = document.getElementById('obi-input');
7166
- const val = input ? input.value : '';
7167
- tfCloseOnboardInput();
8596
+ const intent = document.getElementById('np-intent');
8597
+ const val = intent ? intent.value : '';
8598
+ tfCloseNewProject();
7168
8599
  tfStartProjectOnboarding(val);
7169
8600
  }
8601
+ function tfOpenNewProjectRerun() {
8602
+ const modal = document.getElementById('np-modal');
8603
+ if (!modal) { tfStartProjectOnboarding(); return; }
8604
+ const folder = document.getElementById('np-folder');
8605
+ const np1Next = document.getElementById('np1-next');
8606
+ const intent = document.getElementById('np-intent');
8607
+ const browseBtn = document.getElementById('np-browse');
8608
+ if (folder) {
8609
+ folder.value = state.projectPath || '';
8610
+ folder.readOnly = true;
8611
+ }
8612
+ if (browseBtn) browseBtn.hidden = true;
8613
+ if (intent) {
8614
+ intent.value = '';
8615
+ const label = intent.closest('.np-field') && intent.closest('.np-field').querySelector('label');
8616
+ if (label) label.dataset.originalText = label.innerHTML;
8617
+ if (label) label.innerHTML = 'Anything new to focus on this time? <span class="np-optional">(optional)</span>';
8618
+ }
8619
+ if (np1Next) { np1Next.disabled = false; np1Next.removeAttribute('disabled'); }
8620
+ // Update Step 1 heading and description for re-run context.
8621
+ const np1Hdr = document.querySelector('#np1 .np-hdr');
8622
+ if (np1Hdr) {
8623
+ const h2 = np1Hdr.querySelector('h2');
8624
+ const desc = np1Hdr.querySelector('p');
8625
+ const stepLabel = np1Hdr.querySelector('.step-label');
8626
+ if (h2) { h2.dataset.originalText = h2.textContent; h2.textContent = 'Update project onboarding'; }
8627
+ if (desc) { desc.dataset.originalText = desc.textContent; desc.textContent = 'Your project folder is already set. Tell the team anything new to focus on this time.'; }
8628
+ if (stepLabel) { stepLabel.dataset.originalText = stepLabel.textContent; stepLabel.textContent = 'Step 1 of 4 — Re-run'; }
8629
+ }
8630
+ const np1Hint = document.querySelector('#np1 .np-field-hint');
8631
+ if (np1Hint) { np1Hint.dataset.originalText = np1Hint.textContent; np1Hint.textContent = 'This project folder is already set — only the direction below will change.'; }
8632
+ modal.dataset.rerun = '1';
8633
+ // Pre-select all hired personas so step 2 starts with a sensible default.
8634
+ tf.npSelectedEmployees = tfHiredPersonas().map((p) => p.key);
8635
+ tfRenderNpEmployees();
8636
+ tfNpGo(1);
8637
+ modal.hidden = false;
8638
+ }
7170
8639
 
7171
8640
  // ---------------------------------------------------------------------------
7172
8641
  // New-project modal (4-step)
@@ -7187,7 +8656,26 @@ function tfOpenNewProject() {
7187
8656
  }
7188
8657
  function tfCloseNewProject() {
7189
8658
  const m = document.getElementById('np-modal');
7190
- if (m) m.hidden = true;
8659
+ if (m) { m.hidden = true; delete m.dataset.rerun; }
8660
+ const folder = document.getElementById('np-folder');
8661
+ if (folder) folder.readOnly = false;
8662
+ const browseBtn = document.getElementById('np-browse');
8663
+ if (browseBtn) browseBtn.hidden = false;
8664
+ const intent = document.getElementById('np-intent');
8665
+ if (intent) {
8666
+ const label = intent.closest('.np-field') && intent.closest('.np-field').querySelector('label');
8667
+ if (label && label.dataset.originalText) { label.innerHTML = label.dataset.originalText; delete label.dataset.originalText; }
8668
+ }
8669
+ // Restore Step 1 heading, description, step-label, and hint to new-project defaults.
8670
+ const np1Hdr = document.querySelector('#np1 .np-hdr');
8671
+ if (np1Hdr) {
8672
+ for (const sel of ['h2', 'p', '.step-label']) {
8673
+ const el = np1Hdr.querySelector(sel);
8674
+ if (el && el.dataset.originalText) { el.textContent = el.dataset.originalText; delete el.dataset.originalText; }
8675
+ }
8676
+ }
8677
+ const np1Hint = document.querySelector('#np1 .np-field-hint');
8678
+ if (np1Hint && np1Hint.dataset.originalText) { np1Hint.textContent = np1Hint.dataset.originalText; delete np1Hint.dataset.originalText; }
7191
8679
  }
7192
8680
  function tfNpGo(n) {
7193
8681
  tf.npStep = n;
@@ -7642,16 +9130,6 @@ function tfWireShell() {
7642
9130
  if (overviewTab) overviewTab.addEventListener('click', () => tfSelectProjectView('overview'));
7643
9131
  const addTab = document.getElementById('ptab-add');
7644
9132
  if (addTab) addTab.addEventListener('click', tfOpenNewProject);
7645
- const cta = document.getElementById('gs-cta');
7646
- if (cta) cta.addEventListener('click', () => {
7647
- const fr = state.bootstrap && state.bootstrap.firstRun;
7648
- const active = fr && tfFirstRunActiveStep(fr);
7649
- if (active) tfRailGo(active);
7650
- });
7651
- const hide = document.getElementById('gs-hide');
7652
- if (hide) hide.addEventListener('click', () => { window.localStorage.setItem(STORAGE_KEY_RAIL_512, '1'); tfRenderRail(); });
7653
- const pill = document.getElementById('gs-pill');
7654
- if (pill) pill.addEventListener('click', () => { window.localStorage.removeItem(STORAGE_KEY_RAIL_512); tfRenderRail(); });
7655
9133
  const npClose = document.getElementById('np-close');
7656
9134
  if (npClose) npClose.addEventListener('click', tfCloseNewProject);
7657
9135
 
@@ -7689,7 +9167,16 @@ function tfWireShell() {
7689
9167
  }
7690
9168
  const npModal = document.getElementById('np-modal');
7691
9169
  if (npModal) npModal.addEventListener('click', (e) => { if (e.target === npModal) tfCloseNewProject(); });
7692
- const closers = [['aj-close', tfCloseAssignJob], ['ae-close', tfCloseAddEmp], ['pr-close', tfClosePricing], ['obi-close', tfCloseOnboardInput], ['obi-cancel', tfCloseOnboardInput], ['hm-close', tfCloseHireManager]];
9170
+ // #594: Area onboarding pre-flight modal wiring.
9171
+ const aomClose = document.getElementById('aom-close');
9172
+ if (aomClose) aomClose.addEventListener('click', tfCloseAreaOnboardModal);
9173
+ const aomCancel = document.getElementById('aom-cancel');
9174
+ if (aomCancel) aomCancel.addEventListener('click', tfCloseAreaOnboardModal);
9175
+ const aomStart = document.getElementById('aom-start');
9176
+ if (aomStart) aomStart.addEventListener('click', tfSubmitAreaOnboardModal);
9177
+ const aomModal = document.getElementById('area-onboard-modal');
9178
+ if (aomModal) aomModal.addEventListener('click', (e) => { if (e.target === aomModal) tfCloseAreaOnboardModal(); });
9179
+ const closers = [['aj-close', tfCloseAssignJob], ['ae-close', tfCloseAddEmp], ['pr-close', tfClosePricing], ['hm-close', tfCloseHireManager]];
7693
9180
  for (const [id, fn] of closers) {
7694
9181
  const el = document.getElementById(id);
7695
9182
  if (el) el.addEventListener('click', fn);
@@ -7715,16 +9202,7 @@ function tfWireShell() {
7715
9202
  const q = document.getElementById('hm-query');
7716
9203
  if (q && navigator.clipboard) navigator.clipboard.writeText(q.textContent || '');
7717
9204
  });
7718
- const hmBtn = document.getElementById('manager-hire-btn');
7719
- if (hmBtn) hmBtn.addEventListener('click', () => {
7720
- const mh = state.bootstrap && state.bootstrap.managerHiring;
7721
- const hired = ((state.bootstrap && state.bootstrap.personas) || []).filter((p) => p.status === 'hired' && mh && mh.roles[p.key]);
7722
- const first = hired[0] ? hired[0].key : (mh ? Object.keys(mh.roles)[0] : null);
7723
- openHireManagerModal(first);
7724
- });
7725
- const obiStart = document.getElementById('obi-start');
7726
- if (obiStart) obiStart.addEventListener('click', tfSubmitOnboardInput);
7727
- const backdrops = [['assign-job-modal', tfCloseAssignJob], ['add-emp-modal', tfCloseAddEmp], ['pricing-modal', tfClosePricing], ['onboard-input-modal', tfCloseOnboardInput], ['hm-modal', tfCloseHireManager]];
9205
+ const backdrops = [['assign-job-modal', tfCloseAssignJob], ['add-emp-modal', tfCloseAddEmp], ['pricing-modal', tfClosePricing], ['hm-modal', tfCloseHireManager]];
7728
9206
  for (const [id, fn] of backdrops) {
7729
9207
  const el = document.getElementById(id);
7730
9208
  if (el) el.addEventListener('click', (e) => { if (e.target === el) fn(); });
@@ -7735,7 +9213,6 @@ function tfWireShell() {
7735
9213
  // dismisses whichever team-flow modal is open (top-most first).
7736
9214
  const escClosers = [
7737
9215
  ['hm-modal', tfCloseHireManager],
7738
- ['onboard-input-modal', tfCloseOnboardInput],
7739
9216
  ['pricing-modal', tfClosePricing],
7740
9217
  ['add-emp-modal', tfCloseAddEmp],
7741
9218
  ['assign-job-modal', tfCloseAssignJob],
@@ -7804,9 +9281,10 @@ function tfInitShell() {
7804
9281
  } else if (tf.projects.length && !tf.activeProjectId) {
7805
9282
  tf.activeProjectId = tf.projects[0].id;
7806
9283
  }
7807
- tfRenderRail();
7808
9284
  tfRenderProjectTabs();
7809
9285
  tfShowArea('projects');
9286
+ // Issue #578: wire deployment modal buttons.
9287
+ initDeploymentButtons();
7810
9288
  // Open the workspace by default so the conversation surface (#conversation,
7811
9289
  // #new-conv-btn, #empty, the welcome line, the project button) is visible on
7812
9290
  // load — preserving the contract the shipped #339/#347/#429/#442 Hub UI
@@ -7816,12 +9294,15 @@ function tfInitShell() {
7816
9294
  } else {
7817
9295
  tfSelectProjectView('overview');
7818
9296
  }
9297
+ // Server-hydrated completed runs can arrive before the shell finishes wiring
9298
+ // the project workspace. Re-render once the workspace is selected so manager
9299
+ // delegation boards are visible on first load, not only after a refresh tick.
9300
+ renderActive();
7819
9301
  }
7820
9302
 
7821
9303
  // Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
7822
9304
  function tfRefreshAfterBootstrap() {
7823
9305
  if (!tf.enabled) return;
7824
- tfRenderRail();
7825
9306
  tfRenderProjectTabs();
7826
9307
  if (tf.area === 'company') tfRenderCompany();
7827
9308
  else if (tf.area === 'manager') tfRenderManager();