fraim 2.0.183 → 2.0.185

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.
@@ -668,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
668
668
  .trim();
669
669
  }
670
670
 
671
- function stripHubInjectedPromptBlocks(text) {
671
+ function stripHubInjectedNotes(text) {
672
672
  return String(text || '')
673
673
  .replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
674
- .replace(/(?:^|\n)\s*(?:[$/]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
674
+ .replace(/\n{3,}/g, '\n\n')
675
+ .trim();
676
+ }
677
+
678
+ function stripHubInjectedPromptBlocks(text) {
679
+ return stripHubInjectedNotes(text)
680
+ .replace(/(?:^|\n)\s*(?:[$/@]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
675
681
  .replace(/\n{3,}/g, '\n\n')
676
682
  .trim();
677
683
  }
@@ -833,11 +839,9 @@ function renderRail() {
833
839
  renderTeamRoster();
834
840
  els['conv-list'].innerHTML = '';
835
841
  // R4: filter by selected persona when one is active.
836
- // #521: in the project workspace, project-lifecycle jobs (onboarding +
837
- // sleep-on-learnings) have their own "Project Updates" home, so keep them out
838
- // of the general Runs list to avoid showing the same run twice.
839
- const inWorkspace = !!document.querySelector('#proj-workspace .workspace-conv');
840
- const projectUpdateJobs = new Set(['project-onboarding', 'sleep-on-learnings']);
842
+ // #693 R1: the "Project Updates" section was retired; project-lifecycle runs
843
+ // (project-onboarding + sleep-on-learnings) now surface in the Runs list under
844
+ // their employee like any other run, so their history stays reachable.
841
845
  const allProjectConversations = projectConversations();
842
846
  const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
843
847
  const childrenByManager = new Map();
@@ -850,7 +854,6 @@ function renderRail() {
850
854
  const list = allProjectConversations.filter((conv) =>
851
855
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
852
856
  !isManagedDelegationChild(conv) &&
853
- !(inWorkspace && projectUpdateJobs.has(conv.jobId)) &&
854
857
  !AREA_SCOPED_JOBS.has(conv.jobId)
855
858
  );
856
859
 
@@ -880,6 +883,31 @@ function renderRail() {
880
883
  groups.get(key).conversations.push(conv);
881
884
  }
882
885
 
886
+ // #693 R1 (PR round 2): this Runs list is the single employees section in the
887
+ // workspace tree, so include every project employee — team, manager-team, and
888
+ // assigned — even before they have a run, so you can assign/delegate to them.
889
+ const tfProjId = (typeof tf !== 'undefined' && tf) ? tf.activeProjectId : null;
890
+ const managerTeamKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey).filter(Boolean);
891
+ // merge: master resolves the tree's employees via tfProjectTeamKeys() (hired personas),
892
+ // not project.team; align on it so every hired employee appears in the single section.
893
+ const projectEmployeeKeys = new Set([
894
+ ...(typeof tfProjectTeamKeys === 'function' ? tfProjectTeamKeys() : []),
895
+ ...(tfProjId && typeof tfProjectAssignments === 'function' ? tfProjectAssignments(tfProjId).map((a) => a.employeeKey).filter(Boolean) : []),
896
+ ...managerTeamKeys,
897
+ ]);
898
+ for (const key of projectEmployeeKeys) {
899
+ if (groups.has(key)) continue;
900
+ const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
901
+ if (p && p.status !== 'hired') continue; // reconcile against real entitlements (#538 follow-up)
902
+ groups.set(key, {
903
+ key,
904
+ label: p ? p.displayName : key,
905
+ detail: p ? (p.role || 'AI Employee') : '',
906
+ sample: p ? { personaKey: key } : { agentName: key },
907
+ conversations: [],
908
+ });
909
+ }
910
+
883
911
  function buildRunButton(conv, options = {}) {
884
912
  const btn = document.createElement('button');
885
913
  btn.type = 'button';
@@ -993,9 +1021,33 @@ function renderRail() {
993
1021
  summary.appendChild(avatar);
994
1022
  summary.appendChild(copy);
995
1023
  summary.appendChild(addBtn);
1024
+ // #693 R1 (PR round 2): hire-a-human-manager (#538) affordance, ported from the
1025
+ // retired emp-hero list. The old per-employee "Delegate" button is intentionally
1026
+ // omitted — it did the same openPalette('/persona') as the "+" above, so it was
1027
+ // redundant and only squeezed the employee-name column.
1028
+ if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[group.key]) {
1029
+ const hireBtn = document.createElement('button');
1030
+ hireBtn.type = 'button';
1031
+ hireBtn.className = 'eh-hire-manager';
1032
+ hireBtn.textContent = '🧑‍💼';
1033
+ hireBtn.title = 'Find a human manager for ' + group.label;
1034
+ hireBtn.setAttribute('aria-label', 'Find a human manager for ' + group.label);
1035
+ hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(group.key); });
1036
+ summary.appendChild(hireBtn);
1037
+ }
996
1038
  summary.appendChild(count);
997
1039
  details.appendChild(summary);
998
- details.appendChild(buildGroupList(group.conversations));
1040
+ if (group.conversations.length) {
1041
+ details.appendChild(buildGroupList(group.conversations));
1042
+ } else {
1043
+ const emptyWrap = document.createElement('div');
1044
+ emptyWrap.className = 'conv-employee-list';
1045
+ const em = document.createElement('div');
1046
+ em.className = 'eh-empty';
1047
+ em.textContent = 'No runs yet — + to assign a job.';
1048
+ emptyWrap.appendChild(em);
1049
+ details.appendChild(emptyWrap);
1050
+ }
999
1051
  els['conv-list'].appendChild(details);
1000
1052
  }
1001
1053
 
@@ -1010,9 +1062,11 @@ function renderRail() {
1010
1062
  wcDetails.open = true;
1011
1063
  const wcSummary = document.createElement('summary');
1012
1064
  wcSummary.className = 'conv-employee-tab';
1013
- // R4: dashed-border avatar placeholder no image or initials.
1065
+ // #693.8: watercooler icon (replaces the blank dashed placeholder).
1014
1066
  const wcAvatar = document.createElement('span');
1015
- wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--adhoc';
1067
+ wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--watercooler';
1068
+ wcAvatar.setAttribute('aria-hidden', 'true');
1069
+ wcAvatar.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.5" width="10" height="6" rx="1.2"></rect><path d="M8.5 8.5h7v9a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2z"></path><line x1="9.5" y1="21.5" x2="14.5" y2="21.5"></line></svg>';
1016
1070
  const wcCopy = document.createElement('span');
1017
1071
  wcCopy.className = 'conv-employee-tab-copy';
1018
1072
  const wcLabel = document.createElement('strong');
@@ -2628,8 +2682,8 @@ function closeTemplatePopover() {
2628
2682
  }
2629
2683
 
2630
2684
  function applyTemplateInvocation(managerJobId) {
2631
- // The invocation prefix ($fraim / /fraim) is an implementation detail that
2632
- // the server adds based on the active employee the user should never see it.
2685
+ // The server adds the invocation prefix based on the active employee. Keep
2686
+ // that syntax out of the editable textarea; the thread shows it after send.
2633
2687
  // Store the job ID as a pending coaching job. The #coach-note shows a
2634
2688
  // human-readable label; the textarea gets editable context the user can amend.
2635
2689
  // When the user clicks Send, continueRun sends { coachingJobId, instructions }.
@@ -2638,7 +2692,7 @@ function applyTemplateInvocation(managerJobId) {
2638
2692
  // for sessions that stored raw invocation text before this change).
2639
2693
  const textarea = els['coach-text'];
2640
2694
  textarea.value = textarea.value
2641
- .replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2695
+ .replace(/(?:^|\n|\s)[$/@]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2642
2696
  .replace(/\s+/g, ' ')
2643
2697
  .trim();
2644
2698
  textarea.focus();
@@ -2677,17 +2731,20 @@ function stripStubReference(text) {
2677
2731
  }
2678
2732
 
2679
2733
  function surfaceText(role, text, conv) {
2680
- const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
2734
+ const withoutStub = stripStubReference(text);
2735
+ const raw = role === 'manager'
2736
+ ? stripHubInjectedNotes(withoutStub)
2737
+ : stripHubInjectedPromptBlocks(withoutStub);
2681
2738
  if (!raw) return '';
2682
2739
 
2683
2740
  if (role === 'manager') {
2684
- const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
2741
+ const invocationOnly = raw.match(/^(?:[$/@]fraim)\s+([a-z0-9-]+)\s*$/i);
2685
2742
  if (invocationOnly) return raw;
2686
2743
  const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
2687
- const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
2744
+ const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
2688
2745
  return raw
2689
2746
  .replace(invocationWithSlug, '')
2690
- .replace(/^(?:[$/]fraim)\s*/i, '')
2747
+ .replace(/^(?:[$/@]fraim)\s*/i, '')
2691
2748
  .trim();
2692
2749
  }
2693
2750
 
@@ -2980,7 +3037,6 @@ function convAwaitingReview(conv) {
2980
3037
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
2981
3038
  // stakeholder-status-reporting) may legitimately surface artifacts for human review.
2982
3039
  if (delegationLedgerForConversation(conv)) return false;
2983
- if (conv.status === 'completed' && Array.isArray(conv.artifacts) && conv.artifacts.length > 0) return true;
2984
3040
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
2985
3041
  // only belongs when the employee actually submits for review (a review_handoff,
2986
3042
  // emitted by the submit phase). A turn that just finished (a question, a pause
@@ -3045,20 +3101,22 @@ function normalizeReviewHandoff(raw) {
3045
3101
  if (targetType === 'pull_request') {
3046
3102
  const url = safeHttpUrl(rawTarget && rawTarget.url);
3047
3103
  if (!url) return null;
3104
+ if (artifacts.length > 0) return null;
3048
3105
  return {
3049
3106
  reviewRequired: true,
3050
3107
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
3051
- artifacts,
3108
+ artifacts: [],
3052
3109
  summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
3053
3110
  feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'pull_request_comments',
3054
3111
  };
3055
3112
  }
3056
3113
 
3057
- if (targetType === 'artifact_set' && artifacts.length > 0) {
3114
+ const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
3115
+ if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
3058
3116
  return {
3059
3117
  reviewRequired: true,
3060
3118
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length || 1} artifact${artifacts.length === 1 ? '' : 's'}` },
3061
- artifacts,
3119
+ artifacts: fileArtifacts,
3062
3120
  summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
3063
3121
  feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'inline',
3064
3122
  };
@@ -3750,7 +3808,7 @@ async function handleArtifactAction(conv, action) {
3750
3808
  'Please resend the review handoff contract as valid JSON inside <review_handoff>...</review_handoff>.',
3751
3809
  'Do not rely on prose.',
3752
3810
  'Include reviewRequired, reviewTarget, artifacts, summary, and feedbackMode.',
3753
- 'Use reviewTarget.type "pull_request" only for a real PR URL, or "artifact_set" for one or more review files.',
3811
+ 'Use reviewTarget.type "pull_request" with only the PR URL and no artifacts, or "artifact_set" with exact local file paths and no PR URL.',
3754
3812
  ].join(' ');
3755
3813
  if (conv && conv.sessionId) {
3756
3814
  els['coach-text'].value = message;
@@ -3945,17 +4003,14 @@ function wirePopovers() {
3945
4003
 
3946
4004
  if (e.key === 'Escape') {
3947
4005
  const cpModal = document.getElementById('cp-modal');
3948
- const schedModal = document.getElementById('dep-schedule-modal');
3949
- const webhookModal = document.getElementById('dep-webhook-modal');
4006
+ const assignModal = document.getElementById('dep-assignment-modal');
3950
4007
  const aomModal = document.getElementById('area-onboard-modal');
3951
4008
  if (aomModal && !aomModal.hidden) {
3952
4009
  tfCloseAreaOnboardModal();
3953
4010
  } else if (cpModal && !cpModal.hidden) {
3954
4011
  closePalette();
3955
- } else if (schedModal && !schedModal.hidden) {
3956
- schedModal.hidden = true;
3957
- } else if (webhookModal && !webhookModal.hidden) {
3958
- webhookModal.hidden = true;
4012
+ } else if (assignModal && !assignModal.hidden) {
4013
+ assignModal.hidden = true;
3959
4014
  } else if (els['modal'].classList.contains('open')) {
3960
4015
  closeModal();
3961
4016
  } else {
@@ -4136,13 +4191,37 @@ function renderCpRows(searchText) {
4136
4191
  });
4137
4192
  }
4138
4193
 
4139
- // Update employee footer
4140
- const footer = document.getElementById('cp-employee-name');
4141
- if (footer) {
4142
- const emp = state.bootstrap && state.bootstrap.employees
4143
- ? state.bootstrap.employees.find((e) => e.id === (state.cpEmployee || 'claude'))
4144
- : null;
4145
- footer.textContent = emp ? emp.label : (state.cpEmployee || 'claude');
4194
+ // #693 R5: agent picker — choose the AI agent for this launch.
4195
+ renderCpAgentPicker();
4196
+ }
4197
+
4198
+ // #693 R5: render the command-palette AI-agent picker. Lists only agents
4199
+ // available on the machine as selectable; unavailable agents are shown disabled
4200
+ // with an install hint. Defaults to the saved agent (state.cpEmployee); if that
4201
+ // agent is not installed, falls back to the first available one.
4202
+ function renderCpAgentPicker() {
4203
+ const picker = document.getElementById('cp-agent-picker');
4204
+ if (!picker) return;
4205
+ picker.innerHTML = '';
4206
+ const employees = (state.bootstrap && state.bootstrap.employees) || [];
4207
+ const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
4208
+ const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
4209
+ if (!curOk) {
4210
+ const firstAvail = list.find((e) => e.available !== false);
4211
+ if (firstAvail) state.cpEmployee = firstAvail.id;
4212
+ }
4213
+ for (const e of list) {
4214
+ const pill = document.createElement('button');
4215
+ pill.type = 'button';
4216
+ pill.className = 'cp-agent-pill' + (e.id === state.cpEmployee ? ' on' : '');
4217
+ pill.textContent = e.label + (e.available === false ? ' · install' : '');
4218
+ if (e.available === false) {
4219
+ pill.disabled = true;
4220
+ pill.title = e.label + ' is not installed on this machine';
4221
+ } else {
4222
+ pill.addEventListener('click', () => { state.cpEmployee = e.id; renderCpAgentPicker(); });
4223
+ }
4224
+ picker.appendChild(pill);
4146
4225
  }
4147
4226
  }
4148
4227
 
@@ -4640,9 +4719,9 @@ function deriveTitle(jobTitle, instructions) {
4640
4719
  const trimmedJob = (jobTitle || '').trim();
4641
4720
  const raw = (instructions || '').trim();
4642
4721
  // Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
4643
- const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4722
+ const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4644
4723
  const stripped = raw
4645
- .replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4724
+ .replace(/^[$/@]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4646
4725
  .replace(/\s+/g, ' ')
4647
4726
  .trim();
4648
4727
  const words = stripped.split(' ').filter(Boolean).slice(0, 6);
@@ -4910,29 +4989,25 @@ function foldRunIntoConversation(conv, run) {
4910
4989
  conv.personaKey = run.personaKey;
4911
4990
  }
4912
4991
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
4913
- if (runHandoff) conv.reviewHandoff = runHandoff;
4914
- else reviewHandoffForConversation(conv);
4992
+ if (runHandoff) {
4993
+ conv.reviewHandoff = runHandoff;
4994
+ } else if (run.reviewHandoff) {
4995
+ conv.reviewHandoff = run.reviewHandoff;
4996
+ } else {
4997
+ conv.reviewHandoff = null;
4998
+ }
4915
4999
  const runDelegation = normalizeDelegationLedger(run.delegation);
4916
5000
  if (runDelegation) conv.delegation = runDelegation;
4917
5001
  else delegationLedgerForConversation(conv);
4918
5002
  if (Array.isArray(run.artifacts)) {
4919
5003
  conv.artifacts = run.artifacts;
5004
+ } else {
5005
+ conv.artifacts = [];
4920
5006
  }
4921
5007
  // Update status.
4922
5008
  if (run.status === 'completed') conv.status = 'completed';
4923
5009
  else if (run.status === 'failed') conv.status = 'failed';
4924
5010
  else conv.status = 'running';
4925
- // Fallback for server responses that do not include structured artifacts.
4926
- // The browser only parses output text when that structured field is absent.
4927
- if (!Array.isArray(run.artifacts)) {
4928
- for (const e of conv.events) {
4929
- for (const found of extractArtifacts(e.text)) {
4930
- if (!conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
4931
- conv.artifacts.push(found);
4932
- }
4933
- }
4934
- }
4935
- }
4936
5011
  conv.lastUpdatedAt = Date.now();
4937
5012
  }
4938
5013
 
@@ -4951,42 +5026,6 @@ function foldCompareRunIntoConversation(conv, compareRun) {
4951
5026
  };
4952
5027
  }
4953
5028
 
4954
- // Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
4955
- // raw learnings, evidence dumps, mock files), not deliverables the
4956
- // manager should be drawn to. Excluding them keeps the artifact callout
4957
- // meaningful — it should mean "the employee produced this file for you".
4958
- const ARTIFACT_EXCLUDE_RE = /(^|\/)(retrospectives|evidence|learnings|mocks|raw|archive)\//i;
4959
- function extractArtifact(text) {
4960
- return extractArtifacts(text)[0] || null;
4961
- }
4962
-
4963
- function extractArtifacts(text) {
4964
- if (!text) return [];
4965
- const artifacts = [];
4966
- const seen = new Set();
4967
- const matches = String(text)
4968
- .split(/[\s`"'()<>{}\[\],;:]+/)
4969
- .filter((token) => /^(docs|public|src|tests)\//.test(token));
4970
- for (const candidate of matches) {
4971
- const fullPath = candidate.replace(/[.)]+$/g, '');
4972
- if (!/\.[A-Za-z0-9]+$/.test(fullPath)) continue;
4973
- if (ARTIFACT_EXCLUDE_RE.test(fullPath)) continue;
4974
- const segments = fullPath.split('/');
4975
- const name = segments[segments.length - 1];
4976
- const where = segments.slice(0, -1).join('/') + '/';
4977
- // Store the absolute path so the agent can re-read the artifact (and any
4978
- // .docx export written alongside it) without guessing the project root.
4979
- const absPath = state.projectPath
4980
- ? state.projectPath.replace(/\\/g, '/').replace(/\/$/, '') + '/' + fullPath
4981
- : null;
4982
- const key = absPath || fullPath;
4983
- if (seen.has(key)) continue;
4984
- seen.add(key);
4985
- artifacts.push({ name, where, path: absPath });
4986
- }
4987
- return artifacts;
4988
- }
4989
-
4990
5029
  function startPolling() {
4991
5030
  if (state.pollHandle) window.clearInterval(state.pollHandle);
4992
5031
  state.pollHandle = window.setInterval(async () => {
@@ -6004,7 +6043,7 @@ const tf = {
6004
6043
  area: 'projects', // company | manager | projects | brain
6005
6044
  projectView: 'overview', // overview | workspace
6006
6045
  activeProjectId: null,
6007
- projects: [], // [{ id, name, intent, brief, team:[personaKey] }]
6046
+ projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
6008
6047
  assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
6009
6048
  npStep: 1,
6010
6049
  npSelectedEmployees: [],
@@ -6026,6 +6065,12 @@ function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) ||
6026
6065
  function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
6027
6066
  function tfAvailablePersonas() { return tfPersonas().filter((p) => p.status !== 'hired'); }
6028
6067
  function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) || null; }
6068
+ // A project's employee roster is derived from the hired personas in the bootstrap
6069
+ // (DB-authoritative), NOT from per-machine local state. With the legacy-workspace
6070
+ // bypass every user sees the full employee catalog, and the roster is identical on
6071
+ // every machine the user signs in from. Deliberately not persisted to disk /
6072
+ // localStorage — see AiHubProjectListEntry in src/ai-hub/types.ts.
6073
+ function tfProjectTeamKeys() { return tfHiredPersonas().map((p) => p.key); }
6029
6074
 
6030
6075
  // ---------------------------------------------------------------------------
6031
6076
  // Persistence (projects + assignments, mirrors the conversation model)
@@ -6056,7 +6101,8 @@ function tfNormalizeProject(raw, fallbackPath) {
6056
6101
  outcome: raw.outcome || '',
6057
6102
  rules: raw.rules || '',
6058
6103
  brief: raw.brief || raw.intent || '',
6059
- team: Array.isArray(raw.team) ? raw.team.filter((key) => typeof key === 'string') : [],
6104
+ // Roster is derived from hired personas at render time (tfProjectTeamKeys),
6105
+ // never stored per machine. Any legacy `team` on stored state is dropped.
6060
6106
  updatedAt: raw.updatedAt || undefined,
6061
6107
  };
6062
6108
  }
@@ -6086,9 +6132,6 @@ function tfMergeProjects(projects) {
6086
6132
  const existing = merged.get(key);
6087
6133
  if (existing) {
6088
6134
  const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
6089
- if ((!Array.isArray(normalized.team) || normalized.team.length === 0) && Array.isArray(existing.team) && existing.team.length > 0) {
6090
- next.team = existing.team;
6091
- }
6092
6135
  merged.set(key, next);
6093
6136
  } else {
6094
6137
  merged.set(key, normalized);
@@ -6110,7 +6153,6 @@ function tfEnsureCurrentProject() {
6110
6153
  id: tfProjectIdForPath(state.projectPath),
6111
6154
  name: friendlyProjectShortName(state.projectPath),
6112
6155
  folderPath: state.projectPath,
6113
- team: tfHiredPersonas().map((p) => p.key),
6114
6156
  }, state.projectPath);
6115
6157
  tfMergeProjects([current]);
6116
6158
  const resolved = tfProjectForPath(state.projectPath);
@@ -6411,7 +6453,7 @@ function tfRenderOverview() {
6411
6453
  const team = document.createElement('div');
6412
6454
  team.className = 'proj-team';
6413
6455
  const assigned = tfProjectAssignments(proj.id);
6414
- const empKeys = Array.from(new Set([...(proj.team || []), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6456
+ const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6415
6457
  empKeys.slice(0, 6).forEach((key, i) => {
6416
6458
  const persona = tfPersonaByKey(key);
6417
6459
  const av = tfAvatarFor(persona ? persona.displayName : key, i);
@@ -6683,10 +6725,10 @@ function tfRenderTree() {
6683
6725
  });
6684
6726
  host.appendChild(briefNav);
6685
6727
  host.appendChild(tfTreeSep());
6686
- // #521: "Project Updates" the project-lifecycle jobs (project onboarding +
6687
- // sleep-on-learnings) grouped in one accordion with run history, above New job.
6688
- host.appendChild(tfProjectUpdatesSection());
6689
- host.appendChild(tfTreeSep());
6728
+ // #693 R1: the standalone "Project Updates" accordion is retired. Its two
6729
+ // lifecycle actions now live where their output lands Run Project Onboarding
6730
+ // in the Brief section and Sleep on learnings in the Learnings section
6731
+ // (see tfRenderProjectContextTop). No separate tree section.
6690
6732
  // The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
6691
6733
  // #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
6692
6734
  // which the shell suppresses (display:none). Relocate the live node to the
@@ -6699,127 +6741,20 @@ function tfRenderTree() {
6699
6741
  host.appendChild(newConvBtn);
6700
6742
  host.appendChild(tfTreeSep());
6701
6743
  }
6702
- // The legacy conversation switcher (#conv-list .conv-item buttons, rendered
6703
- // by renderRail) also lives in the suppressed rail. Relocate the whole Runs
6704
- // section so standalone conversations stay switchable the contract behind
6705
- // #429 R6 (thread-collapse persists across conversation switches). renderRail
6706
- // keeps targeting the same cached #conv-list node wherever it now lives.
6744
+ // #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
6745
+ // conv-employee-group) ARE the single employees section they list every
6746
+ // project employee with that employee's runs nested under their accordion.
6747
+ // Relocate that section into the tree; the redundant emp-hero list is retired.
6707
6748
  const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
6708
6749
  const runsSection = convList && convList.closest('.rail-section--runs');
6709
6750
  if (runsSection) {
6710
6751
  runsSection.classList.add('tree-runs');
6711
6752
  host.appendChild(runsSection);
6712
6753
  }
6713
- const projectId = tf.activeProjectId;
6714
- const assigned = tfProjectAssignments(projectId);
6715
- const project = tf.projects.find((p) => p.id === projectId);
6716
- const onProject = (project && project.team) || [];
6717
- // Issue #540 R8: include personas on the manager's team so delegate buttons
6718
- // appear in the workspace tree even when the persona hasn't been assigned yet.
6719
- const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
6720
- const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
6721
- // #538 follow-up: reconcile against real entitlements. The localStorage
6722
- // project.team can hold members who are no longer hired (or were seeded
6723
- // before an entitlement change), which made un-hired personas like swen
6724
- // appear in a workspace where only beza/pam are actually hired. Drop any
6725
- // key that resolves to a known persona that is NOT hired; keep hired
6726
- // personas and genuinely custom employees (no persona record).
6727
- .filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
6728
- const managerTeamKeySet = new Set(managerTeamPersonaKeys);
6729
-
6730
- empKeys.forEach((key, i) => {
6731
- const persona = tfPersonaByKey(key);
6732
- const name = persona ? persona.displayName : key;
6733
- const role = (persona && persona.role) || '';
6734
- const av = tfAvatarFor(name, i);
6735
- // R1 (#521): each employee is a large hero accordion — the team are the
6736
- // heroes of the workspace, not a thin nav list. Their jobs nest inside.
6737
- const details = document.createElement('details');
6738
- details.className = 'emp-hero';
6739
- details.dataset.accKey = 'emp:' + key; // #533 R5: stable key for open-state preservation
6740
- details.open = true;
6741
- const summary = document.createElement('summary');
6742
- summary.title = name;
6743
- const avEl = document.createElement('div');
6744
- avEl.className = 'eh-av';
6745
- avEl.style.background = av.color;
6746
- avEl.textContent = av.badge;
6747
- const idWrap = document.createElement('div');
6748
- idWrap.className = 'eh-id';
6749
- const nameEl = document.createElement('div');
6750
- nameEl.className = 'eh-name';
6751
- nameEl.textContent = name;
6752
- idWrap.appendChild(nameEl);
6753
- if (role) {
6754
- const roleEl = document.createElement('div');
6755
- roleEl.className = 'eh-role';
6756
- roleEl.textContent = role;
6757
- idWrap.appendChild(roleEl);
6758
- }
6759
- const addBtn = document.createElement('button');
6760
- addBtn.type = 'button';
6761
- addBtn.className = 'eh-add';
6762
- addBtn.textContent = '+';
6763
- addBtn.title = 'Assign a job to ' + name;
6764
- addBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); tfOpenAssignJob(key); });
6765
- // Issue #538 — per-employee entry point: hire a human manager for this role.
6766
- let hireBtn = null;
6767
- if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[key]) {
6768
- hireBtn = document.createElement('button');
6769
- hireBtn.type = 'button';
6770
- hireBtn.className = 'eh-hire-manager';
6771
- hireBtn.textContent = '🧑‍💼';
6772
- hireBtn.title = 'Find a human manager for ' + name;
6773
- hireBtn.setAttribute('aria-label', 'Find a human manager for ' + name);
6774
- hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(key); });
6775
- }
6776
- const empDot = tfEmployeeDot(projectId, key);
6777
- const dot = document.createElement('span');
6778
- dot.className = 'eh-dot t-dot dot-' + empDot;
6779
- dot.style.background = 'var(--' + empDot + ')';
6780
- dot.title = tfDotTitle(empDot);
6781
- const chev = document.createElement('span');
6782
- chev.className = 'eh-chev';
6783
- chev.textContent = '▸';
6784
- summary.appendChild(avEl);
6785
- summary.appendChild(idWrap);
6786
- if (hireBtn) summary.appendChild(hireBtn);
6787
- summary.appendChild(addBtn);
6788
- // Issue #540 R8: delegate button for personas on the manager's team.
6789
- if (managerTeamKeySet.has(key)) {
6790
- summary.appendChild(renderDelegateButton(key));
6791
- }
6792
- summary.appendChild(dot);
6793
- summary.appendChild(chev);
6794
- details.appendChild(summary);
6795
-
6796
- const body = document.createElement('div');
6797
- body.className = 'eh-jobs';
6798
- const empJobs = assigned.filter((x) => x.employeeKey === key);
6799
- if (empJobs.length === 0) {
6800
- const none = document.createElement('div');
6801
- none.className = 'eh-empty';
6802
- none.textContent = 'No jobs yet — + to assign one.';
6803
- body.appendChild(none);
6804
- }
6805
- for (const a of empJobs) {
6806
- const jobRow = document.createElement('div');
6807
- jobRow.className = 'tree-job';
6808
- const jobDot = tfAssignmentDot(a);
6809
- const label = document.createElement('span');
6810
- label.className = 'tj-label';
6811
- label.textContent = a.jobName;
6812
- const jd = document.createElement('span');
6813
- jd.className = 'tj-dot dot-' + jobDot;
6814
- jd.style.background = 'var(--' + jobDot + ')';
6815
- jd.title = tfDotTitle(jobDot);
6816
- jobRow.appendChild(label); jobRow.appendChild(jd);
6817
- jobRow.addEventListener('click', () => tfSelectAssignmentConversation(a));
6818
- body.appendChild(jobRow);
6819
- }
6820
- details.appendChild(body);
6821
- host.appendChild(details);
6822
- });
6754
+ // #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
6755
+ // relocated Runs section above (renderRail's conv-employee-group) is now the
6756
+ // single employees section — every project employee, with their runs nested
6757
+ // under their own accordion, plus per-employee assign/hire-manager.
6823
6758
 
6824
6759
  // #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
6825
6760
  // collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
@@ -6920,6 +6855,37 @@ function tfRenderProjectContextTop() {
6920
6855
  // #533 #4: project-LEVEL manager learnings (repo-local) live in the project tab.
6921
6856
  tfRenderLearningsList(learnHost, 'manager', 'project', 'Nothing here yet — what your team learns about working with you on THIS project (preferences, patterns to avoid, validated approaches) appears here. Add one with + Add learning.');
6922
6857
  }
6858
+ // #693 R1: the retired "Project Updates" section's actions now live where their
6859
+ // output lands — Run Project Onboarding in the Brief section, Sleep on learnings
6860
+ // in the Learnings section.
6861
+ tfEnsureAccAction('proj-brief-acc', '🚀', 'Run Project Onboarding', () => tfOpenOnboardInput('run'));
6862
+ tfEnsureAccAction('proj-learnings-acc', '🧠', 'Sleep on learnings', () => tfRunShareLearnings('project'));
6863
+ }
6864
+
6865
+ // #693 R1: insert (idempotently) a single lifecycle-action button at the top of a
6866
+ // ctx accordion's body. Used to place onboarding/sleep actions inside the Brief,
6867
+ // Learnings, and Company/Manager context sections they populate.
6868
+ function tfEnsureAccAction(accId, ico, label, onClick) {
6869
+ const acc = document.getElementById(accId);
6870
+ if (!acc) return;
6871
+ const body = acc.querySelector('.ctx-acc-body');
6872
+ if (!body) return;
6873
+ let row = body.querySelector(':scope > .ctx-acc-action-row');
6874
+ if (!row) {
6875
+ row = document.createElement('div');
6876
+ row.className = 'ctx-acc-action-row';
6877
+ body.insertBefore(row, body.firstChild);
6878
+ }
6879
+ // Rebuild the button so repeated renders don't stack duplicates or stale handlers.
6880
+ row.innerHTML = '';
6881
+ const btn = document.createElement('button');
6882
+ btn.type = 'button';
6883
+ btn.className = 'ctx-acc-action';
6884
+ const ic = document.createElement('span'); ic.className = 'caa-ico'; ic.textContent = ico;
6885
+ const tx = document.createElement('span'); tx.textContent = label;
6886
+ btn.appendChild(ic); btn.appendChild(tx);
6887
+ btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(); });
6888
+ row.appendChild(btn);
6923
6889
  }
6924
6890
 
6925
6891
  // #521: the Brief section is the project's real captured context + rules — the two
@@ -6991,9 +6957,12 @@ async function loadDeployments() {
6991
6957
  const list = document.getElementById('proj-deployments-list');
6992
6958
  if (!list) return;
6993
6959
  try {
6960
+ const params = new URLSearchParams();
6961
+ if (state.projectPath) params.set('projectPath', state.projectPath);
6962
+ const suffix = params.toString() ? '?' + params.toString() : '';
6994
6963
  const [schResp, whResp] = await Promise.all([
6995
- fetch('/api/ai-hub/schedules'),
6996
- fetch('/api/ai-hub/webhooks'),
6964
+ fetch('/api/ai-hub/schedules' + suffix),
6965
+ fetch('/api/ai-hub/webhooks' + suffix),
6997
6966
  ]);
6998
6967
  const schedules = schResp.ok ? await schResp.json() : [];
6999
6968
  const webhooks = whResp.ok ? await whResp.json() : [];
@@ -7085,119 +7054,181 @@ function cronToHuman(expr) {
7085
7054
  return expr;
7086
7055
  }
7087
7056
 
7057
+ // #693 R3 (PR round 2): render assignments grouped by the EMPLOYEE the job maps to
7058
+ // (job.requiredPersonaKey), not the AI agent, reusing the Runs section's
7059
+ // conv-employee-group accordion so the two sections look and behave the same.
7088
7060
  function renderDeploymentList(container, deployments) {
7089
7061
  container.innerHTML = '';
7090
7062
  if (!deployments.length) {
7091
7063
  const empty = document.createElement('p');
7092
7064
  empty.className = 'dep-empty';
7093
- empty.textContent = 'No assignments yet. Use + Schedule or + Trigger above to set up scheduled and triggered assignments.';
7065
+ empty.textContent = 'No assignments yet. Use + New Assignment above to schedule a job or trigger it from an external system.';
7094
7066
  container.appendChild(empty);
7095
7067
  return;
7096
7068
  }
7069
+ const jobs = state.bootstrap?.jobs ?? [];
7070
+ const UNASSIGNED = '__unassigned__';
7071
+ // The employee is determined by the job: its requiredPersonaKey. Fall back to an
7072
+ // "Unassigned" group for generic jobs with no required specialist.
7073
+ const personaKeyForDep = (dep) => {
7074
+ const j = jobs.find((x) => x.id === dep.jobId);
7075
+ return (j && j.requiredPersonaKey) || UNASSIGNED;
7076
+ };
7077
+ const groups = new Map();
7097
7078
  for (const dep of deployments) {
7098
- const card = document.createElement('div');
7099
- card.className = 'dep-card';
7100
- const top = document.createElement('div');
7101
- top.className = 'dep-card-top';
7102
- const icon = document.createElement('span');
7103
- icon.className = 'dep-type-icon';
7104
- icon.textContent = dep.type === 'scheduled' ? '⏱' : '🔗';
7105
- const label = document.createElement('span');
7106
- label.className = 'dep-label';
7107
- label.textContent = dep.label;
7108
- const typeBadge = document.createElement('span');
7109
- typeBadge.className = 'dep-type-badge dep-type-badge--' + dep.type;
7110
- typeBadge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
7111
- top.appendChild(icon);
7112
- top.appendChild(label);
7113
- top.appendChild(typeBadge);
7114
- card.appendChild(top);
7115
-
7116
- if (dep.cronExpr) {
7117
- const cron = document.createElement('div');
7118
- cron.className = 'dep-detail';
7119
- const human = cronToHuman(dep.cronExpr);
7120
- cron.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
7121
- if (human !== dep.cronExpr) cron.title = dep.cronExpr;
7122
- card.appendChild(cron);
7123
- }
7124
-
7125
- const employee = (state.bootstrap?.employees || []).find((e) => e.id === dep.hostId);
7126
- if (employee) {
7127
- const empRow = document.createElement('div');
7128
- empRow.className = 'dep-detail dep-detail--emp';
7129
- empRow.textContent = employee.label;
7130
- card.appendChild(empRow);
7079
+ const key = personaKeyForDep(dep);
7080
+ if (!groups.has(key)) groups.set(key, []);
7081
+ groups.get(key).push(dep);
7082
+ }
7083
+ for (const [personaKey, deps] of groups) {
7084
+ const persona = personaKey === UNASSIGNED ? null : tfPersonaByKey(personaKey);
7085
+ const empLabel = persona ? persona.displayName : 'Unassigned';
7086
+ const empDetail = persona ? (persona.role || 'AI Employee') : 'No specialist required';
7087
+ const details = document.createElement('details');
7088
+ details.className = 'conv-employee-group';
7089
+ details.open = true;
7090
+ const summary = document.createElement('summary');
7091
+ summary.className = 'conv-employee-tab';
7092
+ // DiceBear when the employee has an avatar; else an initials chip.
7093
+ let avatar;
7094
+ if (persona && persona.avatarUrl) {
7095
+ avatar = document.createElement('img');
7096
+ avatar.className = 'conv-employee-avatar';
7097
+ avatar.src = persona.avatarUrl; avatar.alt = empLabel;
7098
+ } else {
7099
+ avatar = document.createElement('span');
7100
+ avatar.className = 'conv-employee-avatar';
7101
+ avatar.textContent = initialBadge(empLabel);
7131
7102
  }
7132
-
7133
- if (dep.inboundUrl) {
7134
- const urlRow = document.createElement('div');
7135
- urlRow.className = 'dep-detail dep-detail--url';
7136
- const urlCode = document.createElement('code');
7137
- urlCode.textContent = dep.inboundUrl;
7138
- urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
7139
- const copyBtn = document.createElement('button');
7140
- copyBtn.type = 'button';
7141
- copyBtn.className = 'hm-copy-btn';
7142
- copyBtn.textContent = 'Copy';
7143
- copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
7144
- urlRow.appendChild(urlCode);
7145
- urlRow.appendChild(copyBtn);
7146
- card.appendChild(urlRow);
7103
+ const copy = document.createElement('span');
7104
+ copy.className = 'conv-employee-tab-copy';
7105
+ const label = document.createElement('strong');
7106
+ label.className = 'conv-employee-tab-label';
7107
+ label.textContent = empLabel;
7108
+ const detail = document.createElement('small');
7109
+ detail.className = 'conv-employee-tab-detail';
7110
+ detail.textContent = empDetail;
7111
+ copy.appendChild(label); copy.appendChild(detail);
7112
+ const count = document.createElement('span');
7113
+ count.className = 'conv-employee-tab-count';
7114
+ count.textContent = String(deps.length);
7115
+ const addBtn = document.createElement('button');
7116
+ addBtn.type = 'button';
7117
+ addBtn.className = 'conv-employee-add';
7118
+ addBtn.textContent = '+';
7119
+ addBtn.title = 'New assignment for ' + empLabel;
7120
+ addBtn.setAttribute('aria-label', 'New assignment for ' + empLabel);
7121
+ addBtn.addEventListener('click', (e) => {
7122
+ e.preventDefault(); e.stopPropagation();
7123
+ openDeploymentModal();
7124
+ // Pre-select a job that maps to this employee so the grouping stays consistent.
7125
+ if (persona) {
7126
+ const sel = document.getElementById('dep-job');
7127
+ const match = jobs.find((x) => x.requiredPersonaKey === personaKey);
7128
+ if (sel && match && Array.from(sel.options).some((o) => o.value === match.id)) sel.value = match.id;
7129
+ }
7130
+ });
7131
+ summary.appendChild(avatar);
7132
+ summary.appendChild(copy);
7133
+ summary.appendChild(addBtn);
7134
+ summary.appendChild(count);
7135
+ details.appendChild(summary);
7136
+ const list = document.createElement('div');
7137
+ list.className = 'conv-employee-list';
7138
+ for (const dep of deps) {
7139
+ list.appendChild(buildDeploymentRow(dep));
7140
+ if (dep.inboundUrl) list.appendChild(buildInboundUrlRow(dep));
7147
7141
  }
7142
+ details.appendChild(list);
7143
+ container.appendChild(details);
7144
+ }
7145
+ }
7148
7146
 
7149
- const cardActions = document.createElement('div');
7150
- cardActions.className = 'dep-card-actions';
7151
-
7152
- const editBtn = document.createElement('button');
7153
- editBtn.type = 'button';
7154
- editBtn.className = 'dep-edit-btn';
7155
- editBtn.title = 'Edit this assignment';
7156
- editBtn.textContent = 'Edit';
7157
- editBtn.addEventListener('click', () => { openDeploymentModal(dep.type === 'scheduled' ? 'schedule' : 'webhook', dep); });
7158
- cardActions.appendChild(editBtn);
7159
-
7160
- const delBtn = document.createElement('button');
7161
- delBtn.type = 'button';
7162
- delBtn.className = 'dep-del-btn';
7163
- delBtn.title = 'Remove this assignment';
7164
- delBtn.textContent = 'Remove';
7165
- delBtn.addEventListener('click', async () => {
7166
- const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
7167
- await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
7168
- await loadDeployments();
7169
- });
7170
- cardActions.appendChild(delBtn);
7171
- card.appendChild(cardActions);
7172
- container.appendChild(card);
7147
+ // #693 R3: a single assignment rendered as a Runs-style row.
7148
+ function buildDeploymentRow(dep) {
7149
+ const row = document.createElement('div');
7150
+ // #693 R3: a dedicated class (not .conv-item) so run-list logic never treats an
7151
+ // assignment as a run; styled to match the Runs rows via CSS.
7152
+ row.className = 'dep-row';
7153
+ const body = document.createElement('span');
7154
+ body.className = 'conv-body';
7155
+ const title = document.createElement('span');
7156
+ title.className = 'dep-row-title';
7157
+ title.textContent = dep.label;
7158
+ body.appendChild(title);
7159
+ const meta = document.createElement('span');
7160
+ meta.className = 'assign-row-meta';
7161
+ const badge = document.createElement('span');
7162
+ badge.className = 'assign-badge assign-badge--' + (dep.type === 'scheduled' ? 'scheduled' : 'triggered');
7163
+ badge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
7164
+ meta.appendChild(badge);
7165
+ const detailText = document.createElement('span');
7166
+ if (dep.type === 'scheduled' && dep.cronExpr) {
7167
+ const human = cronToHuman(dep.cronExpr);
7168
+ detailText.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
7169
+ if (human !== dep.cronExpr) detailText.title = dep.cronExpr;
7170
+ } else {
7171
+ detailText.textContent = 'Inbound webhook';
7173
7172
  }
7173
+ meta.appendChild(detailText);
7174
+ body.appendChild(meta);
7175
+ row.appendChild(body);
7176
+ const actions = document.createElement('span');
7177
+ actions.className = 'dep-row-actions';
7178
+ const editBtn = document.createElement('button');
7179
+ editBtn.type = 'button'; editBtn.className = 'dep-edit-btn'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit this assignment';
7180
+ editBtn.addEventListener('click', () => openDeploymentModal(dep));
7181
+ const delBtn = document.createElement('button');
7182
+ delBtn.type = 'button'; delBtn.className = 'dep-del-btn'; delBtn.textContent = 'Remove'; delBtn.title = 'Remove this assignment';
7183
+ delBtn.addEventListener('click', async () => {
7184
+ const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
7185
+ await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
7186
+ await loadDeployments();
7187
+ });
7188
+ actions.appendChild(editBtn); actions.appendChild(delBtn);
7189
+ row.appendChild(actions);
7190
+ return row;
7191
+ }
7192
+
7193
+ // #693 R3: inbound-URL sub-row for triggered assignments (kept from the old card).
7194
+ function buildInboundUrlRow(dep) {
7195
+ const urlRow = document.createElement('div');
7196
+ // #693 R3: self-contained class — do NOT add .dep-detail (its width:100% plus this
7197
+ // row's left margin overflows the container and forces a horizontal scrollbar).
7198
+ urlRow.className = 'dep-inbound-sub';
7199
+ const urlCode = document.createElement('code');
7200
+ urlCode.textContent = dep.inboundUrl;
7201
+ urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
7202
+ const copyBtn = document.createElement('button');
7203
+ copyBtn.type = 'button';
7204
+ copyBtn.className = 'hm-copy-btn';
7205
+ copyBtn.textContent = 'Copy';
7206
+ copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
7207
+ urlRow.appendChild(urlCode);
7208
+ urlRow.appendChild(copyBtn);
7209
+ return urlRow;
7174
7210
  }
7175
7211
 
7176
7212
  function initDeploymentButtons() {
7177
- const addSchBtn = document.getElementById('dep-add-schedule-btn');
7178
- const addWhBtn = document.getElementById('dep-add-webhook-btn');
7179
- if (addSchBtn) addSchBtn.addEventListener('click', () => openDeploymentModal('schedule'));
7180
- if (addWhBtn) addWhBtn.addEventListener('click', () => openDeploymentModal('webhook'));
7181
-
7182
- // Close buttons
7183
- const schClose = document.getElementById('dep-schedule-close');
7184
- const whClose = document.getElementById('dep-webhook-close');
7185
- if (schClose) schClose.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
7186
- if (whClose) whClose.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
7187
-
7188
- // Cancel buttons
7189
- const schCancel = document.getElementById('dep-sch-cancel-btn');
7190
- const whCancel = document.getElementById('dep-wh-cancel-btn');
7191
- if (schCancel) schCancel.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
7192
- if (whCancel) whCancel.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
7193
-
7194
- // Save buttons
7195
- const schSave = document.getElementById('dep-sch-save-btn');
7196
- if (schSave) schSave.addEventListener('click', saveScheduleDeployment);
7197
- const whSave = document.getElementById('dep-wh-save-btn');
7198
- if (whSave) whSave.addEventListener('click', saveWebhookDeployment);
7199
-
7200
- // Preset chips
7213
+ // #693 R2: one entry point opens the consolidated assignment modal.
7214
+ const addBtn = document.getElementById('dep-add-btn');
7215
+ if (addBtn) addBtn.addEventListener('click', () => openDeploymentModal());
7216
+
7217
+ const closeModal = () => { const m = document.getElementById('dep-assignment-modal'); if (m) m.hidden = true; };
7218
+ const close = document.getElementById('dep-assign-close');
7219
+ const cancel = document.getElementById('dep-cancel-btn');
7220
+ if (close) close.addEventListener('click', closeModal);
7221
+ if (cancel) cancel.addEventListener('click', closeModal);
7222
+
7223
+ const save = document.getElementById('dep-save-btn');
7224
+ if (save) save.addEventListener('click', saveDeployment);
7225
+
7226
+ // Scheduled / Triggered segmented toggle.
7227
+ document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((btn) => {
7228
+ btn.addEventListener('click', () => setDepType(btn.dataset.depType));
7229
+ });
7230
+
7231
+ // Preset chips (scheduled block).
7201
7232
  document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(chip => {
7202
7233
  chip.addEventListener('click', () => applySchPreset(chip.dataset.preset));
7203
7234
  });
@@ -7243,131 +7274,131 @@ function initDeploymentButtons() {
7243
7274
  });
7244
7275
  }
7245
7276
 
7246
- function openDeploymentModal(type, dep) {
7247
- const jobs = state.bootstrap?.jobs ?? [];
7277
+ // #693 R2: current assignment type in the consolidated modal.
7278
+ let _depType = 'scheduled';
7279
+ function setDepType(type) {
7280
+ _depType = (type === 'triggered') ? 'triggered' : 'scheduled';
7281
+ document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((b) => {
7282
+ b.classList.toggle('on', b.dataset.depType === _depType);
7283
+ });
7284
+ const sched = document.getElementById('dep-sched-block');
7285
+ const trig = document.getElementById('dep-trig-block');
7286
+ if (sched) sched.hidden = _depType !== 'scheduled';
7287
+ if (trig) trig.hidden = _depType !== 'triggered';
7288
+ }
7289
+
7290
+ // #693 R2/R5: populate the AI Agent select with only agents available on the
7291
+ // machine. Mirrors the existing employee-select behavior — unavailable agents are
7292
+ // shown disabled rather than as selectable options.
7293
+ function populateAgentSelect(sel, currentHostId) {
7294
+ sel.innerHTML = '';
7248
7295
  const employees = state.bootstrap?.employees ?? [];
7296
+ const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
7297
+ for (const e of list) {
7298
+ const opt = document.createElement('option');
7299
+ opt.value = e.id;
7300
+ opt.textContent = e.label + (e.available === false ? ' (not installed)' : '');
7301
+ if (e.available === false) opt.disabled = true;
7302
+ sel.appendChild(opt);
7303
+ }
7304
+ const avail = list.filter((e) => e.available !== false);
7305
+ const pick = (currentHostId && list.some((e) => e.id === currentHostId && e.available !== false))
7306
+ ? currentHostId
7307
+ : (avail[0]?.id || list[0]?.id);
7308
+ if (pick) sel.value = pick;
7309
+ }
7310
+
7311
+ // #693 R2: open the single consolidated assignment modal. `dep` present = edit;
7312
+ // its .type locks the segmented control. New assignments default to Scheduled.
7313
+ function openDeploymentModal(dep) {
7314
+ const jobs = state.bootstrap?.jobs ?? [];
7249
7315
  const editing = dep != null;
7250
7316
  _editingDepId = editing ? dep.id : null;
7251
7317
 
7252
- function populateJobs(sel, currentJobId) {
7253
- sel.innerHTML = '';
7254
- for (const j of jobs) {
7255
- const opt = document.createElement('option');
7256
- opt.value = j.id;
7257
- opt.textContent = j.title;
7258
- sel.appendChild(opt);
7259
- }
7260
- if (currentJobId) sel.value = currentJobId;
7318
+ const jobSel = document.getElementById('dep-job');
7319
+ jobSel.innerHTML = '';
7320
+ for (const j of jobs) {
7321
+ const opt = document.createElement('option');
7322
+ opt.value = j.id; opt.textContent = j.title;
7323
+ jobSel.appendChild(opt);
7324
+ }
7325
+ if (dep?.jobId) jobSel.value = dep.jobId;
7326
+
7327
+ populateAgentSelect(document.getElementById('dep-agent'), dep?.hostId || 'claude');
7328
+ document.getElementById('dep-label').value = dep?.label ?? '';
7329
+ document.getElementById('dep-instructions').value = dep?.instructions ?? '';
7330
+ const errEl = document.getElementById('dep-error');
7331
+ if (errEl) errEl.hidden = true;
7332
+ const inbound = document.getElementById('dep-wh-inbound-row');
7333
+ if (inbound) inbound.hidden = true;
7334
+
7335
+ setDepType(editing ? (dep.type === 'scheduled' ? 'scheduled' : 'triggered') : 'scheduled');
7336
+
7337
+ // Scheduled fields: detect preset and pre-fill time/day.
7338
+ const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
7339
+ if (preset && preset !== 'custom' && dep?.cronExpr) {
7340
+ const parts = dep.cronExpr.trim().split(/\s+/);
7341
+ const m = parseInt(parts[0], 10) || 0;
7342
+ const h = parseInt(parts[1], 10) || 9;
7343
+ document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
7344
+ if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
7345
+ document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
7346
+ } else if (preset === 'custom' && dep?.cronExpr) {
7347
+ document.getElementById('dep-sch-cron').value = dep.cronExpr;
7348
+ const cronPreview = document.getElementById('dep-sch-cron-preview');
7349
+ if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
7350
+ } else {
7351
+ document.getElementById('dep-sch-cron').value = '';
7352
+ document.getElementById('dep-sch-time').value = '09:00';
7261
7353
  }
7354
+ applySchPreset(preset || 'daily');
7262
7355
 
7263
- function populateEmployees(sel, currentHostId) {
7264
- sel.innerHTML = '';
7265
- const fallback = [{ id: 'claude', label: 'Claude' }, { id: 'codex', label: 'Codex' }, { id: 'gemini', label: 'Gemini' }, { id: 'copilot', label: 'Copilot' }];
7266
- const list = employees.length ? employees : fallback;
7267
- for (const e of list) {
7268
- const opt = document.createElement('option');
7269
- opt.value = e.id;
7270
- opt.textContent = e.label;
7271
- sel.appendChild(opt);
7272
- }
7273
- if (currentHostId) sel.value = currentHostId;
7274
- }
7275
-
7276
- if (type === 'schedule') {
7277
- const modal = document.getElementById('dep-schedule-modal');
7278
- document.getElementById('dep-sch-modal-title').textContent = editing ? 'Edit Scheduled Assignment' : 'New Scheduled Assignment';
7279
- document.getElementById('dep-sch-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
7280
- populateJobs(document.getElementById('dep-sch-job'), dep?.jobId);
7281
- populateEmployees(document.getElementById('dep-sch-host'), dep?.hostId || 'claude');
7282
- document.getElementById('dep-sch-error').hidden = true;
7283
- document.getElementById('dep-sch-label').value = dep?.label ?? '';
7284
- document.getElementById('dep-sch-instructions').value = dep?.instructions ?? '';
7285
- // Detect preset and pre-fill time/day
7286
- const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
7287
- if (preset && preset !== 'custom' && dep?.cronExpr) {
7288
- const parts = dep.cronExpr.trim().split(/\s+/);
7289
- const m = parseInt(parts[0], 10) || 0;
7290
- const h = parseInt(parts[1], 10) || 9;
7291
- document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
7292
- if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
7293
- document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
7294
- } else if (preset === 'custom' && dep?.cronExpr) {
7295
- document.getElementById('dep-sch-cron').value = dep.cronExpr;
7296
- const cronPreview = document.getElementById('dep-sch-cron-preview');
7297
- if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
7298
- } else {
7299
- document.getElementById('dep-sch-cron').value = '';
7300
- document.getElementById('dep-sch-time').value = '09:00';
7301
- }
7302
- applySchPreset(preset || 'daily');
7303
- modal.hidden = false;
7304
- } else {
7305
- const modal = document.getElementById('dep-webhook-modal');
7306
- document.getElementById('dep-wh-modal-title').textContent = editing ? 'Edit Triggered Assignment' : 'New Triggered Assignment';
7307
- document.getElementById('dep-wh-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
7308
- populateJobs(document.getElementById('dep-wh-job'), dep?.jobId);
7309
- populateEmployees(document.getElementById('dep-wh-host'), dep?.hostId || 'claude');
7310
- document.getElementById('dep-wh-error').hidden = true;
7311
- document.getElementById('dep-wh-inbound-row').hidden = true;
7312
- document.getElementById('dep-wh-label').value = dep?.label ?? '';
7313
- document.getElementById('dep-wh-instructions').value = dep?.instructions ?? '';
7314
- modal.hidden = false;
7315
- }
7316
- }
7317
-
7318
- async function saveScheduleDeployment() {
7319
- const label = document.getElementById('dep-sch-label').value.trim();
7320
- const jobId = document.getElementById('dep-sch-job').value;
7321
- const hostId = document.getElementById('dep-sch-host').value;
7322
- const isCustom = _activeSchPreset === 'custom';
7323
- const cronExpr = isCustom
7324
- ? document.getElementById('dep-sch-cron').value.trim()
7325
- : (document.getElementById('dep-sch-cron-preset').value.trim() || '');
7326
- const instructions = document.getElementById('dep-sch-instructions').value.trim();
7327
- const errEl = document.getElementById('dep-sch-error');
7328
- if (!label || !cronExpr) { errEl.textContent = 'Label and schedule are required.'; errEl.hidden = false; return; }
7329
- const isEdit = _editingDepId !== null;
7330
- const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
7331
- const method = isEdit ? 'PUT' : 'POST';
7332
- try {
7333
- const resp = await fetch(url, {
7334
- method,
7335
- headers: { 'Content-Type': 'application/json' },
7336
- body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
7337
- });
7338
- 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; }
7339
- _editingDepId = null;
7340
- document.getElementById('dep-schedule-modal').hidden = true;
7341
- await loadDeployments();
7342
- } catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
7356
+ document.getElementById('dep-assign-title').textContent = editing ? 'Edit assignment' : 'New assignment';
7357
+ document.getElementById('dep-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
7358
+ document.getElementById('dep-assignment-modal').hidden = false;
7343
7359
  }
7344
7360
 
7345
- async function saveWebhookDeployment() {
7346
- const label = document.getElementById('dep-wh-label').value.trim();
7347
- const jobId = document.getElementById('dep-wh-job').value;
7348
- const hostId = document.getElementById('dep-wh-host').value;
7349
- const instructions = document.getElementById('dep-wh-instructions').value.trim();
7350
- const errEl = document.getElementById('dep-wh-error');
7351
- if (!label) { errEl.textContent = 'Label is required.'; errEl.hidden = false; return; }
7361
+ // #693 R2: one save path routes to the schedule or webhook endpoint by type.
7362
+ async function saveDeployment() {
7363
+ const errEl = document.getElementById('dep-error');
7364
+ const label = document.getElementById('dep-label').value.trim();
7365
+ const jobId = document.getElementById('dep-job').value;
7366
+ const hostId = document.getElementById('dep-agent').value;
7367
+ const instructions = document.getElementById('dep-instructions').value.trim();
7352
7368
  const isEdit = _editingDepId !== null;
7353
- const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
7354
- const method = isEdit ? 'PUT' : 'POST';
7355
- try {
7356
- const resp = await fetch(url, {
7357
- method,
7358
- headers: { 'Content-Type': 'application/json' },
7359
- body: JSON.stringify({ label, jobId, hostId, instructions: instructions || undefined }),
7360
- });
7361
- 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; }
7362
- if (!isEdit) {
7363
- const dep = await resp.json();
7364
- document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
7365
- document.getElementById('dep-wh-inbound-row').hidden = false;
7366
- }
7367
- _editingDepId = null;
7368
- if (isEdit) document.getElementById('dep-webhook-modal').hidden = true;
7369
- await loadDeployments();
7370
- } catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
7369
+ if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
7370
+
7371
+ if (_depType === 'scheduled') {
7372
+ const isCustom = _activeSchPreset === 'custom';
7373
+ const cronExpr = isCustom
7374
+ ? document.getElementById('dep-sch-cron').value.trim()
7375
+ : (document.getElementById('dep-sch-cron-preset').value.trim() || '');
7376
+ if (!cronExpr) { errEl.textContent = 'A schedule is required.'; errEl.hidden = false; return; }
7377
+ const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
7378
+ try {
7379
+ // merge: keep master's projectPath association on the request body.
7380
+ const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }) });
7381
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
7382
+ _editingDepId = null;
7383
+ document.getElementById('dep-assignment-modal').hidden = true;
7384
+ await loadDeployments();
7385
+ } catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
7386
+ } else {
7387
+ const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
7388
+ try {
7389
+ const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }) });
7390
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
7391
+ if (!isEdit) {
7392
+ const dep = await resp.json();
7393
+ document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
7394
+ document.getElementById('dep-wh-inbound-row').hidden = false;
7395
+ } else {
7396
+ document.getElementById('dep-assignment-modal').hidden = true;
7397
+ }
7398
+ _editingDepId = null;
7399
+ await loadDeployments();
7400
+ } catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
7401
+ }
7371
7402
  }
7372
7403
 
7373
7404
  // ---------------------------------------------------------------------------
@@ -7891,123 +7922,12 @@ function tfContextRow(key, label, placeholder, rerender) {
7891
7922
 
7892
7923
  return row;
7893
7924
  }
7894
- // #521 R6/R6.5: a Company/Manager rail job is an accordion that nests its past
7895
- // runs — the SAME paradigm as the project tree (employee → job runs). The summary
7896
- // is the job (icon, name, run count, + to run again); the body lists prior runs,
7897
- // each clicking through to that run's conversation.
7898
- function tfAreaRailJob(ico, jobId, name, sub, onRun) {
7899
- const details = document.createElement('details');
7900
- details.className = 'emp-hero area-rail-acc';
7901
- details.open = true;
7902
- const summary = document.createElement('summary');
7903
- const av = document.createElement('div');
7904
- av.className = 'eh-av job-ico';
7905
- av.style.borderRadius = '9px';
7906
- av.style.background = 'var(--accent-soft)';
7907
- av.style.color = 'var(--accent)';
7908
- av.textContent = ico;
7909
- const id = document.createElement('div'); id.className = 'eh-id';
7910
- const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = name;
7911
- const runs = tfConversationsForJob(jobId);
7912
- const role = document.createElement('div'); role.className = 'eh-role';
7913
- role.textContent = runs.length ? (runs.length + (runs.length === 1 ? ' past run' : ' past runs')) : sub;
7914
- id.appendChild(nm); id.appendChild(role);
7915
- const add = document.createElement('button');
7916
- add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run ' + name + ' again';
7917
- add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onRun(); });
7918
- const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
7919
- summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
7920
- details.appendChild(summary);
7921
- const body = document.createElement('div'); body.className = 'eh-jobs';
7922
- if (!runs.length) {
7923
- const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet — + to run it.';
7924
- body.appendChild(none);
7925
- } else {
7926
- for (const c of runs) {
7927
- const row = document.createElement('div'); row.className = 'tree-job';
7928
- const label = document.createElement('span'); label.className = 'tj-label'; label.textContent = c.title || c.jobTitle || name;
7929
- const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
7930
- const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
7931
- row.appendChild(label); row.appendChild(dot);
7932
- tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete an area-rail job run
7933
- row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
7934
- body.appendChild(row);
7935
- }
7936
- }
7937
- details.appendChild(body);
7938
- return details;
7939
- }
7925
+ // #693 R1 (PR round 2): tfAreaRailJob removed with the Company/Manager job rails.
7940
7926
 
7941
- // #521: "Project Updates" — one accordion grouping the project-lifecycle jobs
7942
- // (project-onboarding + sleep-on-learnings) with their run history. The header
7943
- // "+" opens a small menu to run either; the body lists each job and its past
7944
- // runs (click a run to open it). Sits between Project Info and "+ New job".
7945
- function tfProjectUpdatesSection() {
7946
- const JOBS = [
7947
- { id: 'project-onboarding', ico: '🚀', name: 'Project Onboarding', run: () => tfStartProjectOnboarding() },
7948
- { id: 'sleep-on-learnings', ico: '🧠', name: 'Sleep on learnings', run: () => tfRunShareLearnings('project') },
7949
- ];
7950
- const details = document.createElement('details');
7951
- // NB: deliberately NOT `.emp-hero` — that class is the employee-hero selector
7952
- // the tree's per-employee assign-job affordance keys off; Project Updates reuses
7953
- // the same visual chrome via `.proj-updates-acc` grouped CSS instead.
7954
- details.className = 'proj-updates-acc';
7955
- details.id = 'proj-updates-acc';
7956
- details.dataset.accKey = 'proj-updates'; // #533 R5: preserve open-state across status re-renders
7957
- details.open = true;
7958
-
7959
- const summary = document.createElement('summary');
7960
- const av = document.createElement('div');
7961
- av.className = 'eh-av job-ico';
7962
- av.style.borderRadius = '9px';
7963
- av.style.background = 'var(--accent-soft)';
7964
- av.style.color = 'var(--accent)';
7965
- av.textContent = '🔄';
7966
- const id = document.createElement('div'); id.className = 'eh-id';
7967
- const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = 'Project Updates';
7968
- const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
7969
- const role = document.createElement('div'); role.className = 'eh-role';
7970
- role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
7971
- id.appendChild(nm); id.appendChild(role);
7972
-
7973
- const add = document.createElement('button');
7974
- add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run a project update';
7975
- add.addEventListener('click', (e) => {
7976
- e.preventDefault(); e.stopPropagation();
7977
- openPalette();
7978
- });
7979
-
7980
- const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
7981
- summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
7982
- details.appendChild(summary);
7983
-
7984
- const body = document.createElement('div'); body.className = 'eh-jobs';
7985
- JOBS.forEach((j) => {
7986
- const group = document.createElement('div'); group.className = 'pu-job';
7987
- const head = document.createElement('div'); head.className = 'pu-job-name';
7988
- head.textContent = j.ico + ' ' + j.name;
7989
- group.appendChild(head);
7990
- const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
7991
- if (!runs.length) {
7992
- const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
7993
- group.appendChild(none);
7994
- } else {
7995
- runs.forEach((c) => {
7996
- const row = document.createElement('div'); row.className = 'tree-job';
7997
- const rl = document.createElement('span'); rl.className = 'tj-label'; rl.textContent = c.title || c.jobTitle || j.name;
7998
- const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
7999
- const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
8000
- row.appendChild(rl); row.appendChild(dot);
8001
- tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete a project-update run
8002
- row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
8003
- group.appendChild(row);
8004
- });
8005
- }
8006
- body.appendChild(group);
8007
- });
8008
- details.appendChild(body);
8009
- return details;
8010
- }
7927
+ // #693 R1: tfProjectUpdatesSection was removed. The "Project Updates" tree
7928
+ // accordion is retired; its Run Project Onboarding and Sleep on learnings actions
7929
+ // now live inside the Brief and Learnings sections (see tfEnsureAccAction, called
7930
+ // from tfRenderProjectContextTop).
8011
7931
 
8012
7932
  // #594 R2: render a conversation panel (topline + messages + coach input) into an
8013
7933
  // area-level container so org/manager jobs are viewable inside Company/Manager tabs.
@@ -8142,13 +8062,9 @@ function tfRenderCompany() {
8142
8062
  infoBtn.textContent = '📋 Company Info';
8143
8063
  infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
8144
8064
  rail.appendChild(infoBtn);
8145
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Company jobs';
8146
- rail.appendChild(head);
8147
- rail.appendChild(tfAreaRailJob('🏢', 'organization-onboarding', 'Organization onboarding', 'Set up / update org context & rules', () => tfStartOrgOnboarding()));
8148
- rail.appendChild(tfAreaRailJob('🧠', 'organizational-learning-synthesis', 'organizational-learning-synthesis', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis()));
8149
- const note = document.createElement('div'); note.className = 'area-rail-note';
8150
- note.textContent = "Company-wide work lives here — it applies across every project, so it's not in any project's job list. (sleep-on-learnings runs at the project level.)";
8151
- rail.appendChild(note);
8065
+ // #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
8066
+ // now run from the section they populate — Run Organization Onboarding in
8067
+ // "Context & rules", Synthesize company learnings in "Company learnings".
8152
8068
  }
8153
8069
  const profile = document.getElementById('company-profile');
8154
8070
  const learn = document.getElementById('company-learnings');
@@ -8204,6 +8120,11 @@ function tfRenderCompany() {
8204
8120
  if (learn) {
8205
8121
  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.');
8206
8122
  }
8123
+ // #693 R1: lifecycle actions live in the sections they populate (mirrors the
8124
+ // Projects tab): onboarding in Context & rules, learnings synthesis in Company learnings.
8125
+ tfEnsureAccAction('company-ctx-acc', '🏢', 'Run Organization Onboarding', () => tfStartOrgOnboarding());
8126
+ // #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
8127
+ tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
8207
8128
  tfMaybeRenderOrgPushBanner();
8208
8129
  // Show the full coaching conversation panel when there is an active org conv;
8209
8130
  // otherwise show the info view (accordions). This uses the shared .page element
@@ -8293,12 +8214,8 @@ function tfRenderManager() {
8293
8214
  infoBtn.textContent = '📋 Manager Info';
8294
8215
  infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
8295
8216
  rail.appendChild(infoBtn);
8296
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Manager jobs';
8297
- rail.appendChild(head);
8298
- rail.appendChild(tfAreaRailJob('🤝', 'manager-agreements', 'manager-agreements', 'Set up / update how you work', () => tfStartManagerOnboarding()));
8299
- const note = document.createElement('div'); note.className = 'area-rail-note';
8300
- note.textContent = "manager-agreements is about you, not a single project — so it isn't in any project's job list. You coach in project conversations; what the team learns about you surfaces here.";
8301
- rail.appendChild(note);
8217
+ // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
8218
+ // Manager agreements now runs from the "Context & rules" section it populates.
8302
8219
  }
8303
8220
  const profile = document.getElementById('manager-profile');
8304
8221
  const learn = document.getElementById('manager-learnings');
@@ -8361,6 +8278,12 @@ function tfRenderManager() {
8361
8278
  // the manager-coaching learnings the team has captured for you.
8362
8279
  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.');
8363
8280
  }
8281
+ // #693 R1: lifecycle actions live in the sections they populate (mirrors the
8282
+ // Projects tab): manager agreements in Context & rules, learnings in Manager learnings.
8283
+ tfEnsureAccAction('manager-ctx-acc', '🤝', 'Set up manager agreements', () => tfStartManagerOnboarding());
8284
+ // #693 R1 (PR round 2): Manager has no learnings-synthesis job — its learnings
8285
+ // surface here from project sleep-on-learnings promoted to manager scope — so no
8286
+ // run-action button in the Manager learnings section.
8364
8287
  // Issue #540 R4-R7: render manager team pool on every manager-tab activation.
8365
8288
  renderManagerTeamPool();
8366
8289
  // Show the full coaching conversation panel when there is an active manager conv;
@@ -8869,6 +8792,47 @@ function tfCloseAccountMenu() {
8869
8792
  if (menu) menu.classList.remove('open');
8870
8793
  }
8871
8794
 
8795
+ // ---------------------------------------------------------------------------
8796
+ // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
8797
+ // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
8798
+ // OS preference; here we reflect it in the toggle control and persist changes.
8799
+ // Same-origin key sharing keeps /account and /analytics in sync on their next load.
8800
+ // ---------------------------------------------------------------------------
8801
+ function tfCurrentTheme() {
8802
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
8803
+ }
8804
+ function tfSyncThemeToggle(theme) {
8805
+ const btn = document.getElementById('am-theme-toggle');
8806
+ if (!btn) return;
8807
+ const on = theme === 'dark';
8808
+ btn.setAttribute('aria-checked', on ? 'true' : 'false');
8809
+ const sub = document.getElementById('am-theme-sub');
8810
+ if (sub) sub.textContent = on ? 'On' : 'Off';
8811
+ const ico = document.getElementById('am-theme-ico');
8812
+ if (ico) ico.textContent = on ? '🌙' : '☀️';
8813
+ }
8814
+ function tfApplyTheme(theme, persist) {
8815
+ const t = theme === 'dark' ? 'dark' : 'light';
8816
+ document.documentElement.setAttribute('data-theme', t);
8817
+ if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
8818
+ tfSyncThemeToggle(t);
8819
+ // Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
8820
+ try {
8821
+ document.querySelectorAll('iframe').forEach((f) => {
8822
+ if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
8823
+ });
8824
+ } catch (e) {}
8825
+ }
8826
+ function tfToggleTheme(e) {
8827
+ if (e) e.stopPropagation();
8828
+ tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
8829
+ }
8830
+ function tfWireThemeToggle() {
8831
+ const btn = document.getElementById('am-theme-toggle');
8832
+ if (btn) btn.addEventListener('click', tfToggleTheme);
8833
+ tfSyncThemeToggle(tfCurrentTheme());
8834
+ }
8835
+
8872
8836
  // Per-job config for the area onboarding pre-flight modal.
8873
8837
  const AREA_ONBOARD_CONFIG = {
8874
8838
  'organization-onboarding': {
@@ -9200,7 +9164,7 @@ async function tfCreateProject() {
9200
9164
  intent && `Project intent: ${intent}`,
9201
9165
  val('np-outcome') && `Success looks like: ${val('np-outcome')}`,
9202
9166
  val('np-rules') && `Rules & guardrails: ${val('np-rules')}`,
9203
- `Team: ${(project.team || []).join(', ') || 'no employees assigned yet'}`,
9167
+ `Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
9204
9168
  ].filter(Boolean).join('\n\n');
9205
9169
 
9206
9170
  if (onboardingContext) {
@@ -9386,10 +9350,9 @@ function tfAssignJobToEmployee(employeeKey, job) {
9386
9350
  if (!projectId) { tfCloseAssignJob(); return; }
9387
9351
  if (!tf.assignments[projectId]) tf.assignments[projectId] = [];
9388
9352
  tf.assignments[projectId].push({ id: 'a-' + Date.now(), employeeKey, jobName: job.title || job.id, jobId: job.id });
9389
- const proj = tf.projects.find((p) => p.id === projectId);
9390
- if (proj) { proj.team = proj.team || []; if (!proj.team.includes(employeeKey)) proj.team.push(employeeKey); }
9353
+ // No longer mutate a stored project.team: the roster is derived from hired
9354
+ // personas (tfProjectTeamKeys) and every employee is always available.
9391
9355
  tfPersistAssignments();
9392
- tfPersistProjects();
9393
9356
  tfCloseAssignJob();
9394
9357
  tfRenderTree();
9395
9358
  tfRenderProjectTabs();
@@ -9408,7 +9371,7 @@ function tfOpenAddEmp(preselectKey) {
9408
9371
  const sub = document.getElementById('ae-sub');
9409
9372
  if (!modal || !body) return;
9410
9373
  body.innerHTML = '';
9411
- const onProject = (tf.projects.find((p) => p.id === tf.activeProjectId) || {}).team || [];
9374
+ const onProject = tfProjectTeamKeys();
9412
9375
  const onProjectPersonas = onProject.map(tfPersonaByKey).filter(Boolean);
9413
9376
  if (onProjectPersonas.length) {
9414
9377
  const label = document.createElement('div');
@@ -9578,6 +9541,7 @@ function tfWireShell() {
9578
9541
  }
9579
9542
  const avatar = document.getElementById('avatar-btn');
9580
9543
  if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
9544
+ tfWireThemeToggle();
9581
9545
  const brainItem = document.getElementById('am-brain');
9582
9546
  if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
9583
9547
  const signout = document.getElementById('am-signout');