fraim-framework 2.0.182 → 2.0.184

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.
@@ -50,6 +50,8 @@ const state = {
50
50
  _hireStripPending: null,
51
51
  };
52
52
 
53
+ const bootstrapCache = new Map();
54
+
53
55
  const els = {};
54
56
 
55
57
  // ---------------------------------------------------------------------------
@@ -120,14 +122,16 @@ async function requestJson(url, options) {
120
122
  return payload;
121
123
  }
122
124
 
123
- async function loadBootstrap(projectPath, docUrl) {
124
- const params = new URLSearchParams();
125
- if (projectPath) params.set('projectPath', projectPath);
126
- if (docUrl) params.set('docUrl', docUrl);
127
- const query = params.toString() ? '?' + params.toString() : '';
128
- const fetchOptions = {};
129
- if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
130
- const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
125
+ function bootstrapCacheKey(projectPath, docUrl) {
126
+ return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
127
+ }
128
+
129
+ function cacheBootstrapPayload(bootstrap, docUrl) {
130
+ if (!bootstrap || !bootstrap.project || !bootstrap.project.path) return;
131
+ bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
132
+ }
133
+
134
+ function applyBootstrap(bootstrap, docUrl) {
131
135
  state.bootstrap = bootstrap;
132
136
  state.projectPath = bootstrap.project.path;
133
137
  state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
@@ -162,6 +166,45 @@ async function loadBootstrap(projectPath, docUrl) {
162
166
  // Issue #540: render the persona grid and manager team pool.
163
167
  renderPersonaGrid();
164
168
  renderManagerTeamPool();
169
+ cacheBootstrapPayload(bootstrap, docUrl);
170
+ }
171
+
172
+ async function fetchBootstrap(projectPath, docUrl) {
173
+ const params = new URLSearchParams();
174
+ if (projectPath) params.set('projectPath', projectPath);
175
+ if (docUrl) params.set('docUrl', docUrl);
176
+ const query = params.toString() ? '?' + params.toString() : '';
177
+ const fetchOptions = {};
178
+ if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
179
+ const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
180
+ cacheBootstrapPayload(bootstrap, docUrl);
181
+ return bootstrap;
182
+ }
183
+
184
+ async function refreshBootstrapInBackground(projectPath, docUrl) {
185
+ try {
186
+ const bootstrap = await fetchBootstrap(projectPath, docUrl);
187
+ if (tfCanonicalProjectPath(state.projectPath) !== tfCanonicalProjectPath(bootstrap.project.path)) return;
188
+ applyBootstrap(bootstrap, docUrl);
189
+ await hydrateConversationsFromServer();
190
+ tfRefreshAfterBootstrap();
191
+ } catch (error) {
192
+ console.warn('[512] Background project refresh failed:', error);
193
+ }
194
+ }
195
+
196
+ async function loadBootstrap(projectPath, docUrl, options) {
197
+ const opts = options || {};
198
+ const requestedKey = bootstrapCacheKey(projectPath || state.projectPath, docUrl);
199
+ if (opts.preferCache && bootstrapCache.has(requestedKey)) {
200
+ const cached = bootstrapCache.get(requestedKey);
201
+ applyBootstrap(cached, docUrl);
202
+ if (opts.backgroundRefresh !== false) refreshBootstrapInBackground(projectPath, docUrl);
203
+ return cached;
204
+ }
205
+ const bootstrap = await fetchBootstrap(projectPath, docUrl);
206
+ applyBootstrap(bootstrap, docUrl);
207
+ return bootstrap;
165
208
  }
166
209
 
167
210
  // ---------------------------------------------------------------------------
@@ -348,6 +391,11 @@ function friendlyProjectName(p) {
348
391
  return parts.slice(-2).join('/') || p;
349
392
  }
350
393
 
394
+ function friendlyProjectShortName(p) {
395
+ const friendly = friendlyProjectName(p);
396
+ return friendly.split(/[\\/]/).filter(Boolean).pop() || friendly;
397
+ }
398
+
351
399
  // ---------------------------------------------------------------------------
352
400
  // Conversation persistence (per project, in localStorage)
353
401
  // ---------------------------------------------------------------------------
@@ -620,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
620
668
  .trim();
621
669
  }
622
670
 
623
- function stripHubInjectedPromptBlocks(text) {
671
+ function stripHubInjectedNotes(text) {
624
672
  return String(text || '')
625
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')
626
- .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')
627
681
  .replace(/\n{3,}/g, '\n\n')
628
682
  .trim();
629
683
  }
@@ -2580,8 +2634,8 @@ function closeTemplatePopover() {
2580
2634
  }
2581
2635
 
2582
2636
  function applyTemplateInvocation(managerJobId) {
2583
- // The invocation prefix ($fraim / /fraim) is an implementation detail that
2584
- // the server adds based on the active employee the user should never see it.
2637
+ // The server adds the invocation prefix based on the active employee. Keep
2638
+ // that syntax out of the editable textarea; the thread shows it after send.
2585
2639
  // Store the job ID as a pending coaching job. The #coach-note shows a
2586
2640
  // human-readable label; the textarea gets editable context the user can amend.
2587
2641
  // When the user clicks Send, continueRun sends { coachingJobId, instructions }.
@@ -2590,7 +2644,7 @@ function applyTemplateInvocation(managerJobId) {
2590
2644
  // for sessions that stored raw invocation text before this change).
2591
2645
  const textarea = els['coach-text'];
2592
2646
  textarea.value = textarea.value
2593
- .replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2647
+ .replace(/(?:^|\n|\s)[$/@]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
2594
2648
  .replace(/\s+/g, ' ')
2595
2649
  .trim();
2596
2650
  textarea.focus();
@@ -2629,17 +2683,20 @@ function stripStubReference(text) {
2629
2683
  }
2630
2684
 
2631
2685
  function surfaceText(role, text, conv) {
2632
- const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
2686
+ const withoutStub = stripStubReference(text);
2687
+ const raw = role === 'manager'
2688
+ ? stripHubInjectedNotes(withoutStub)
2689
+ : stripHubInjectedPromptBlocks(withoutStub);
2633
2690
  if (!raw) return '';
2634
2691
 
2635
2692
  if (role === 'manager') {
2636
- const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
2693
+ const invocationOnly = raw.match(/^(?:[$/@]fraim)\s+([a-z0-9-]+)\s*$/i);
2637
2694
  if (invocationOnly) return raw;
2638
2695
  const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
2639
- const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
2696
+ const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
2640
2697
  return raw
2641
2698
  .replace(invocationWithSlug, '')
2642
- .replace(/^(?:[$/]fraim)\s*/i, '')
2699
+ .replace(/^(?:[$/@]fraim)\s*/i, '')
2643
2700
  .trim();
2644
2701
  }
2645
2702
 
@@ -4592,9 +4649,9 @@ function deriveTitle(jobTitle, instructions) {
4592
4649
  const trimmedJob = (jobTitle || '').trim();
4593
4650
  const raw = (instructions || '').trim();
4594
4651
  // Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
4595
- const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4652
+ const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
4596
4653
  const stripped = raw
4597
- .replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4654
+ .replace(/^[$/@]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
4598
4655
  .replace(/\s+/g, ' ')
4599
4656
  .trim();
4600
4657
  const words = stripped.split(' ').filter(Boolean).slice(0, 6);
@@ -5956,7 +6013,7 @@ const tf = {
5956
6013
  area: 'projects', // company | manager | projects | brain
5957
6014
  projectView: 'overview', // overview | workspace
5958
6015
  activeProjectId: null,
5959
- projects: [], // [{ id, name, intent, brief, team:[personaKey] }]
6016
+ projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
5960
6017
  assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
5961
6018
  npStep: 1,
5962
6019
  npSelectedEmployees: [],
@@ -5978,24 +6035,144 @@ function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) ||
5978
6035
  function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
5979
6036
  function tfAvailablePersonas() { return tfPersonas().filter((p) => p.status !== 'hired'); }
5980
6037
  function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) || null; }
6038
+ // A project's employee roster is derived from the hired personas in the bootstrap
6039
+ // (DB-authoritative), NOT from per-machine local state. With the legacy-workspace
6040
+ // bypass every user sees the full employee catalog, and the roster is identical on
6041
+ // every machine the user signs in from. Deliberately not persisted to disk /
6042
+ // localStorage — see AiHubProjectListEntry in src/ai-hub/types.ts.
6043
+ function tfProjectTeamKeys() { return tfHiredPersonas().map((p) => p.key); }
5981
6044
 
5982
6045
  // ---------------------------------------------------------------------------
5983
6046
  // Persistence (projects + assignments, mirrors the conversation model)
5984
6047
  // ---------------------------------------------------------------------------
6048
+ function tfCanonicalProjectPath(folderPath) {
6049
+ const value = String(folderPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
6050
+ return /^[A-Za-z]:\//.test(value) ? value.toLowerCase() : value;
6051
+ }
6052
+
6053
+ function tfProjectIdForPath(folderPath) {
6054
+ const canonical = tfCanonicalProjectPath(folderPath);
6055
+ let hash = 0;
6056
+ for (let i = 0; i < canonical.length; i++) hash = ((hash << 5) - hash + canonical.charCodeAt(i)) | 0;
6057
+ return 'p-' + Math.abs(hash).toString(36);
6058
+ }
6059
+
6060
+ function tfNormalizeProject(raw, fallbackPath) {
6061
+ if (!raw || typeof raw !== 'object') return null;
6062
+ const folderPath = raw.folderPath || raw.path || raw.folder || fallbackPath || '';
6063
+ if (!folderPath) return null;
6064
+ const cleanPath = String(folderPath);
6065
+ const rawId = typeof raw.id === 'string' ? raw.id.trim() : '';
6066
+ return {
6067
+ id: rawId || tfProjectIdForPath(cleanPath),
6068
+ name: raw.name || friendlyProjectShortName(cleanPath),
6069
+ folderPath: cleanPath,
6070
+ intent: raw.intent || '',
6071
+ outcome: raw.outcome || '',
6072
+ rules: raw.rules || '',
6073
+ brief: raw.brief || raw.intent || '',
6074
+ // Roster is derived from hired personas at render time (tfProjectTeamKeys),
6075
+ // never stored per machine. Any legacy `team` on stored state is dropped.
6076
+ updatedAt: raw.updatedAt || undefined,
6077
+ };
6078
+ }
6079
+
6080
+ function tfApplyUniqueProjectIds(projects) {
6081
+ const seen = new Set();
6082
+ return (projects || []).map((project) => {
6083
+ const fallbackId = tfProjectIdForPath(project.folderPath || project.path || project.folder);
6084
+ const rawId = typeof project.id === 'string' ? project.id.trim() : '';
6085
+ let id = rawId && rawId !== 'p-current' ? rawId : fallbackId;
6086
+ let suffix = 2;
6087
+ while (seen.has(id)) {
6088
+ id = `${fallbackId}-${suffix}`;
6089
+ suffix += 1;
6090
+ }
6091
+ seen.add(id);
6092
+ return { ...project, id };
6093
+ });
6094
+ }
6095
+
6096
+ function tfMergeProjects(projects) {
6097
+ const merged = new Map();
6098
+ const add = (project) => {
6099
+ const normalized = tfNormalizeProject(project);
6100
+ if (!normalized) return;
6101
+ const key = tfCanonicalProjectPath(normalized.folderPath);
6102
+ const existing = merged.get(key);
6103
+ if (existing) {
6104
+ const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
6105
+ merged.set(key, next);
6106
+ } else {
6107
+ merged.set(key, normalized);
6108
+ }
6109
+ };
6110
+ for (const project of tf.projects || []) add(project);
6111
+ for (const project of projects || []) add(project);
6112
+ tf.projects = tfApplyUniqueProjectIds(Array.from(merged.values()));
6113
+ }
6114
+
6115
+ function tfProjectForPath(folderPath) {
6116
+ const key = tfCanonicalProjectPath(folderPath);
6117
+ return tf.projects.find((project) => tfCanonicalProjectPath(project.folderPath || project.path || project.folder) === key) || null;
6118
+ }
6119
+
6120
+ function tfEnsureCurrentProject() {
6121
+ if (!state.projectPath) return null;
6122
+ const current = tfNormalizeProject({
6123
+ id: tfProjectIdForPath(state.projectPath),
6124
+ name: friendlyProjectShortName(state.projectPath),
6125
+ folderPath: state.projectPath,
6126
+ }, state.projectPath);
6127
+ tfMergeProjects([current]);
6128
+ const resolved = tfProjectForPath(state.projectPath);
6129
+ if (!tf.activeProjectId || !tf.projects.some((project) => project.id === tf.activeProjectId)) {
6130
+ tf.activeProjectId = resolved ? resolved.id : tf.projects[0]?.id || null;
6131
+ }
6132
+ return resolved;
6133
+ }
6134
+
5985
6135
  function tfLoadStorage() {
5986
- try { tf.projects = JSON.parse(window.localStorage.getItem(STORAGE_KEY_PROJECTS_512) || '[]'); }
6136
+ try {
6137
+ const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY_PROJECTS_512) || '[]');
6138
+ const normalized = Array.isArray(stored) ? stored.map((project) => tfNormalizeProject(project)).filter(Boolean) : [];
6139
+ tf.projects = tfApplyUniqueProjectIds(normalized);
6140
+ }
5987
6141
  catch { tf.projects = []; }
5988
6142
  try { tf.assignments = JSON.parse(window.localStorage.getItem(STORAGE_KEY_ASSIGNMENTS_512) || '{}'); }
5989
6143
  catch { tf.assignments = {}; }
5990
6144
  }
5991
6145
  function tfPersistProjects() {
5992
6146
  try { window.localStorage.setItem(STORAGE_KEY_PROJECTS_512, JSON.stringify(tf.projects)); } catch {}
6147
+ if (!state.projectPath) return;
6148
+ requestJson('/api/ai-hub/projects', {
6149
+ method: 'PUT',
6150
+ headers: { 'Content-Type': 'application/json' },
6151
+ body: JSON.stringify({ projectPath: state.projectPath, projects: tf.projects }),
6152
+ }).catch((error) => console.warn('[512] Could not persist projects to local disk:', error));
5993
6153
  }
5994
6154
  function tfPersistAssignments() {
5995
6155
  try { window.localStorage.setItem(STORAGE_KEY_ASSIGNMENTS_512, JSON.stringify(tf.assignments)); } catch {}
5996
6156
  }
5997
6157
  function tfProjectAssignments(projectId) { return tf.assignments[projectId] || []; }
5998
6158
 
6159
+ function tfSetProjectSwitching(active, project) {
6160
+ const workspace = document.getElementById('proj-workspace');
6161
+ const mask = document.getElementById('proj-switch-mask');
6162
+ const label = document.getElementById('proj-switch-label');
6163
+ if (workspace) {
6164
+ workspace.classList.toggle('project-switching', !!active);
6165
+ workspace.setAttribute('aria-busy', active ? 'true' : 'false');
6166
+ }
6167
+ if (mask) mask.setAttribute('aria-hidden', active ? 'false' : 'true');
6168
+ if (label && project) {
6169
+ label.textContent = `Loading ${project.name || friendlyProjectShortName(project.folderPath || project.path || project.folder)}...`;
6170
+ }
6171
+ document.querySelectorAll('#proj-tabs .ptab').forEach((tab) => {
6172
+ if (tab.id !== 'ptab-add') tab.disabled = !!active;
6173
+ });
6174
+ }
6175
+
5999
6176
  // ---------------------------------------------------------------------------
6000
6177
  // Status dots — derived from each conversation's read-side run projection.
6001
6178
  // red = needs you, amber = working, green = done, grey = idle/no run.
@@ -6023,9 +6200,13 @@ function tfConvForAssignment(assignment) {
6023
6200
  }
6024
6201
  // #521 R6.5: all past runs of a job across the workspace (newest first) — powers
6025
6202
  // the run history under the Company/Manager rail jobs, same as the project tree.
6026
- function tfConversationsForJob(jobId) {
6203
+ function tfConversationsForJob(jobId, opts) {
6027
6204
  const out = [];
6028
- for (const list of Object.values(state.conversations || {})) {
6205
+ const projectPath = opts && opts.projectPath;
6206
+ const lists = projectPath
6207
+ ? [state.conversations[projectPath] || []]
6208
+ : Object.values(state.conversations || {});
6209
+ for (const list of lists) {
6029
6210
  for (const c of (list || [])) if (c && c.jobId === jobId) out.push(c);
6030
6211
  }
6031
6212
  out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
@@ -6079,7 +6260,7 @@ function tfShowArea(area) {
6079
6260
  window.scrollTo(0, 0);
6080
6261
  }
6081
6262
 
6082
- function tfSelectProjectView(view, projectId) {
6263
+ async function tfSelectProjectView(view, projectId) {
6083
6264
  tf.projectView = view;
6084
6265
  if (projectId) tf.activeProjectId = projectId;
6085
6266
  // Always return to the conversation when changing views.
@@ -6100,9 +6281,16 @@ function tfSelectProjectView(view, projectId) {
6100
6281
  // When switching to a project workspace, switch the active folder so all
6101
6282
  // runs, context reads, and learnings scope to that project's disk location.
6102
6283
  const proj = tf.projects.find((p) => p.id === tf.activeProjectId);
6103
- if (proj && proj.folderPath && proj.folderPath !== state.projectPath) {
6104
- tfSwitchProjectFolder(proj.folderPath).catch((e) =>
6105
- console.warn('[512] tfSwitchProjectFolder failed:', e));
6284
+ const folderPath = proj && (proj.folderPath || proj.path || proj.folder);
6285
+ if (folderPath && tfCanonicalProjectPath(folderPath) !== tfCanonicalProjectPath(state.projectPath)) {
6286
+ try {
6287
+ tfSetProjectSwitching(true, proj);
6288
+ await tfSwitchProjectFolder(folderPath);
6289
+ } catch (e) {
6290
+ console.warn('[512] tfSwitchProjectFolder failed:', e);
6291
+ } finally {
6292
+ tfSetProjectSwitching(false);
6293
+ }
6106
6294
  }
6107
6295
  // #521 fix: invalidate the project-scoped context cache whenever the active
6108
6296
  // project changes — covers new-project creation (where state.projectPath is
@@ -6235,7 +6423,7 @@ function tfRenderOverview() {
6235
6423
  const team = document.createElement('div');
6236
6424
  team.className = 'proj-team';
6237
6425
  const assigned = tfProjectAssignments(proj.id);
6238
- const empKeys = Array.from(new Set([...(proj.team || []), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6426
+ const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
6239
6427
  empKeys.slice(0, 6).forEach((key, i) => {
6240
6428
  const persona = tfPersonaByKey(key);
6241
6429
  const av = tfAvatarFor(persona ? persona.displayName : key, i);
@@ -6417,7 +6605,7 @@ function kbMakeCard(conv) {
6417
6605
  // Kanban/Cards overview is read-only. Clicking anywhere on the card navigates to the workspace.
6418
6606
  const navigate = () => {
6419
6607
  if (!conv.id) return;
6420
- const _proj = conv.projectPath ? tf.projects.find((p) => p.folderPath === conv.projectPath) : null;
6608
+ const _proj = conv.projectPath ? tfProjectForPath(conv.projectPath) : null;
6421
6609
  tfSelectProjectView('workspace', _proj ? _proj.id : tf.activeProjectId);
6422
6610
  switchToConversation(conv.id);
6423
6611
  };
@@ -6537,17 +6725,16 @@ function tfRenderTree() {
6537
6725
  const projectId = tf.activeProjectId;
6538
6726
  const assigned = tfProjectAssignments(projectId);
6539
6727
  const project = tf.projects.find((p) => p.id === projectId);
6540
- const onProject = (project && project.team) || [];
6728
+ const onProject = tfProjectTeamKeys();
6541
6729
  // Issue #540 R8: include personas on the manager's team so delegate buttons
6542
6730
  // appear in the workspace tree even when the persona hasn't been assigned yet.
6543
6731
  const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
6544
6732
  const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
6545
- // #538 follow-up: reconcile against real entitlements. The localStorage
6546
- // project.team can hold members who are no longer hired (or were seeded
6547
- // before an entitlement change), which made un-hired personas like swen
6548
- // appear in a workspace where only beza/pam are actually hired. Drop any
6549
- // key that resolves to a known persona that is NOT hired; keep hired
6550
- // personas and genuinely custom employees (no persona record).
6733
+ // #538 follow-up: reconcile against real entitlements. `assigned` job records
6734
+ // and manager-team keys can reference personas that are no longer hired; drop
6735
+ // any key that resolves to a known persona that is NOT hired. Keep hired
6736
+ // personas and genuinely custom employees (no persona record). onProject is
6737
+ // already hired-only (tfProjectTeamKeys), so this is a no-op for it.
6551
6738
  .filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
6552
6739
  const managerTeamKeySet = new Set(managerTeamPersonaKeys);
6553
6740
 
@@ -6815,9 +7002,12 @@ async function loadDeployments() {
6815
7002
  const list = document.getElementById('proj-deployments-list');
6816
7003
  if (!list) return;
6817
7004
  try {
7005
+ const params = new URLSearchParams();
7006
+ if (state.projectPath) params.set('projectPath', state.projectPath);
7007
+ const suffix = params.toString() ? '?' + params.toString() : '';
6818
7008
  const [schResp, whResp] = await Promise.all([
6819
- fetch('/api/ai-hub/schedules'),
6820
- fetch('/api/ai-hub/webhooks'),
7009
+ fetch('/api/ai-hub/schedules' + suffix),
7010
+ fetch('/api/ai-hub/webhooks' + suffix),
6821
7011
  ]);
6822
7012
  const schedules = schResp.ok ? await schResp.json() : [];
6823
7013
  const webhooks = whResp.ok ? await whResp.json() : [];
@@ -7157,7 +7347,7 @@ async function saveScheduleDeployment() {
7157
7347
  const resp = await fetch(url, {
7158
7348
  method,
7159
7349
  headers: { 'Content-Type': 'application/json' },
7160
- body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
7350
+ body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }),
7161
7351
  });
7162
7352
  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; }
7163
7353
  _editingDepId = null;
@@ -7180,7 +7370,7 @@ async function saveWebhookDeployment() {
7180
7370
  const resp = await fetch(url, {
7181
7371
  method,
7182
7372
  headers: { 'Content-Type': 'application/json' },
7183
- body: JSON.stringify({ label, jobId, hostId, instructions: instructions || undefined }),
7373
+ body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }),
7184
7374
  });
7185
7375
  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; }
7186
7376
  if (!isEdit) {
@@ -7789,7 +7979,7 @@ function tfProjectUpdatesSection() {
7789
7979
  av.textContent = '🔄';
7790
7980
  const id = document.createElement('div'); id.className = 'eh-id';
7791
7981
  const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = 'Project Updates';
7792
- const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id).length, 0);
7982
+ const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
7793
7983
  const role = document.createElement('div'); role.className = 'eh-role';
7794
7984
  role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
7795
7985
  id.appendChild(nm); id.appendChild(role);
@@ -7811,7 +8001,7 @@ function tfProjectUpdatesSection() {
7811
8001
  const head = document.createElement('div'); head.className = 'pu-job-name';
7812
8002
  head.textContent = j.ico + ' ' + j.name;
7813
8003
  group.appendChild(head);
7814
- const runs = tfConversationsForJob(j.id);
8004
+ const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
7815
8005
  if (!runs.length) {
7816
8006
  const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
7817
8007
  group.appendChild(none);
@@ -9024,7 +9214,7 @@ async function tfCreateProject() {
9024
9214
  intent && `Project intent: ${intent}`,
9025
9215
  val('np-outcome') && `Success looks like: ${val('np-outcome')}`,
9026
9216
  val('np-rules') && `Rules & guardrails: ${val('np-rules')}`,
9027
- `Team: ${(project.team || []).join(', ') || 'no employees assigned yet'}`,
9217
+ `Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
9028
9218
  ].filter(Boolean).join('\n\n');
9029
9219
 
9030
9220
  if (onboardingContext) {
@@ -9045,8 +9235,13 @@ async function tfCreateProject() {
9045
9235
  * the server's preferences store records it so it survives restart.
9046
9236
  */
9047
9237
  async function tfSwitchProjectFolder(folderPath) {
9048
- if (!folderPath || folderPath === state.projectPath) return;
9049
- state.projectPath = folderPath;
9238
+ const normalized = tfNormalizeProject({ folderPath });
9239
+ if (!normalized) return;
9240
+ if (tfCanonicalProjectPath(normalized.folderPath) === tfCanonicalProjectPath(state.projectPath)) {
9241
+ await hydrateConversationsFromServer();
9242
+ return;
9243
+ }
9244
+ state.projectPath = normalized.folderPath;
9050
9245
  // #521 fix: the context cache (state._ctxCache) is keyed by context key only
9051
9246
  // (projectContext/projectBrief/projectRules), NOT by project — so without this
9052
9247
  // the new project's Brief section would render the PREVIOUS project's content
@@ -9056,7 +9251,23 @@ async function tfSwitchProjectFolder(folderPath) {
9056
9251
  for (const k of ['projectContext', 'projectBrief', 'projectRules']) delete cache[k];
9057
9252
  // loadBootstrap sends projectPath as a query param — the server records it
9058
9253
  // in its preferences store so this folder becomes the default on next load.
9059
- await loadBootstrap(folderPath);
9254
+ const cachedConversations = Array.isArray(state.conversations[normalized.folderPath]);
9255
+ const conversationsPromise = cachedConversations ? null : hydrateConversationsFromServer();
9256
+ await loadBootstrap(normalized.folderPath, undefined, { preferCache: true });
9257
+ if (cachedConversations) {
9258
+ renderRail();
9259
+ renderActive();
9260
+ hydrateConversationsFromServer().catch((error) =>
9261
+ console.warn('[512] Could not refresh project conversations:', error)
9262
+ );
9263
+ } else {
9264
+ await conversationsPromise;
9265
+ }
9266
+ tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
9267
+ const currentProject = tfEnsureCurrentProject();
9268
+ if (currentProject) tf.activeProjectId = currentProject.id;
9269
+ tfPersistProjects();
9270
+ tfRefreshAfterBootstrap();
9060
9271
  }
9061
9272
 
9062
9273
  // ---------------------------------------------------------------------------
@@ -9078,7 +9289,7 @@ function tfOpenAssignJob(employeeKey) {
9078
9289
  const filterKey = employeeKey || null;
9079
9290
  const filterPersona = filterKey ? tfPersonaByKey(filterKey) : null;
9080
9291
  const filterName = filterPersona ? (filterPersona.displayName || filterKey) : null;
9081
- if (title) title.textContent = filterKey ? ('Jobs for ' + filterName) : 'Assign a job';
9292
+ if (title) title.textContent = filterKey ? ('Assign a job to ' + filterName) : 'Assign a job';
9082
9293
 
9083
9294
  const allJobs = (state.bootstrap && state.bootstrap.jobs) || [];
9084
9295
  const personasToShow = filterKey ? hired.filter((p) => p.key === filterKey) : hired;
@@ -9132,7 +9343,7 @@ function tfOpenAssignJob(employeeKey) {
9132
9343
  if (specificJobs.length > 0) {
9133
9344
  const specLabel = document.createElement('div');
9134
9345
  specLabel.className = 'cat-label';
9135
- specLabel.textContent = (filterName || persona.displayName) + 's specialty';
9346
+ specLabel.textContent = (filterName || persona.displayName) + "'s jobs";
9136
9347
  listWrap.appendChild(specLabel);
9137
9348
  specificJobs.forEach(function(job) { listWrap.appendChild(buildJobRow(av, job, persona)); });
9138
9349
  }
@@ -9189,10 +9400,9 @@ function tfAssignJobToEmployee(employeeKey, job) {
9189
9400
  if (!projectId) { tfCloseAssignJob(); return; }
9190
9401
  if (!tf.assignments[projectId]) tf.assignments[projectId] = [];
9191
9402
  tf.assignments[projectId].push({ id: 'a-' + Date.now(), employeeKey, jobName: job.title || job.id, jobId: job.id });
9192
- const proj = tf.projects.find((p) => p.id === projectId);
9193
- if (proj) { proj.team = proj.team || []; if (!proj.team.includes(employeeKey)) proj.team.push(employeeKey); }
9403
+ // No longer mutate a stored project.team: the roster is derived from hired
9404
+ // personas (tfProjectTeamKeys) and every employee is always available.
9194
9405
  tfPersistAssignments();
9195
- tfPersistProjects();
9196
9406
  tfCloseAssignJob();
9197
9407
  tfRenderTree();
9198
9408
  tfRenderProjectTabs();
@@ -9211,7 +9421,7 @@ function tfOpenAddEmp(preselectKey) {
9211
9421
  const sub = document.getElementById('ae-sub');
9212
9422
  if (!modal || !body) return;
9213
9423
  body.innerHTML = '';
9214
- const onProject = (tf.projects.find((p) => p.id === tf.activeProjectId) || {}).team || [];
9424
+ const onProject = tfProjectTeamKeys();
9215
9425
  const onProjectPersonas = onProject.map(tfPersonaByKey).filter(Boolean);
9216
9426
  if (onProjectPersonas.length) {
9217
9427
  const label = document.createElement('div');
@@ -9538,19 +9748,15 @@ function tfInitShell() {
9538
9748
  const tabs = document.getElementById('hub-tabs');
9539
9749
  if (tabs) tabs.hidden = false;
9540
9750
  tfLoadStorage();
9751
+ tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
9541
9752
  tfWireShell();
9542
9753
  tfPopulateAccountMenu(); // #533: show the real account identity, not "SM"
9543
9754
  // Seed a project from the loaded folder so the workspace reuses the existing
9544
9755
  // conversation machinery for that real project path.
9545
- if (state.projectPath && !tf.projects.length) {
9546
- const proj = {
9547
- id: 'p-current',
9548
- name: friendlyProjectName(state.projectPath),
9549
- intent: '', brief: '', team: tfHiredPersonas().map((p) => p.key), path: state.projectPath,
9550
- };
9551
- tf.projects.push(proj);
9552
- if (!tf.assignments[proj.id]) tf.assignments[proj.id] = [];
9553
- tf.activeProjectId = proj.id;
9756
+ const currentProject = tfEnsureCurrentProject();
9757
+ if (currentProject && !tf.assignments[currentProject.id]) tf.assignments[currentProject.id] = [];
9758
+ if (currentProject) {
9759
+ tf.activeProjectId = currentProject.id;
9554
9760
  tfPersistProjects();
9555
9761
  } else if (tf.projects.length && !tf.activeProjectId) {
9556
9762
  tf.activeProjectId = tf.projects[0].id;
@@ -9577,6 +9783,9 @@ function tfInitShell() {
9577
9783
  // Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
9578
9784
  function tfRefreshAfterBootstrap() {
9579
9785
  if (!tf.enabled) return;
9786
+ tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
9787
+ tfEnsureCurrentProject();
9788
+ tfPersistProjects();
9580
9789
  tfRenderProjectTabs();
9581
9790
  if (tf.area === 'company') tfRenderCompany();
9582
9791
  else if (tf.area === 'manager') tfRenderManager();
@@ -2915,6 +2915,50 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2915
2915
  display: flex;
2916
2916
  flex: 1;
2917
2917
  min-height: 0;
2918
+ position: relative;
2919
+ }
2920
+ .proj-switch-mask {
2921
+ position: absolute;
2922
+ inset: 0;
2923
+ z-index: 30;
2924
+ display: none;
2925
+ align-items: center;
2926
+ justify-content: center;
2927
+ background: rgba(250, 250, 250, .74);
2928
+ backdrop-filter: blur(2px);
2929
+ pointer-events: auto;
2930
+ }
2931
+ #proj-workspace.project-switching .proj-switch-mask { display: flex; }
2932
+ .proj-switch-panel {
2933
+ display: inline-flex;
2934
+ align-items: center;
2935
+ gap: 10px;
2936
+ min-height: 38px;
2937
+ max-width: min(320px, calc(100vw - 48px));
2938
+ padding: 9px 14px;
2939
+ border: 1px solid var(--line);
2940
+ border-radius: 8px;
2941
+ background: var(--surface);
2942
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .12);
2943
+ color: var(--text);
2944
+ font-size: 13px;
2945
+ font-weight: 600;
2946
+ }
2947
+ .proj-switch-spinner {
2948
+ width: 16px;
2949
+ height: 16px;
2950
+ border: 2px solid rgba(0, 0, 0, .14);
2951
+ border-top-color: var(--accent);
2952
+ border-radius: 50%;
2953
+ flex: 0 0 auto;
2954
+ animation: proj-switch-spin .8s linear infinite;
2955
+ }
2956
+ @keyframes proj-switch-spin {
2957
+ to { transform: rotate(360deg); }
2958
+ }
2959
+ .ptab:disabled {
2960
+ cursor: wait;
2961
+ opacity: .72;
2918
2962
  }
2919
2963
  .tree-sidebar {
2920
2964
  width: var(--tree-sidebar-width);