fraim-framework 2.0.195 → 2.0.197

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.
@@ -20,6 +20,12 @@ const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements
20
20
  // listed here — a persona can run a job from a project OR from the Manager tab, and a
21
21
  // run's placement is decided per-invocation by conv.invokedArea, not by job id.
22
22
  const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
23
+ // Issue #702: personas whose home is the MANAGER tab because their work is inherently
24
+ // cross-project and manager-facing (not project work). Per the spec (R1b), Ashley — the
25
+ // AI Executive Assistant — is Manager-scoped. Every other hired persona works inside
26
+ // projects and belongs on the Projects tab only, NOT the Manager rail. Add a key here
27
+ // to surface a new manager-scoped persona.
28
+ const MANAGER_PERSONA_KEYS = new Set(['ashley']);
23
29
  // Issue #708: project-independent conversation scope buckets (match server sentinels).
24
30
  const MANAGER_CONV_KEY = '@manager';
25
31
  const COMPANY_CONV_KEY = '@company';
@@ -100,7 +106,7 @@ function gatherElements() {
100
106
  'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
101
107
  'job-persona-filter',
102
108
  'picked-name', 'picked-desc', 'instructions',
103
- 'employee-select', 'agent-install-panel', 'active-employee-select',
109
+ 'employee-select', 'agent-install-panel', 'hub-agent-setup-panel', 'cp-agent-install-panel', 'active-employee-select',
104
110
  // Issue #347 additions: tracker, template picker, totals.
105
111
  'tracker', 'tracker-rows', 'tracker-note',
106
112
  'template-picker-btn', 'template-popover',
@@ -192,7 +198,15 @@ function applyBootstrap(bootstrap, docUrl) {
192
198
  if (bootstrap.preferences && bootstrap.preferences.apiKey) {
193
199
  state.storedApiKey = bootstrap.preferences.apiKey;
194
200
  }
195
- state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
201
+ const employees = bootstrap.employees || [];
202
+ const selectedAvailable = employees.some((employee) => employee.id === state.selectedEmployeeId && employee.available);
203
+ const preferredAvailable = employees.some((employee) => employee.id === bootstrap.preferences.employeeId && employee.available);
204
+ if (!state.selectedEmployeeId || !selectedAvailable) {
205
+ const firstAvailable = employees.find((employee) => employee.available);
206
+ state.selectedEmployeeId = preferredAvailable
207
+ ? bootstrap.preferences.employeeId
208
+ : (firstAvailable ? firstAvailable.id : state.selectedEmployeeId);
209
+ }
196
210
  // Restore persona selection persisted on the server side.
197
211
  if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
198
212
  state.selectedPersonaKey = bootstrap.preferences.personaKey;
@@ -224,6 +238,9 @@ function applyBootstrap(bootstrap, docUrl) {
224
238
  // Issue #540: render the persona grid and manager team pool.
225
239
  renderPersonaGrid();
226
240
  renderManagerTeamPool();
241
+ // Issue #744: apply the org cobrand (nav lockup, accent, title, favicon) or
242
+ // fall back to FRAIM identity when none is set.
243
+ tfApplyOrgBrand(bootstrap.orgBrand || null);
227
244
  cacheBootstrapPayload(bootstrap, docUrl);
228
245
  }
229
246
 
@@ -1036,13 +1053,21 @@ function renderRail() {
1036
1053
  els['conv-list'].querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
1037
1054
  const key = details.dataset ? details.dataset.employeeKey : '';
1038
1055
  const projectKey = details.dataset ? (details.dataset.projectKey || activeRailProjectKey) : activeRailProjectKey;
1039
- if (key) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
1056
+ // Only preserve the toggle state of groups that actually have runs. A hired persona
1057
+ // renders as a zero-run group BEFORE conversations hydrate; capturing that transient
1058
+ // collapsed state would wrongly keep the group collapsed after it gains runs. No-run
1059
+ // groups always follow the default (collapsed); with-run groups default open (#726 UX).
1060
+ const hasRuns = !!details.querySelector('.conv-item');
1061
+ if (key && hasRuns) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
1040
1062
  });
1041
1063
  const employeeGroupStateKey = (key) => activeRailProjectKey + ':' + key;
1042
- const preservedEmployeeGroupOpen = (key) => (
1064
+ // Preserve the user's open/closed toggle across re-renders; otherwise fall back to
1065
+ // defaultOpen. An employee with no runs defaults to COLLAPSED so the rail stays scannable
1066
+ // — expand it to see "No jobs assigned yet. Click + to assign a job."
1067
+ const preservedEmployeeGroupOpen = (key, defaultOpen) => (
1043
1068
  employeeGroupOpenState.has(employeeGroupStateKey(key))
1044
1069
  ? employeeGroupOpenState.get(employeeGroupStateKey(key))
1045
- : true
1070
+ : defaultOpen
1046
1071
  );
1047
1072
  els['conv-list'].innerHTML = '';
1048
1073
  // R4: filter by selected persona when one is active.
@@ -1202,7 +1227,7 @@ function renderRail() {
1202
1227
  details.className = 'conv-employee-group';
1203
1228
  details.dataset.employeeKey = group.key;
1204
1229
  details.dataset.projectKey = activeRailProjectKey;
1205
- details.open = preservedEmployeeGroupOpen(group.key);
1230
+ details.open = preservedEmployeeGroupOpen(group.key, group.conversations.length > 0);
1206
1231
  const summary = document.createElement('summary');
1207
1232
  summary.className = 'conv-employee-tab';
1208
1233
  const avatar = buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
@@ -1257,7 +1282,7 @@ function renderRail() {
1257
1282
  emptyWrap.className = 'conv-employee-list';
1258
1283
  const em = document.createElement('div');
1259
1284
  em.className = 'eh-empty';
1260
- em.textContent = 'No runs yet + to assign a job.';
1285
+ em.textContent = 'No jobs assigned yet. Click + to assign a job.';
1261
1286
  emptyWrap.appendChild(em);
1262
1287
  details.appendChild(emptyWrap);
1263
1288
  }
@@ -1274,7 +1299,7 @@ function renderRail() {
1274
1299
  wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
1275
1300
  wcDetails.dataset.employeeKey = '__watercooler__';
1276
1301
  wcDetails.dataset.projectKey = activeRailProjectKey;
1277
- wcDetails.open = preservedEmployeeGroupOpen('__watercooler__');
1302
+ wcDetails.open = preservedEmployeeGroupOpen('__watercooler__', true);
1278
1303
  const wcSummary = document.createElement('summary');
1279
1304
  wcSummary.className = 'conv-employee-tab';
1280
1305
  // #693.8: watercooler icon (replaces the blank dashed placeholder).
@@ -1773,6 +1798,7 @@ function renderActive() {
1773
1798
  if (!conv) {
1774
1799
  els['empty'].hidden = false;
1775
1800
  els['active-conv'].hidden = true;
1801
+ renderHubAgentSetupPanel();
1776
1802
  const _c = document.getElementById('conversation');
1777
1803
  if (_c) _c.classList.remove('ab-mode');
1778
1804
  if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
@@ -1791,6 +1817,7 @@ function renderActive() {
1791
1817
  }
1792
1818
  els['empty'].hidden = true;
1793
1819
  els['active-conv'].hidden = false;
1820
+ if (els['hub-agent-setup-panel']) els['hub-agent-setup-panel'].hidden = true;
1794
1821
  els['active-conv'].dataset.runId = conv.runId || '';
1795
1822
  els['active-title'].textContent = conversationTitle(conv);
1796
1823
  els['active-conv'].classList.toggle('has-delegation-board', !!normalizeDelegationLedger(conv.delegation));
@@ -3277,6 +3304,10 @@ function convAwaitingReview(conv) {
3277
3304
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
3278
3305
  // stakeholder-status-reporting) may legitimately surface artifacts for human review.
3279
3306
  if (delegationLedgerForConversation(conv)) return false;
3307
+ const runArtifacts = Array.isArray(conv.artifacts) ? conv.artifacts : [];
3308
+ if (conv.status === 'completed' && runArtifacts.some((artifact) => artifactLocalPath(artifact) || artifactExternalUrl(artifact))) {
3309
+ return true;
3310
+ }
3280
3311
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
3281
3312
  // only belongs when the employee actually submits for review (a review_handoff,
3282
3313
  // emitted by the submit phase). A turn that just finished (a question, a pause
@@ -3483,12 +3514,32 @@ function hasReviewHandoffSignal(text) {
3483
3514
  return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
3484
3515
  }
3485
3516
 
3517
+ function reviewSubmissionClaimText(text) {
3518
+ return String(stripMarkdownForDisplay(text) || '')
3519
+ .replace(/\s+/g, ' ')
3520
+ .trim();
3521
+ }
3522
+
3523
+ function hasExplicitReviewSubmissionClaim(text) {
3524
+ const cleaned = reviewSubmissionClaimText(text);
3525
+ if (!cleaned) return false;
3526
+ if (/\b(?:not\s+ready|not\s+yet\s+ready|will\s+be\s+ready|soon\s+be\s+ready)\b.{0,60}\breview\b/i.test(cleaned)) {
3527
+ return false;
3528
+ }
3529
+ return /\bready\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
3530
+ || /\bplease\s+review\b/i.test(cleaned)
3531
+ || /\bsubmitted\s+(?:the\s+|this\s+|my\s+)?(?:work|deliverable|artifact|bundle|pr|pull request|document|doc|file|files)\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
3532
+ || /\b(?:review|open)\s+(?:the\s+)?(?:pr|pull request|artifact|document|doc|file|files|deliverable|bundle)\b/i.test(cleaned);
3533
+ }
3534
+
3486
3535
  function reviewHandoffIssueForConversation(conv) {
3487
3536
  if (!conv) return '';
3488
3537
  if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
3489
3538
  return 'The review handoff contract is invalid.';
3490
3539
  }
3491
3540
  const messages = conv.messages || [];
3541
+ const hasRunArtifacts = Array.isArray(conv.artifacts) && conv.artifacts.length > 0;
3542
+ const latestEmployee = [...messages].reverse().find((message) => message.role === 'employee');
3492
3543
  for (let i = messages.length - 1; i >= 0; i -= 1) {
3493
3544
  if (messages[i].role !== 'employee') continue;
3494
3545
  if (!hasReviewHandoffSignal(messages[i].text)) continue;
@@ -3496,6 +3547,9 @@ function reviewHandoffIssueForConversation(conv) {
3496
3547
  return 'The review handoff contract is malformed or missing required fields.';
3497
3548
  }
3498
3549
  }
3550
+ if (!conv.reviewHandoff && !hasRunArtifacts && latestEmployee && hasExplicitReviewSubmissionClaim(latestEmployee.text)) {
3551
+ return 'This run says it is ready for review, but did not include a structured review handoff.';
3552
+ }
3499
3553
  return '';
3500
3554
  }
3501
3555
 
@@ -3851,7 +3905,7 @@ function renderReviewExperience(conv) {
3851
3905
  if (handoff) {
3852
3906
  const hasArtifact = !!(fmt.actions && fmt.actions.length);
3853
3907
  handoff.textContent = fmt.key === 'contract_error'
3854
- ? 'The review handoff contract is invalid. Ask the employee to resend it before review.'
3908
+ ? `${fmt.issue || 'The review handoff contract is invalid.'} Ask the employee to resend it before review.`
3855
3909
  : fmt.key === 'pull_request'
3856
3910
  ? 'Comment on the PR to leave inline notes, or approve / type changes below.'
3857
3911
  : isManagerTemplateJob(conv && conv.jobId)
@@ -4448,7 +4502,7 @@ function renderCpAgentPicker() {
4448
4502
  const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
4449
4503
  if (!curOk) {
4450
4504
  const firstAvail = list.find((e) => e.available !== false);
4451
- if (firstAvail) state.cpEmployee = firstAvail.id;
4505
+ state.cpEmployee = firstAvail ? firstAvail.id : null;
4452
4506
  }
4453
4507
  for (const e of list) {
4454
4508
  const pill = document.createElement('button');
@@ -4463,6 +4517,10 @@ function renderCpAgentPicker() {
4463
4517
  }
4464
4518
  picker.appendChild(pill);
4465
4519
  }
4520
+ renderCpAgentInstallPanel();
4521
+ const startBtn = document.getElementById('cp-start-btn');
4522
+ const instr = document.getElementById('cp-instructions');
4523
+ if (startBtn && instr) startBtn.disabled = !instr.value.trim() || !hubHasAvailableEmployee();
4466
4524
  }
4467
4525
 
4468
4526
  function buildCpRow(row, flatIndex) {
@@ -4556,7 +4614,7 @@ function selectCpRow(index) {
4556
4614
  }
4557
4615
 
4558
4616
  const startBtn = document.getElementById('cp-start-btn');
4559
- const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim(); };
4617
+ const syncStart = () => { if (startBtn) startBtn.disabled = !instr.value.trim() || !hubHasAvailableEmployee(); };
4560
4618
  instr.oninput = syncStart;
4561
4619
  syncStart();
4562
4620
  instr.focus();
@@ -4568,6 +4626,11 @@ function submitCpRun() {
4568
4626
  const instr = document.getElementById('cp-instructions');
4569
4627
  const instructions = instr ? instr.value.trim() : '';
4570
4628
  if (!instructions) return;
4629
+ if (!hubHasAvailableEmployee()) {
4630
+ renderCpAgentInstallPanel();
4631
+ showStatus('Set up a Hub CLI agent before starting work in FRAIM Hub.', true);
4632
+ return;
4633
+ }
4571
4634
  const employeeId = state.cpEmployee || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude';
4572
4635
  closePalette();
4573
4636
  // Issue #540: if the job requires a persona not on the manager's team, show
@@ -4788,6 +4851,8 @@ function renderEmployeeSelect() {
4788
4851
  els['employee-select'].appendChild(opt);
4789
4852
  }
4790
4853
  renderAgentInstallPanel();
4854
+ renderHubAgentSetupPanel();
4855
+ renderCpAgentInstallPanel();
4791
4856
  updateAbToggleVisibility();
4792
4857
  }
4793
4858
 
@@ -4800,6 +4865,18 @@ function updateAbToggleVisibility() {
4800
4865
  wrap.hidden = !(emp && emp.supportsRaw);
4801
4866
  }
4802
4867
 
4868
+ function hubEmployees() {
4869
+ return state.bootstrap?.employees || [];
4870
+ }
4871
+
4872
+ function hubHasAvailableEmployee() {
4873
+ return hubEmployees().some((employee) => employee.available);
4874
+ }
4875
+
4876
+ function hubEmployeeIsAvailable(employeeId) {
4877
+ return hubEmployees().some((employee) => employee.id === employeeId && employee.available);
4878
+ }
4879
+
4803
4880
  // ---------------------------------------------------------------------------
4804
4881
  // Agent install panel — shown in Step 2 for unavailable agents
4805
4882
  // ---------------------------------------------------------------------------
@@ -4808,19 +4885,65 @@ function updateAbToggleVisibility() {
4808
4885
  const agentInstallState = {};
4809
4886
 
4810
4887
  function renderAgentInstallPanel() {
4811
- const panel = els['agent-install-panel'];
4888
+ renderAgentInstallPanelInto(els['agent-install-panel'], {
4889
+ heading: 'Install missing agents',
4890
+ testPrefix: 'hub-agent',
4891
+ });
4892
+ }
4893
+
4894
+ function renderHubAgentSetupPanel() {
4895
+ const panel = els['hub-agent-setup-panel'];
4896
+ if (!panel) return;
4897
+ if (hubHasAvailableEmployee()) {
4898
+ panel.hidden = true;
4899
+ panel.innerHTML = '';
4900
+ return;
4901
+ }
4902
+ renderAgentInstallPanelInto(panel, {
4903
+ heading: 'Set up a Hub CLI agent',
4904
+ intro: 'FRAIM Hub runs jobs through CLI agents. Download or install one, sign in, then check readiness before starting Hub work with that agent.',
4905
+ testPrefix: 'hub-agent',
4906
+ });
4907
+ }
4908
+
4909
+ function renderCpAgentInstallPanel() {
4910
+ const panel = els['cp-agent-install-panel'];
4911
+ if (!panel) return;
4912
+ if (hubHasAvailableEmployee()) {
4913
+ panel.hidden = true;
4914
+ panel.innerHTML = '';
4915
+ return;
4916
+ }
4917
+ renderAgentInstallPanelInto(panel, {
4918
+ heading: 'Set up a CLI agent before starting',
4919
+ intro: 'Install, sign in, and verify one Hub agent to enable Start.',
4920
+ testPrefix: 'cp-agent',
4921
+ });
4922
+ }
4923
+
4924
+ function renderAgentInstallPanelInto(panel, options) {
4812
4925
  if (!panel) return;
4813
4926
  panel.innerHTML = '';
4814
4927
 
4815
- const employees = state.bootstrap?.employees || [];
4816
- const unavailable = employees.filter((e) => !e.available);
4817
- if (unavailable.length === 0) return;
4928
+ const unavailable = hubEmployees().filter((e) => !e.available);
4929
+ if (unavailable.length === 0) {
4930
+ panel.hidden = true;
4931
+ return;
4932
+ }
4933
+ panel.hidden = false;
4818
4934
 
4819
4935
  const heading = document.createElement('div');
4820
4936
  heading.className = 'install-panel-heading';
4821
- heading.textContent = 'Install missing agents';
4937
+ heading.textContent = options.heading || 'Install missing agents';
4822
4938
  panel.appendChild(heading);
4823
4939
 
4940
+ if (options.intro) {
4941
+ const intro = document.createElement('p');
4942
+ intro.className = 'install-panel-copy';
4943
+ intro.textContent = options.intro;
4944
+ panel.appendChild(intro);
4945
+ }
4946
+
4824
4947
  for (const emp of unavailable) {
4825
4948
  const row = document.createElement('div');
4826
4949
  row.className = 'install-row';
@@ -4844,10 +4967,11 @@ function renderAgentInstallPanel() {
4844
4967
 
4845
4968
  const st = agentInstallState[emp.id] || {};
4846
4969
  if (!st.phase) {
4847
- btn.textContent = `Install ${emp.label}`;
4970
+ btn.textContent = `Download / Install ${emp.label}`;
4971
+ btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
4848
4972
  btn.addEventListener('click', () => startAgentInstall(emp.id));
4849
4973
  } else if (st.phase === 'installing') {
4850
- btn.textContent = 'Installing';
4974
+ btn.textContent = 'Installing...';
4851
4975
  btn.disabled = true;
4852
4976
  } else if (st.phase === 'needs-login') {
4853
4977
  btn.textContent = 'Sign In';
@@ -4856,22 +4980,26 @@ function renderAgentInstallPanel() {
4856
4980
  const checkBtn = document.createElement('button');
4857
4981
  checkBtn.className = 'secondary small';
4858
4982
  checkBtn.textContent = 'Check if Ready';
4983
+ checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
4859
4984
  checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
4860
4985
  row.appendChild(checkBtn);
4861
4986
 
4862
4987
  const skipBtn = document.createElement('button');
4863
4988
  skipBtn.className = 'ghost small';
4864
4989
  skipBtn.textContent = 'Choose another agent';
4990
+ skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
4865
4991
  skipBtn.style.marginLeft = '6px';
4866
4992
  skipBtn.addEventListener('click', () => {
4867
4993
  delete agentInstallState[emp.id];
4868
4994
  renderAgentInstallPanel();
4995
+ renderHubAgentSetupPanel();
4996
+ renderCpAgentInstallPanel();
4869
4997
  });
4870
4998
  row.appendChild(skipBtn);
4871
4999
  panel.appendChild(row);
4872
5000
  continue;
4873
5001
  } else if (st.phase === 'ready') {
4874
- btn.textContent = 'Ready';
5002
+ btn.textContent = 'Ready';
4875
5003
  btn.disabled = true;
4876
5004
  btn.style.color = 'var(--accent)';
4877
5005
  } else if (st.phase === 'error') {
@@ -4887,6 +5015,9 @@ function renderAgentInstallPanel() {
4887
5015
  function setInstallState(hubId, phase, statusText) {
4888
5016
  agentInstallState[hubId] = { phase, statusText: statusText || '' };
4889
5017
  renderAgentInstallPanel();
5018
+ renderHubAgentSetupPanel();
5019
+ renderCpAgentInstallPanel();
5020
+ renderCpAgentPicker();
4890
5021
  }
4891
5022
 
4892
5023
  async function startAgentInstall(hubId) {
@@ -4941,8 +5072,9 @@ async function checkAgentReady(hubId) {
4941
5072
  async function refreshEmployees() {
4942
5073
  try {
4943
5074
  const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
4944
- state.bootstrap = bootstrap;
5075
+ applyBootstrap(bootstrap);
4945
5076
  renderEmployeeSelect();
5077
+ renderCpAgentPicker();
4946
5078
  } catch { /* best-effort */ }
4947
5079
  }
4948
5080
 
@@ -4994,6 +5126,12 @@ function deriveTitle(jobTitle, instructions) {
4994
5126
  // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
4995
5127
  // share one start/continue contract.
4996
5128
  async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
5129
+ if (!hubEmployeeIsAvailable(employeeId)) {
5130
+ renderHubAgentSetupPanel();
5131
+ renderCpAgentInstallPanel();
5132
+ showStatus('Set up a Hub CLI agent before starting work in FRAIM Hub.', true);
5133
+ return;
5134
+ }
4997
5135
  const isFreeform = job.id === '__freeform__';
4998
5136
  // In task-pane/extension mode: get a fresh selection snapshot and prepend
4999
5137
  // document context to the instructions so the agent knows what the user is
@@ -6317,6 +6455,11 @@ const tf = {
6317
6455
  activeProjectId: null,
6318
6456
  projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
6319
6457
  assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
6458
+ // #726: canonical paths just removed this session. A delete switches workspaces first,
6459
+ // and that switch's (possibly cached) bootstrap merge would otherwise re-add the project
6460
+ // client-side before the next full reload. Mirroring the server tombstone here keeps a
6461
+ // removed project gone immediately; cleared when a project is (re-)created at that path.
6462
+ removedPaths: new Set(),
6320
6463
  npStep: 1,
6321
6464
  npSelectedEmployees: [],
6322
6465
  };
@@ -6401,6 +6544,8 @@ function tfMergeProjects(projects) {
6401
6544
  const normalized = tfNormalizeProject(project);
6402
6545
  if (!normalized) return;
6403
6546
  const key = tfCanonicalProjectPath(normalized.folderPath);
6547
+ // #726: never re-add a project removed this session (stale/cached bootstrap merge).
6548
+ if (tf.removedPaths && tf.removedPaths.has(key)) return;
6404
6549
  const existing = merged.get(key);
6405
6550
  if (existing) {
6406
6551
  const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
@@ -6421,8 +6566,13 @@ function tfProjectForPath(folderPath) {
6421
6566
 
6422
6567
  function tfEnsureCurrentProject() {
6423
6568
  if (!state.projectPath) return null;
6569
+ // Reuse the id the server already assigned this path (from bootstrap.projects) when
6570
+ // one exists. The client and server hash a path to an id differently, so minting a
6571
+ // fresh client id here would shadow the server's id on the CURRENT project and break
6572
+ // delete-by-id — you could never remove the project you're viewing (#726).
6573
+ const existingForPath = tfProjectForPath(state.projectPath);
6424
6574
  const current = tfNormalizeProject({
6425
- id: tfProjectIdForPath(state.projectPath),
6575
+ id: existingForPath ? existingForPath.id : tfProjectIdForPath(state.projectPath),
6426
6576
  name: friendlyProjectShortName(state.projectPath),
6427
6577
  folderPath: state.projectPath,
6428
6578
  }, state.projectPath);
@@ -6916,7 +7066,7 @@ async function tfRemoveProject(proj) {
6916
7066
  let payload;
6917
7067
  try {
6918
7068
  payload = await requestJson(
6919
- '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath),
7069
+ '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath) + '&folderPath=' + encodeURIComponent(proj.folderPath || ''),
6920
7070
  { method: 'DELETE' },
6921
7071
  );
6922
7072
  } catch (error) {
@@ -6936,6 +7086,9 @@ async function tfRemoveProject(proj) {
6936
7086
  }
6937
7087
  }
6938
7088
 
7089
+ // #726: remember this path as removed so an in-flight switch/bootstrap merge cannot
7090
+ // re-add it before the next full reload (the server tombstone handles reloads).
7091
+ tf.removedPaths.add(removedCanonical);
6939
7092
  // R3/R10: drop every client layer, adopting the server list as authoritative.
6940
7093
  tfAdoptServerProjects(payload && payload.projects);
6941
7094
  delete tf.assignments[proj.id];
@@ -8585,6 +8738,8 @@ function tfRenderCompany() {
8585
8738
  // #521 R4/R6: company-level jobs live ONLY in this left rail (organization-onboarding
8586
8739
  // + organizational-learning-synthesis) — not in any project's job list, not as
8587
8740
  // chips elsewhere.
8741
+ // #744: render the Brand editor (its host #company-brand-editor is static in the info view).
8742
+ if (typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
8588
8743
  const rail = document.getElementById('company-rail');
8589
8744
  if (rail) {
8590
8745
  rail.innerHTML = '';
@@ -8759,6 +8914,10 @@ function tfRenderManager() {
8759
8914
  const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
8760
8915
  let anyMgrEmployee = false;
8761
8916
  for (const persona of mgrPersonas) {
8917
+ // Only manager-scoped personas (Ashley) live on the Manager tab; project employees
8918
+ // are shown on Projects, not here (#702 R1b). Without this, an all-hired legacy
8919
+ // workspace surfaced every employee on the Manager rail.
8920
+ if (!MANAGER_PERSONA_KEYS.has(persona.key)) continue;
8762
8921
  if (!tfIsPersonaHired(persona.key)) continue;
8763
8922
  if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
8764
8923
  if (!anyMgrEmployee) {
@@ -9491,6 +9650,289 @@ async function tfRememberConnectedApiKey(apiKey) {
9491
9650
  }
9492
9651
  }
9493
9652
 
9653
+ // ---------------------------------------------------------------------------
9654
+ // Issue #744 — Hub cobranding. Renders the company brand (name/logo/color) from
9655
+ // bootstrap.orgBrand into the top nav, recolors the Hub via the single --accent
9656
+ // token (contrast-guarded per theme), sets the window title + favicon, and keeps
9657
+ // a "powered by FRAIM" co-mark (co-branding, not white-label). Falls back to the
9658
+ // shipped FRAIM identity when no brand is set. The authoritative sanitize/contrast
9659
+ // logic lives server-side in src/ai-hub/brand-store.ts; this is the render mirror.
9660
+ // ---------------------------------------------------------------------------
9661
+ var FRAIM_COMARK_SVG = '<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="11" fill="#3d8a6e"></rect><text x="24" y="33" text-anchor="middle" font-family="Helvetica,Arial,sans-serif" font-size="24" font-weight="700" fill="#fff">F</text></svg>';
9662
+ var BRAND_COLOR_PRESETS = ['#0d9488', '#0071e3', '#6d28d9', '#b0421f', '#1f7a34'];
9663
+
9664
+ function tfHexToRgb(hex) {
9665
+ var m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
9666
+ if (!m) return null;
9667
+ var n = m[1];
9668
+ return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
9669
+ }
9670
+ function tfNormalizeHex(input) {
9671
+ var raw = (typeof input === 'string' ? input : '').trim().replace(/^#/, '').toLowerCase();
9672
+ if (/^[0-9a-f]{3}$/.test(raw)) return '#' + raw.split('').map(function (c) { return c + c; }).join('');
9673
+ if (/^[0-9a-f]{6}$/.test(raw)) return '#' + raw;
9674
+ return null;
9675
+ }
9676
+ function tfLuminance(hex) {
9677
+ var rgb = tfHexToRgb(hex);
9678
+ if (!rgb) return 0;
9679
+ var a = rgb.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
9680
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
9681
+ }
9682
+ function tfContrast(a, b) {
9683
+ var la = tfLuminance(a), lb = tfLuminance(b);
9684
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
9685
+ }
9686
+ function tfMixHex(hex, target, amt) {
9687
+ var a = tfHexToRgb(hex), b = tfHexToRgb(target);
9688
+ if (!a || !b) return hex;
9689
+ return '#' + a.map(function (v, i) { return ('0' + Math.round(v + (b[i] - v) * amt).toString(16)).slice(-2); }).join('');
9690
+ }
9691
+ // Canonical Hub surface colors — mirror --surface in styles.css. Used only by the
9692
+ // editor's dual-theme contrast preview; tfApplyBrandAccent reads the LIVE --surface.
9693
+ var HUB_SURFACE_LIGHT = '#ffffff';
9694
+ var HUB_SURFACE_DARK = '#2a2a2a';
9695
+ // Contrast-safe accent for a given surface (R2.3): returns the derived accent trio,
9696
+ // or null when even a derived variant can't be read (caller uses the FRAIM default).
9697
+ function tfBrandAccentForTheme(color, surface) {
9698
+ var norm = tfNormalizeHex(color);
9699
+ if (!norm) return null;
9700
+ var dark = tfLuminance(surface) < 0.5;
9701
+ var toward = dark ? '#ffffff' : '#000000';
9702
+ var accent = norm;
9703
+ if (tfContrast(accent, surface) < 3.0) {
9704
+ accent = tfMixHex(norm, toward, 0.35);
9705
+ if (tfContrast(accent, surface) < 3.0) return null;
9706
+ }
9707
+ var rgb = tfHexToRgb(accent);
9708
+ return {
9709
+ accent: accent,
9710
+ strong: tfMixHex(accent, toward, 0.18),
9711
+ soft: 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + (dark ? '0.16' : '0.13') + ')'
9712
+ };
9713
+ }
9714
+ function tfApplyBrandAccent() {
9715
+ var root = document.documentElement;
9716
+ var brand = state.orgBrand;
9717
+ // Read the live --surface token so contrast tracks the actual theme (no
9718
+ // hardcoded surface duplication); fall back to the canonical values pre-paint.
9719
+ var surface = (getComputedStyle(root).getPropertyValue('--surface') || '').trim()
9720
+ || (root.getAttribute('data-theme') === 'dark' ? HUB_SURFACE_DARK : HUB_SURFACE_LIGHT);
9721
+ var trio = brand && brand.color ? tfBrandAccentForTheme(brand.color, surface) : null;
9722
+ if (!trio) {
9723
+ root.style.removeProperty('--accent');
9724
+ root.style.removeProperty('--accent-strong');
9725
+ root.style.removeProperty('--accent-soft');
9726
+ return;
9727
+ }
9728
+ root.style.setProperty('--accent', trio.accent);
9729
+ root.style.setProperty('--accent-strong', trio.strong);
9730
+ root.style.setProperty('--accent-soft', trio.soft);
9731
+ }
9732
+ // Resolve a logo value to a safe <img> src. SVG is rendered as a data URI via
9733
+ // <img> (never innerHTML) so the browser's non-scripting image context, not the
9734
+ // live DOM, parses it — this neutralizes SVG-borne script even if the server
9735
+ // allowlist sanitizer were ever bypassed (defense in depth, #744 security review).
9736
+ function tfBrandLogoSrc(logo) {
9737
+ if (typeof logo !== 'string' || !logo) return '';
9738
+ if (/^<svg/i.test(logo)) return 'data:image/svg+xml;utf8,' + encodeURIComponent(logo);
9739
+ if (/^data:image/i.test(logo)) return logo;
9740
+ return '';
9741
+ }
9742
+ function tfApplyBrandFavicon(brand) {
9743
+ var link = document.querySelector('link[rel="icon"]');
9744
+ if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
9745
+ var src = brand ? tfBrandLogoSrc(brand.logo) : '';
9746
+ if (src) link.href = src;
9747
+ // No logo → leave the shipped FRAIM favicon in place.
9748
+ }
9749
+ // Build the lockup DOM for a brand into a container (shared by nav + editor preview).
9750
+ function tfFillBrandLockup(container, brand) {
9751
+ container.innerHTML = '';
9752
+ var logoSrc = brand ? tfBrandLogoSrc(brand.logo) : '';
9753
+ if (logoSrc) {
9754
+ var logo = document.createElement('span');
9755
+ logo.className = 'hub-brand-logo';
9756
+ var img = document.createElement('img');
9757
+ img.src = logoSrc; img.alt = '';
9758
+ logo.appendChild(img);
9759
+ container.appendChild(logo);
9760
+ }
9761
+ if (brand && brand.name) {
9762
+ var nm = document.createElement('span');
9763
+ nm.className = 'hub-brand-name';
9764
+ nm.textContent = brand.name;
9765
+ nm.title = brand.name;
9766
+ container.appendChild(nm);
9767
+ }
9768
+ }
9769
+ function tfApplyOrgBrand(brand) {
9770
+ state.orgBrand = (brand && (brand.name || brand.color || brand.logo)) ? brand : null;
9771
+ try {
9772
+ if (state.orgBrand) localStorage.setItem('fraim-org-brand', JSON.stringify({ name: state.orgBrand.name || '', color: state.orgBrand.color || '' }));
9773
+ else localStorage.removeItem('fraim-org-brand');
9774
+ } catch (e) {}
9775
+
9776
+ var brandEl = document.getElementById('hub-brand');
9777
+ var divEl = document.getElementById('hub-brand-divider');
9778
+ var coEl = document.getElementById('hub-cobrand');
9779
+ var show = !!(state.orgBrand && (state.orgBrand.name || state.orgBrand.logo));
9780
+ if (brandEl) {
9781
+ if (show) tfFillBrandLockup(brandEl, state.orgBrand);
9782
+ brandEl.hidden = !show;
9783
+ }
9784
+ if (divEl) divEl.hidden = !show;
9785
+ if (coEl) {
9786
+ if (show) coEl.innerHTML = 'powered by <span class="hub-cobrand-mark">' + FRAIM_COMARK_SVG + '</span> FRAIM';
9787
+ coEl.hidden = !show;
9788
+ }
9789
+ document.title = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name + ' Hub' : 'FRAIM AI Hub';
9790
+ // R4.1: Company/Manager area eyebrows reflect the company name (the FRAIM
9791
+ // co-mark stays in the nav — co-branding, not white-label).
9792
+ var eyebrow = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name : 'FRAIM Hub';
9793
+ ['company-info-view', 'manager-info-view'].forEach(function (id) {
9794
+ var hostEl = document.getElementById(id);
9795
+ var el = hostEl && hostEl.querySelector('.eyebrow');
9796
+ if (el) el.textContent = eyebrow;
9797
+ });
9798
+ tfApplyBrandFavicon(state.orgBrand);
9799
+ tfApplyBrandAccent();
9800
+ }
9801
+
9802
+ // ── Brand editor (Company tab → Brand accordion) ────────────────────────────
9803
+ function tfRenderBrandEditor() {
9804
+ var host = document.getElementById('company-brand-editor');
9805
+ if (!host) return;
9806
+ var draft = {
9807
+ name: (state.orgBrand && state.orgBrand.name) || '',
9808
+ color: (state.orgBrand && state.orgBrand.color) || '',
9809
+ logo: (state.orgBrand && state.orgBrand.logo) || ''
9810
+ };
9811
+
9812
+ host.innerHTML = ''
9813
+ + '<div class="brand-editor">'
9814
+ + ' <div class="brand-field"><label for="be-name">Company name</label>'
9815
+ + ' <input class="search" id="be-name" type="text" placeholder="e.g. Wellness at Work" style="margin:0">'
9816
+ + ' <div class="brand-hint">Shown on the top nav and the window title, for every teammate.</div></div>'
9817
+ + ' <div class="brand-field"><label>Logo</label>'
9818
+ + ' <div class="brand-logo-drop" id="be-logo-drop" role="button" tabindex="0" aria-label="Upload company logo"><span class="blp" id="be-logo-preview"></span>'
9819
+ + ' <span class="blp-txt" id="be-logo-txt">Click to upload a PNG or SVG — square mark, &le; 512&nbsp;KB</span></div>'
9820
+ + ' <input type="file" id="be-logo-file" accept="image/svg+xml,image/png,image/jpeg" hidden>'
9821
+ + ' <div class="brand-hint" id="be-logo-hint">SVG uploads are sanitized (scripts and handlers stripped) before storage.</div></div>'
9822
+ + ' <div class="brand-field"><label for="be-hex">Brand color</label>'
9823
+ + ' <div class="brand-swatch-row" id="be-swatches"></div>'
9824
+ + ' <div class="brand-swatch-row" style="margin-top:8px"><input class="search brand-color-hex" id="be-hex" type="text" placeholder="#0d9488" style="margin:0">'
9825
+ + ' <span class="brand-hint" id="be-contrast-hint" style="margin:0"></span></div></div>'
9826
+ + ' <div class="brand-editor-actions"><button class="send-button" type="button" id="be-save">Save brand</button>'
9827
+ + ' <button class="ghost" type="button" id="be-clear">Reset to FRAIM</button>'
9828
+ + ' <span class="brand-hint" id="be-status" style="margin:0"></span></div>'
9829
+ + ' <div class="brand-preview"><div class="brand-preview-cap">Top nav preview</div>'
9830
+ + ' <nav class="hub-tabs" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
9831
+ + ' <span class="hub-brand-divider" id="be-prev-div"></span>'
9832
+ + ' <button class="hub-tab on" type="button">Projects</button><button class="hub-tab" type="button">Company</button>'
9833
+ + ' <div class="nav-right"><span class="hub-cobrand">powered by <span class="hub-cobrand-mark">' + FRAIM_COMARK_SVG + '</span> FRAIM</span>'
9834
+ + ' <button class="avatar-btn" type="button">SM</button></div></nav></div>'
9835
+ + '</div>';
9836
+
9837
+ var nameEl = host.querySelector('#be-name');
9838
+ var hexEl = host.querySelector('#be-hex');
9839
+ var fileEl = host.querySelector('#be-logo-file');
9840
+ var dropEl = host.querySelector('#be-logo-drop');
9841
+ var swatches = host.querySelector('#be-swatches');
9842
+ var statusEl = host.querySelector('#be-status');
9843
+ nameEl.value = draft.name;
9844
+ hexEl.value = draft.color;
9845
+
9846
+ BRAND_COLOR_PRESETS.forEach(function (c) {
9847
+ var b = document.createElement('button');
9848
+ b.type = 'button'; b.className = 'brand-swatch'; b.style.background = c; b.title = c;
9849
+ b.addEventListener('click', function () { draft.color = c; hexEl.value = c; refresh(); });
9850
+ swatches.appendChild(b);
9851
+ });
9852
+
9853
+ function updatePreviewLogo() {
9854
+ var lp = host.querySelector('#be-logo-preview');
9855
+ if (!lp) return;
9856
+ lp.innerHTML = '';
9857
+ // Preview via <img> (safe image context) so even a not-yet-sanitized upload
9858
+ // can't execute script while the manager is still editing.
9859
+ var src = tfBrandLogoSrc(draft.logo);
9860
+ if (src) { var im = document.createElement('img'); im.src = src; im.alt = ''; lp.appendChild(im); }
9861
+ }
9862
+ function refresh() {
9863
+ // Live nav preview.
9864
+ tfFillBrandLockup(host.querySelector('#be-prev-brand'), draft);
9865
+ updatePreviewLogo();
9866
+ // Selected swatch.
9867
+ Array.prototype.forEach.call(swatches.children, function (b) {
9868
+ b.classList.toggle('sel', tfNormalizeHex(b.title) === tfNormalizeHex(draft.color));
9869
+ });
9870
+ // Contrast hint (R2.3).
9871
+ var hint = host.querySelector('#be-contrast-hint');
9872
+ var norm = tfNormalizeHex(draft.color);
9873
+ if (!draft.color) { hint.textContent = ''; hint.className = 'brand-hint'; }
9874
+ else if (!norm) { hint.textContent = 'Enter a valid hex color (e.g. #0d9488).'; hint.className = 'brand-hint warn'; }
9875
+ else {
9876
+ var lightOk = !!tfBrandAccentForTheme(norm, HUB_SURFACE_LIGHT);
9877
+ var darkOk = !!tfBrandAccentForTheme(norm, HUB_SURFACE_DARK);
9878
+ if (lightOk && darkOk) { hint.textContent = '✓ Readable in light and dark.'; hint.className = 'brand-hint'; }
9879
+ else { hint.textContent = 'Low contrast in ' + (!lightOk && !darkOk ? 'light and dark' : (!lightOk ? 'light' : 'dark')) + ' mode — the Hub will keep the FRAIM accent there.'; hint.className = 'brand-hint warn'; }
9880
+ }
9881
+ }
9882
+
9883
+ nameEl.addEventListener('input', function () { draft.name = nameEl.value; refresh(); });
9884
+ hexEl.addEventListener('input', function () { draft.color = hexEl.value; refresh(); });
9885
+ dropEl.addEventListener('click', function () { fileEl.click(); });
9886
+ // Keyboard access (a11y): Enter/Space on the focusable drop zone opens the picker.
9887
+ dropEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileEl.click(); } });
9888
+ fileEl.addEventListener('change', function () {
9889
+ var f = fileEl.files && fileEl.files[0];
9890
+ if (!f) return;
9891
+ if (f.size > 512 * 1024) { host.querySelector('#be-logo-hint').textContent = 'That file is over 512 KB — pick a smaller logo.'; host.querySelector('#be-logo-hint').className = 'brand-hint warn'; return; }
9892
+ var reader = new FileReader();
9893
+ reader.onload = function () {
9894
+ draft.logo = String(reader.result || '');
9895
+ host.querySelector('#be-logo-txt').textContent = f.name;
9896
+ refresh();
9897
+ };
9898
+ if (/svg/i.test(f.type)) reader.readAsText(f); else reader.readAsDataURL(f);
9899
+ });
9900
+
9901
+ host.querySelector('#be-save').addEventListener('click', function () {
9902
+ statusEl.textContent = 'Saving…'; statusEl.className = 'brand-hint';
9903
+ var body = { name: draft.name, color: draft.color, logo: draft.logo };
9904
+ if (state.projectPath) body.projectPath = state.projectPath;
9905
+ requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
9906
+ .then(function (resp) {
9907
+ tfApplyOrgBrand(resp && resp.brand ? resp.brand : null);
9908
+ statusEl.textContent = 'Saved — applied to your Hub.'; statusEl.className = 'brand-hint';
9909
+ tfShowBrandTeamNote(host);
9910
+ })
9911
+ .catch(function (err) { statusEl.textContent = 'Save failed: ' + (err && err.message ? err.message : 'error'); statusEl.className = 'brand-hint warn'; });
9912
+ });
9913
+ host.querySelector('#be-clear').addEventListener('click', function () {
9914
+ var body = { name: '', color: '', logo: '' };
9915
+ if (state.projectPath) body.projectPath = state.projectPath;
9916
+ requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
9917
+ .then(function () { tfApplyOrgBrand(null); tfRenderBrandEditor(); })
9918
+ .catch(function () {});
9919
+ });
9920
+
9921
+ refresh();
9922
+ }
9923
+ // The brand is stored WITH company info in the org context, so it reaches the
9924
+ // team by the SAME path org context does: publish org context, teammates sync.
9925
+ // (There is no separate one-click Hub push endpoint; propagation is org-pack sync.)
9926
+ function tfShowBrandTeamNote(host) {
9927
+ var existing = host.querySelector('.brand-team-note');
9928
+ if (existing) existing.remove();
9929
+ var note = document.createElement('div');
9930
+ note.className = 'brand-hint brand-team-note';
9931
+ note.style.cssText = 'margin-top:12px;padding-left:12px;border-left:2px solid var(--accent-soft)';
9932
+ note.textContent = 'Saved to your organization context. Teammates pick it up on their next sync once your org context is published (it travels with your company info).';
9933
+ host.appendChild(note);
9934
+ }
9935
+
9494
9936
  // ---------------------------------------------------------------------------
9495
9937
  // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9496
9938
  // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
@@ -9515,6 +9957,8 @@ function tfApplyTheme(theme, persist) {
9515
9957
  document.documentElement.setAttribute('data-theme', t);
9516
9958
  if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
9517
9959
  tfSyncThemeToggle(t);
9960
+ // Issue #744: recompute the contrast-safe brand accent for the new surface.
9961
+ if (typeof tfApplyBrandAccent === 'function') tfApplyBrandAccent();
9518
9962
  // Keep embedded surfaces in sync live, including the connected main-server frame.
9519
9963
  try {
9520
9964
  document.querySelectorAll('iframe').forEach((f) => {
@@ -9853,6 +10297,9 @@ async function tfCreateProject() {
9853
10297
  };
9854
10298
  tf.projects.push(project);
9855
10299
  tf.assignments[id] = [];
10300
+ // #726: re-creating a project at a previously-removed path lifts the client guard
10301
+ // (the server revive is handled via reviveRemovedPaths below).
10302
+ tf.removedPaths.delete(tfCanonicalProjectPath(folderPath));
9856
10303
  tfPersistProjects({ reviveRemovedPaths: [folderPath] });
9857
10304
  tfPersistAssignments();
9858
10305
  tf.activeProjectId = id;