fraim-framework 2.0.144 → 2.0.146

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.
@@ -3,6 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.launchDesktopShell = launchDesktopShell;
4
4
  const electron_1 = require("electron");
5
5
  const server_1 = require("./server");
6
+ const db_service_1 = require("../fraim/db-service");
7
+ function tryCreateDbService() {
8
+ try {
9
+ return new db_service_1.FraimDbService();
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
6
15
  let server = null;
7
16
  let mainWindow = null;
8
17
  let stopping = null;
@@ -79,7 +88,7 @@ async function createWindow(url) {
79
88
  }
80
89
  async function launchDesktopShell(options) {
81
90
  const port = await (0, server_1.findAvailablePort)(options.preferredPort);
82
- server = new server_1.AiHubServer({ projectPath: options.projectPath });
91
+ server = new server_1.AiHubServer({ projectPath: options.projectPath, dbService: tryCreateDbService() });
83
92
  await server.start(port);
84
93
  await createWindow(`http://127.0.0.1:${port}/ai-hub/`);
85
94
  }
@@ -14,6 +14,7 @@ const defaultPreferences = (projectPath) => ({
14
14
  employeeId: DEFAULT_EMPLOYEE,
15
15
  categoryId: DEFAULT_CATEGORY,
16
16
  recentJobIds: [],
17
+ personaKey: null,
17
18
  });
18
19
  class AiHubPreferencesStore {
19
20
  constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
@@ -30,6 +31,8 @@ class AiHubPreferencesStore {
30
31
  employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
31
32
  categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
32
33
  recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
34
+ personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
35
+ apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
33
36
  };
34
37
  }
35
38
  catch {
@@ -13,6 +13,33 @@ const os_1 = __importDefault(require("os"));
13
13
  const crypto_1 = require("crypto");
14
14
  const child_process_1 = require("child_process");
15
15
  const types_1 = require("../first-run/types");
16
+ const persona_entitlement_service_1 = require("../services/persona-entitlement-service");
17
+ const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
18
+ const PERSONA_AVATAR_SEEDS = {
19
+ maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
20
+ beza: { seed: 'BEZA-strategist', bg: 'c7d2fe' },
21
+ pam: { seed: 'PAM-product', bg: 'ddd6fe' },
22
+ swen: { seed: 'SWEN-engineer', bg: 'bfdbfe' },
23
+ qasm: { seed: 'QASM-quality', bg: 'a7f3d0' },
24
+ huxley: { seed: 'HUXLEY-design', bg: 'fbcfe8' },
25
+ gautam: { seed: 'Gautam-marketing', bg: 'fed7aa' },
26
+ cela: { seed: 'CELA-legal', bg: 'cbd5e1' },
27
+ sekhar: { seed: 'SEKHAR-security', bg: 'fecaca' },
28
+ ashley: { seed: 'Ashley-assistant', bg: 'fde68a' },
29
+ mandy: { seed: 'MANDY-manager', bg: 'ede9fe' },
30
+ hari: { seed: 'HARI-hr', bg: 'ccfbf1' },
31
+ careena: { seed: 'CAREENA-career-coach', bg: 'e0f2fe' },
32
+ ricardo: { seed: 'RICARDO-recruiter', bg: 'ede9fe' },
33
+ sade: { seed: 'SADE-salesforce-dev', bg: 'bae6fd' },
34
+ sam: { seed: 'SAM-sales-manager', bg: 'bbf7d0' },
35
+ casey: { seed: 'CASEY-customer-cx', bg: 'fecdd3' },
36
+ };
37
+ function buildPersonaAvatarUrl(personaKey) {
38
+ const data = PERSONA_AVATAR_SEEDS[personaKey] ?? { seed: personaKey, bg: 'f1f5f9' };
39
+ const params = new URLSearchParams({ seed: data.seed, backgroundColor: data.bg, radius: '50' });
40
+ return `https://api.dicebear.com/9.x/notionists/svg?${params.toString()}`;
41
+ }
42
+ const db_service_1 = require("../fraim/db-service");
16
43
  const catalog_1 = require("./catalog");
17
44
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
18
45
  const hosts_1 = require("./hosts");
@@ -327,6 +354,18 @@ class AiHubServer {
327
354
  this.projectPath = options.projectPath || process.cwd();
328
355
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
329
356
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
357
+ if (options.dbService !== undefined) {
358
+ this.dbService = options.dbService;
359
+ }
360
+ else {
361
+ try {
362
+ this.dbService = new db_service_1.FraimDbService();
363
+ }
364
+ catch {
365
+ this.dbService = undefined;
366
+ console.warn('[ai-hub] No dbService — personas will show as locked');
367
+ }
368
+ }
330
369
  this.app.use(express_1.default.json());
331
370
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
332
371
  this.app.get('/health', (_req, res) => {
@@ -338,6 +377,15 @@ class AiHubServer {
338
377
  return this.app;
339
378
  }
340
379
  async start(port) {
380
+ if (this.dbService) {
381
+ try {
382
+ await this.dbService.connect();
383
+ }
384
+ catch (err) {
385
+ console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
386
+ this.dbService = undefined;
387
+ }
388
+ }
341
389
  await new Promise((resolve, reject) => {
342
390
  this.httpServer = this.app.listen(port, '127.0.0.1');
343
391
  this.httpServer.once('listening', () => resolve());
@@ -358,7 +406,7 @@ class AiHubServer {
358
406
  });
359
407
  this.httpServer = undefined;
360
408
  }
361
- bootstrapResponse(projectPath) {
409
+ async bootstrapResponse(projectPath, apiKey) {
362
410
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
363
411
  const employees = this.hostRuntime.detectEmployees();
364
412
  let preferences = this.preferencesStore.load(normalizedProjectPath);
@@ -373,8 +421,13 @@ class AiHubServer {
373
421
  }
374
422
  }
375
423
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
376
- const jobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
424
+ const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
425
+ const jobs = rawJobs.map((job) => ({
426
+ ...job,
427
+ requiredPersonaKey: (0, persona_capability_bundles_1.getProtectedPersonaForJob)(job.id),
428
+ }));
377
429
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
430
+ const { personas, subscriptionActive } = await this.computePersonas(apiKey || preferences.apiKey);
378
431
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
379
432
  // so the bootstrap surface (used on first paint) carries stages and
380
433
  // live totals — not just the raw run state.
@@ -388,15 +441,67 @@ class AiHubServer {
388
441
  jobs,
389
442
  managerTemplates,
390
443
  employees,
444
+ personas,
445
+ subscriptionActive,
391
446
  activeRun,
392
447
  };
393
448
  }
449
+ async computePersonas(apiKey) {
450
+ const allBundles = (0, persona_capability_bundles_1.listPersonaCapabilityBundles)();
451
+ const fallbackPersonas = allBundles.map((bundle) => ({
452
+ key: bundle.personaKey,
453
+ displayName: bundle.catalogMetadata.displayName,
454
+ role: bundle.catalogMetadata.role,
455
+ avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
456
+ pricingLabel: bundle.catalogMetadata.pricingLabel,
457
+ status: 'locked',
458
+ hireUrl: (0, persona_entitlement_service_1.buildPersonaHireUrl)(bundle.personaKey, bundle.defaultHireMode),
459
+ }));
460
+ if (!apiKey || !this.dbService)
461
+ return { personas: fallbackPersonas, subscriptionActive: false };
462
+ try {
463
+ const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(this.dbService, 'anonymous', apiKey);
464
+ const hiredKeys = new Set(state.entitlements
465
+ .filter((e) => e.status === 'active')
466
+ .map((e) => e.personaKey));
467
+ const personas = allBundles.map((bundle) => ({
468
+ key: bundle.personaKey,
469
+ displayName: bundle.catalogMetadata.displayName,
470
+ role: bundle.catalogMetadata.role,
471
+ avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
472
+ pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
473
+ status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
474
+ hireUrl: (0, persona_entitlement_service_1.buildPersonaHireUrl)(bundle.personaKey, bundle.defaultHireMode),
475
+ }));
476
+ return { personas, subscriptionActive: state.subscriptionActive };
477
+ }
478
+ catch (err) {
479
+ console.error('[ai-hub] persona entitlement lookup failed:', err);
480
+ return { personas: fallbackPersonas, subscriptionActive: false };
481
+ }
482
+ }
394
483
  registerRoutes() {
395
- this.app.get('/api/ai-hub/bootstrap', (req, res) => {
484
+ this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
396
485
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
397
486
  ? req.query.projectPath
398
487
  : this.projectPath;
399
- res.json(this.bootstrapResponse(projectPath));
488
+ // Read API key from header — query-param API keys are prohibited (§3.14)
489
+ const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
490
+ res.json(await this.bootstrapResponse(projectPath, apiKey));
491
+ });
492
+ this.app.post('/api/ai-hub/api-key', (req, res) => {
493
+ const { apiKey } = req.body;
494
+ if (!apiKey || typeof apiKey !== 'string')
495
+ return res.status(400).json({ error: 'apiKey required' });
496
+ const prefs = this.preferencesStore.load(this.projectPath);
497
+ this.preferencesStore.save({ ...prefs, apiKey });
498
+ return res.json({ ok: true });
499
+ });
500
+ this.app.post('/api/ai-hub/preferences', (req, res) => {
501
+ const { personaKey } = req.body;
502
+ const prefs = this.preferencesStore.load(this.projectPath);
503
+ this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
504
+ return res.json({ ok: true });
400
505
  });
401
506
  this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
402
507
  try {
@@ -508,6 +613,7 @@ class AiHubServer {
508
613
  phaseHistory: [],
509
614
  totals: emptyTotals(),
510
615
  lastStatusChangeAt: startTimestamp,
616
+ personaKey: (0, persona_capability_bundles_1.getProtectedPersonaForJob)(jobId) ?? null,
511
617
  // Gemini CLI does not emit a session ID in its output stream;
512
618
  // pre-seed one so the Send button is enabled and the continue
513
619
  // endpoint can proceed. continueRun for Gemini ignores it.
@@ -354,38 +354,39 @@ const runAddIDE = async (options) => {
354
354
  else {
355
355
  console.log(chalk_1.default.green('✅ Using existing FRAIM configuration\n'));
356
356
  }
357
- // Detect available IDEs
358
- const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
359
- if (detectedIDEs.length === 0) {
360
- console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
361
- console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
362
- console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
363
- return;
364
- }
365
357
  let idesToConfigure;
366
358
  if (options.ide) {
367
- // Configure specific IDE
359
+ // Explicit IDE requested: configure it regardless of whether it is
360
+ // currently detected. Use the detected path if available (may differ
361
+ // from the default), otherwise fall back to the default so the config
362
+ // can be pre-staged before the IDE is installed.
368
363
  const requestedIDE = (0, ide_detector_1.findIDEByName)(options.ide);
369
364
  if (!requestedIDE) {
370
365
  console.log(chalk_1.default.red(`❌ IDE "${options.ide}" not supported.`));
371
366
  console.log(chalk_1.default.yellow('💡 Use "fraim add-ide --list" to see supported IDEs.'));
372
367
  return;
373
368
  }
369
+ const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
374
370
  const detectedIDE = detectedIDEs.find(ide => ide.name === requestedIDE.name);
375
- if (!detectedIDE) {
376
- console.log(chalk_1.default.red(`❌ ${requestedIDE.name} not found on your system.`));
377
- console.log(chalk_1.default.yellow(`💡 Please install ${requestedIDE.name} and try again.`));
378
- return;
379
- }
380
- idesToConfigure = [detectedIDE];
381
- }
382
- else if (options.all) {
383
- // Configure all detected IDEs
384
- idesToConfigure = detectedIDEs;
371
+ idesToConfigure = [detectedIDE || requestedIDE];
385
372
  }
386
373
  else {
387
- // Interactive selection
388
- idesToConfigure = await promptForIDESelection(detectedIDEs, platformTokens);
374
+ // Auto-detect or interactive selection.
375
+ const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
376
+ if (detectedIDEs.length === 0) {
377
+ console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
378
+ console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
379
+ console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
380
+ return;
381
+ }
382
+ if (options.all) {
383
+ // Configure all detected IDEs
384
+ idesToConfigure = detectedIDEs;
385
+ }
386
+ else {
387
+ // Interactive selection
388
+ idesToConfigure = await promptForIDESelection(detectedIDEs, platformTokens);
389
+ }
389
390
  }
390
391
  if (idesToConfigure.length === 0) {
391
392
  console.log(chalk_1.default.yellow('⚠️ No IDEs selected for configuration.'));
@@ -73,6 +73,9 @@ const resolveManagedCommand = (command) => {
73
73
  if (command !== 'npx') {
74
74
  return command;
75
75
  }
76
- return (0, exports.getPortableNpxCommand)() || (0, exports.getSystemCommandPath)(command) || command;
76
+ // Prefer system-installed npx so we don't install our own Node when the
77
+ // machine already has one. Fall back to the FRAIM-managed portable copy
78
+ // only when no system npx is found. Last resort: bare command name.
79
+ return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableNpxCommand)() || command;
77
80
  };
78
81
  exports.resolveManagedCommand = resolveManagedCommand;
@@ -199,11 +199,23 @@ const findBestConfigPath = (ide) => {
199
199
  // Return default path if nothing found (will be created)
200
200
  return ide.configPath;
201
201
  };
202
+ let _cachedIDEs = null;
203
+ let _cacheTimestamp = 0;
204
+ let _cacheHomeDir = '';
205
+ const DETECT_CACHE_TTL_MS = 5000;
202
206
  const detectInstalledIDEs = () => {
203
- return exports.IDE_CONFIGS.filter(ide => ide.detectMethod()).map(ide => ({
207
+ const now = Date.now();
208
+ const currentHome = os_1.default.homedir();
209
+ if (_cachedIDEs !== null && _cacheHomeDir === currentHome && (now - _cacheTimestamp) < DETECT_CACHE_TTL_MS) {
210
+ return _cachedIDEs;
211
+ }
212
+ _cachedIDEs = exports.IDE_CONFIGS.filter(ide => ide.detectMethod()).map(ide => ({
204
213
  ...ide,
205
214
  configPath: findBestConfigPath(ide)
206
215
  }));
216
+ _cacheTimestamp = now;
217
+ _cacheHomeDir = currentHome;
218
+ return _cachedIDEs;
207
219
  };
208
220
  exports.detectInstalledIDEs = detectInstalledIDEs;
209
221
  const getAllSupportedIDEs = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.144",
3
+ "version": "2.0.146",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -65,6 +65,9 @@
65
65
  <div class="layout">
66
66
  <aside class="rail">
67
67
  <button class="new-conv" type="button" id="new-conv-btn">+ New job</button>
68
+ <!-- R2.4: team roster — horizontal row of avatar chips per hired persona -->
69
+ <div class="team-roster" id="team-roster" hidden></div>
70
+
68
71
  <div class="conv-list" id="conv-list"></div>
69
72
  </aside>
70
73
 
@@ -138,8 +141,18 @@
138
141
  </div>
139
142
  <div class="modal-body">
140
143
  <input class="search" type="text" placeholder="Search jobs…" id="job-search">
144
+ <!-- R3+: persona job filter — only shown when subscription is active -->
145
+ <div id="job-persona-filter" hidden></div>
141
146
  <div id="job-catalog"></div>
142
147
  </div>
148
+ <!-- R3: hire-required notice — shown when a locked job is clicked -->
149
+ <div id="hire-notice" hidden>
150
+ <p id="hire-notice-text"></p>
151
+ <div class="hire-notice-actions">
152
+ <a id="hire-notice-link" href="#" target="_blank" rel="noopener" class="send-button">Go to Pricing →</a>
153
+ <button id="hire-notice-back" type="button" class="ghost">← Back to all jobs</button>
154
+ </div>
155
+ </div>
143
156
  <div class="modal-footer">
144
157
  <span class="left" id="job-pick-status">Choose a job to continue</span>
145
158
  <div class="right">
@@ -20,6 +20,9 @@ const state = {
20
20
  pollHandle: null,
21
21
  selectedJob: null, // chosen in modal step 1
22
22
  selectedEmployeeId: null,
23
+ selectedPersonaKey: null, // R4: null = "All employees"
24
+ modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
+ storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
23
26
  };
24
27
 
25
28
  const els = {};
@@ -32,6 +35,8 @@ function gatherElements() {
32
35
  const ids = [
33
36
  'project-button', 'project-name',
34
37
  'new-conv-btn', 'conv-list',
38
+ // Issue #385: team roster
39
+ 'team-roster',
35
40
  'empty', 'active-conv', 'active-title', 'active-job',
36
41
  'progress', 'stage', 'latest', 'artifact-slot', 'messages',
37
42
  'coach-text', 'send', 'micro-manage', 'micro-log',
@@ -39,6 +44,9 @@ function gatherElements() {
39
44
  'modal', 'step1', 'step2',
40
45
  'cancel1', 'next1', 'back2', 'start',
41
46
  'job-search', 'job-catalog', 'job-pick-status',
47
+ // Issue #385: hire-required notice, persona job filter
48
+ 'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
49
+ 'job-persona-filter',
42
50
  'picked-name', 'picked-desc', 'instructions',
43
51
  'employee-select', 'agent-install-panel', 'active-employee-select',
44
52
  'freeform-btn',
@@ -57,7 +65,8 @@ async function requestJson(url, options) {
57
65
  let payload = null;
58
66
  try { payload = await response.json(); } catch { /* may be empty */ }
59
67
  if (!response.ok) {
60
- const message = (payload && payload.error) || `Request failed (${response.status}).`;
68
+ const errVal = payload && payload.error;
69
+ const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
61
70
  throw new Error(message);
62
71
  }
63
72
  return payload;
@@ -65,10 +74,16 @@ async function requestJson(url, options) {
65
74
 
66
75
  async function loadBootstrap(projectPath) {
67
76
  const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
68
- const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`);
77
+ const fetchOptions = {};
78
+ if (state.storedApiKey) fetchOptions.headers = { 'x-fraim-api-key': state.storedApiKey };
79
+ const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
69
80
  state.bootstrap = bootstrap;
70
81
  state.projectPath = bootstrap.project.path;
71
82
  state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
83
+ // Restore persona selection persisted on the server side.
84
+ if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
85
+ state.selectedPersonaKey = bootstrap.preferences.personaKey;
86
+ }
72
87
  // Render header project name + status line.
73
88
  els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
74
89
  els['status-line'].textContent = bootstrap.project.message || '';
@@ -165,6 +180,17 @@ function loadConversationsFromStorage() {
165
180
  } catch {
166
181
  state.activeId = null;
167
182
  }
183
+ // Retroactively fix titles that were saved as the generic "New job" fallback
184
+ // by re-deriving from the first manager message.
185
+ for (const convList of Object.values(state.conversations)) {
186
+ for (const conv of convList) {
187
+ if (conv.title !== 'New job') continue;
188
+ const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
189
+ if (!firstMsg) continue;
190
+ const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
191
+ if (rederived !== 'New job') conv.title = rederived;
192
+ }
193
+ }
168
194
  }
169
195
 
170
196
  function persistConversations() {
@@ -221,19 +247,60 @@ function newConversationId() {
221
247
  // ---------------------------------------------------------------------------
222
248
 
223
249
  function renderRail() {
250
+ renderTeamRoster();
224
251
  els['conv-list'].innerHTML = '';
225
- const list = projectConversations();
252
+ // R4: filter by selected persona when one is active.
253
+ const list = projectConversations().filter((conv) =>
254
+ !state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey
255
+ );
226
256
  for (const conv of list) {
227
257
  const btn = document.createElement('button');
228
258
  btn.type = 'button';
229
259
  btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
230
260
  btn.dataset.conv = conv.id;
231
- btn.innerHTML = `<span class="conv-title"></span><span class="conv-status"></span>`;
232
- btn.querySelector('.conv-title').textContent = conv.title;
233
- const status = btn.querySelector('.conv-status');
234
- status.textContent = statusLabel(conv.status);
235
- if (conv.status === 'running') status.classList.add('running');
236
- if (conv.status === 'failed') status.classList.add('failed');
261
+ // Avatar chip 34px visual anchor on the left for every item.
262
+ const personas = state.bootstrap?.personas || [];
263
+ let persona = null;
264
+ const chip = document.createElement('span');
265
+ if (conv.personaKey) {
266
+ persona = personas.find((p) => p.key === conv.personaKey) || null;
267
+ chip.className = 'conv-persona-chip';
268
+ chip.title = persona ? persona.displayName : conv.personaKey;
269
+ if (persona && persona.avatarUrl) {
270
+ const img = document.createElement('img');
271
+ img.src = persona.avatarUrl;
272
+ img.alt = persona.displayName;
273
+ chip.appendChild(img);
274
+ } else {
275
+ const label = persona ? persona.displayName : conv.personaKey;
276
+ chip.textContent = label.slice(0, 2).toUpperCase();
277
+ }
278
+ } else {
279
+ chip.className = 'conv-persona-chip conv-persona-chip--free';
280
+ chip.title = 'Free job';
281
+ chip.innerHTML = '<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-3.314 3.134-6 7-6s7 2.686 7 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
282
+ }
283
+ btn.appendChild(chip);
284
+ // Text body: persona name label (if persona) stacked above job title.
285
+ const bodyDiv = document.createElement('span');
286
+ bodyDiv.className = 'conv-body';
287
+ if (persona) {
288
+ const nameSpan = document.createElement('span');
289
+ nameSpan.className = 'conv-persona-name';
290
+ nameSpan.textContent = persona.displayName;
291
+ bodyDiv.appendChild(nameSpan);
292
+ }
293
+ const titleSpan = document.createElement('span');
294
+ titleSpan.className = 'conv-title';
295
+ titleSpan.textContent = conv.title || '';
296
+ bodyDiv.appendChild(titleSpan);
297
+ btn.appendChild(bodyDiv);
298
+ const statusSpan = document.createElement('span');
299
+ statusSpan.className = 'conv-status';
300
+ statusSpan.textContent = statusLabel(conv.status);
301
+ if (conv.status === 'running') statusSpan.classList.add('running');
302
+ if (conv.status === 'failed') statusSpan.classList.add('failed');
303
+ btn.appendChild(statusSpan);
237
304
  btn.addEventListener('click', () => switchToConversation(conv.id));
238
305
  els['conv-list'].appendChild(btn);
239
306
  }
@@ -245,6 +312,169 @@ function statusLabel(s) {
245
312
  return 'Done';
246
313
  }
247
314
 
315
+ // ---------------------------------------------------------------------------
316
+ // Issue #385 — Persona UI (R3 + R4)
317
+ // ---------------------------------------------------------------------------
318
+
319
+ // Prefix a conversation title with its persona display name when a persona
320
+ // is assigned, so the rail and header always show who is responsible.
321
+ function conversationTitle(conv) {
322
+ if (!conv) return '';
323
+ if (!conv.personaKey) return conv.title || '';
324
+ const personas = state.bootstrap?.personas || [];
325
+ const persona = personas.find((p) => p.key === conv.personaKey);
326
+ const prefix = persona ? persona.displayName : conv.personaKey;
327
+ return `${prefix}: ${conv.title || ''}`;
328
+ }
329
+
330
+ // R4.2 — team roster: one avatar chip per hired persona above the conv list.
331
+ // Only rendered when at least one persona is hired (subscription active).
332
+ function renderTeamRoster() {
333
+ const roster = els['team-roster'];
334
+ if (!roster) return;
335
+ const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
336
+ if (personas.length === 0) {
337
+ roster.hidden = true;
338
+ // Reset persona selection when there's no active subscription.
339
+ if (state.selectedPersonaKey) state.selectedPersonaKey = null;
340
+ return;
341
+ }
342
+ roster.hidden = false;
343
+ roster.innerHTML = '';
344
+ const allChip = document.createElement('button');
345
+ allChip.type = 'button';
346
+ allChip.className = 'roster-chip' + (!state.selectedPersonaKey ? ' active' : '');
347
+ allChip.title = 'All employees';
348
+ allChip.textContent = 'All';
349
+ allChip.addEventListener('click', () => setSelectedPersona(null));
350
+ roster.appendChild(allChip);
351
+ for (const persona of personas) {
352
+ const chip = document.createElement('button');
353
+ chip.type = 'button';
354
+ chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
355
+ chip.title = persona.displayName;
356
+ chip.setAttribute('aria-label', persona.displayName);
357
+ if (persona.avatarUrl) {
358
+ const img = document.createElement('img');
359
+ img.src = persona.avatarUrl;
360
+ img.alt = persona.displayName;
361
+ chip.appendChild(img);
362
+ } else {
363
+ chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
364
+ }
365
+ chip.addEventListener('click', () => setSelectedPersona(persona.key));
366
+ roster.appendChild(chip);
367
+ }
368
+ }
369
+
370
+ // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
371
+ function buildAvatarChip(persona, size) {
372
+ const chip = document.createElement('span');
373
+ chip.className = 'emp-avatar-sm';
374
+ chip.style.width = size + 'px';
375
+ chip.style.height = size + 'px';
376
+ chip.style.fontSize = Math.max(8, Math.floor(size * 0.42)) + 'px';
377
+ if (persona && persona.avatarUrl) {
378
+ const img = document.createElement('img');
379
+ img.src = persona.avatarUrl;
380
+ img.alt = persona.displayName;
381
+ chip.appendChild(img);
382
+ } else if (persona) {
383
+ chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
384
+ }
385
+ return chip;
386
+ }
387
+
388
+ function setSelectedPersona(key) {
389
+ state.selectedPersonaKey = key || null;
390
+ renderRail();
391
+ savePersonaPreference(key || null);
392
+ }
393
+
394
+ async function savePersonaPreference(key) {
395
+ try {
396
+ await requestJson('/api/ai-hub/preferences', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: JSON.stringify({ personaKey: key }),
400
+ });
401
+ } catch { /* best-effort */ }
402
+ }
403
+
404
+ // R3.2/R3.3 — show hire-required notice when a locked job is clicked.
405
+ function showHireNotice(job) {
406
+ const personas = state.bootstrap?.personas || [];
407
+ const persona = personas.find((p) => p.key === job.requiredPersonaKey);
408
+ els['job-catalog'].hidden = true;
409
+ els['hire-notice'].hidden = false;
410
+ if (els['hire-notice-text']) {
411
+ els['hire-notice-text'].textContent = persona
412
+ ? `"${job.title}" requires ${persona.displayName} (${persona.pricingLabel}). Hire them to unlock this job.`
413
+ : `"${job.title}" requires a persona that is not yet hired. Go to Pricing to hire them.`;
414
+ }
415
+ if (els['hire-notice-link'] && persona && persona.hireUrl) {
416
+ // R3.3: append returnTo so the pricing page can redirect back after hire.
417
+ const returnTo = encodeURIComponent(window.location.href);
418
+ els['hire-notice-link'].href = `${persona.hireUrl}&returnTo=${returnTo}`;
419
+ }
420
+ state.selectedJob = null;
421
+ els['next1'].disabled = true;
422
+ els['job-pick-status'].textContent = 'Choose a job to continue';
423
+ }
424
+
425
+ function hideHireNotice() {
426
+ if (els['hire-notice']) els['hire-notice'].hidden = true;
427
+ if (els['job-catalog']) els['job-catalog'].hidden = false;
428
+ }
429
+
430
+ // R3+ — persona filter bar inside the New Job modal.
431
+ // Shows "All jobs" + one chip per hired persona + "Free only".
432
+ // Only rendered when at least one persona is hired (subscription active).
433
+ function renderModalPersonaFilter() {
434
+ const container = els['job-persona-filter'];
435
+ if (!container) return;
436
+ const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
437
+ if (personas.length === 0) {
438
+ container.hidden = true;
439
+ state.modalPersonaFilter = null;
440
+ return;
441
+ }
442
+ container.hidden = false;
443
+ container.innerHTML = '';
444
+
445
+ function makeFilterChip(key, label, avatarUrl) {
446
+ const btn = document.createElement('button');
447
+ btn.type = 'button';
448
+ btn.className = 'jf-chip' + (state.modalPersonaFilter === key ? ' active' : '');
449
+ if (avatarUrl) {
450
+ const img = document.createElement('img');
451
+ img.src = avatarUrl;
452
+ img.alt = label;
453
+ btn.appendChild(img);
454
+ }
455
+ const span = document.createElement('span');
456
+ span.textContent = label;
457
+ btn.appendChild(span);
458
+ btn.addEventListener('click', () => {
459
+ state.modalPersonaFilter = key;
460
+ // Reset job selection when filter changes.
461
+ state.selectedJob = null;
462
+ els['next1'].disabled = true;
463
+ els['job-pick-status'].textContent = 'Choose a job to continue';
464
+ hideHireNotice();
465
+ renderModalPersonaFilter();
466
+ renderJobCatalog(els['job-search'].value);
467
+ });
468
+ return btn;
469
+ }
470
+
471
+ container.appendChild(makeFilterChip(null, 'All jobs'));
472
+ for (const persona of personas) {
473
+ container.appendChild(makeFilterChip(persona.key, persona.displayName, persona.avatarUrl));
474
+ }
475
+ container.appendChild(makeFilterChip('__free__', 'Free only'));
476
+ }
477
+
248
478
  // ---------------------------------------------------------------------------
249
479
  // Active conversation rendering
250
480
  // ---------------------------------------------------------------------------
@@ -271,7 +501,7 @@ function renderActive() {
271
501
  }
272
502
  els['empty'].hidden = true;
273
503
  els['active-conv'].hidden = false;
274
- els['active-title'].textContent = conv.title;
504
+ els['active-title'].textContent = conversationTitle(conv);
275
505
  els['active-job'].textContent = `Job: ${conv.jobTitle}`;
276
506
 
277
507
  // Progress section. Plain text updates are cheap and don't animate.
@@ -806,6 +1036,8 @@ function openModal(opts) {
806
1036
  const filter = concept ? CONCEPT_PICKER_FILTERS[concept] : null;
807
1037
  state.selectedJob = null;
808
1038
  state.activeFilter = filter || null;
1039
+ // Inherit the rail persona selection as the starting filter for the modal.
1040
+ state.modalPersonaFilter = state.selectedPersonaKey || null;
809
1041
  els['next1'].disabled = true;
810
1042
  els['job-pick-status'].textContent = 'Choose a job to continue';
811
1043
  els['job-search'].value = '';
@@ -813,6 +1045,9 @@ function openModal(opts) {
813
1045
  els['start'].disabled = true;
814
1046
  els['step1'].hidden = false;
815
1047
  els['step2'].hidden = true;
1048
+ // R3: always start with the catalog visible, hire-notice hidden.
1049
+ if (els['hire-notice']) els['hire-notice'].hidden = true;
1050
+ if (els['job-catalog']) els['job-catalog'].hidden = false;
816
1051
  // Update the modal heading + subhead to match the filter context.
817
1052
  const h = document.querySelector('#step1 .modal-header h2');
818
1053
  const p = document.querySelector('#step1 .modal-header p');
@@ -826,6 +1061,7 @@ function openModal(opts) {
826
1061
  p.textContent = 'Pick one job. You can always start a new job for different work.';
827
1062
  }
828
1063
  renderEmployeeSelect();
1064
+ renderModalPersonaFilter();
829
1065
  renderJobCatalog();
830
1066
  els['modal'].hidden = false;
831
1067
  els['modal'].classList.add('open');
@@ -850,8 +1086,10 @@ function renderJobCatalog(searchTerm = '') {
850
1086
  const managers = state.bootstrap?.managerTemplates || [];
851
1087
 
852
1088
  // Decide which sources contribute given the active filter.
1089
+ // Manager templates are hidden whenever a persona/free chip is active —
1090
+ // they're meta-management jobs, not persona-specific work.
853
1091
  const showEmployee = !filter || filter.kind === 'employee';
854
- const showManager = !filter || filter.kind === 'manager';
1092
+ const showManager = state.modalPersonaFilter == null && (!filter || filter.kind === 'manager');
855
1093
 
856
1094
  // Group employees by category.
857
1095
  const employeeGroups = [];
@@ -860,7 +1098,13 @@ function renderJobCatalog(searchTerm = '') {
860
1098
  for (const cat of cats) {
861
1099
  const rows = employees.filter((j) =>
862
1100
  j.categoryId === cat.id &&
863
- (!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f))
1101
+ (!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f)) &&
1102
+ // R3+: modal persona filter; null = All, '__free__' = free jobs only,
1103
+ // personaKey = only that persona's jobs (free jobs excluded).
1104
+ (state.modalPersonaFilter == null ||
1105
+ (state.modalPersonaFilter === '__free__'
1106
+ ? j.requiredPersonaKey == null
1107
+ : j.requiredPersonaKey === state.modalPersonaFilter))
864
1108
  );
865
1109
  if (rows.length > 0) employeeGroups.push({ label: cat.label, items: rows });
866
1110
  }
@@ -885,11 +1129,22 @@ function renderJobCatalog(searchTerm = '') {
885
1129
  const h4 = document.createElement('h4');
886
1130
  h4.textContent = label;
887
1131
  wrap.appendChild(h4);
1132
+ const personas = state.bootstrap?.personas || [];
1133
+ // Lock enforcement is only active when the workspace has an active subscription.
1134
+ // Legacy workspaces (subscriptionActive = false) see the full catalog with no locks.
1135
+ const personaSystemActive = state.bootstrap?.subscriptionActive ?? false;
888
1136
  for (const job of items) {
889
1137
  total += 1;
1138
+ // R3.1: a job is locked when it requires a persona with status 'locked',
1139
+ // but only when the persona system is active (subscription present).
1140
+ const isLocked = personaSystemActive && job.requiredPersonaKey != null && personas.some(
1141
+ (p) => p.key === job.requiredPersonaKey && p.status === 'locked'
1142
+ );
890
1143
  const btn = document.createElement('button');
891
1144
  btn.type = 'button';
892
- btn.className = 'job-option' + (state.selectedJob?.id === job.id ? ' selected' : '');
1145
+ btn.className = 'job-option' +
1146
+ (isLocked ? ' locked' : '') +
1147
+ (state.selectedJob?.id === job.id ? ' selected' : '');
893
1148
  btn.dataset.jobId = job.id;
894
1149
  const strong = document.createElement('strong');
895
1150
  strong.textContent = job.title;
@@ -897,7 +1152,20 @@ function renderJobCatalog(searchTerm = '') {
897
1152
  span.textContent = job.intent || '';
898
1153
  btn.appendChild(strong);
899
1154
  btn.appendChild(span);
1155
+ if (isLocked) {
1156
+ // R3.1: inline lock badge showing persona display name.
1157
+ const lockBadge = document.createElement('span');
1158
+ lockBadge.className = 'lock-badge';
1159
+ const persona = personas.find((p) => p.key === job.requiredPersonaKey);
1160
+ lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
1161
+ btn.appendChild(lockBadge);
1162
+ }
900
1163
  btn.addEventListener('click', () => {
1164
+ if (isLocked) {
1165
+ // R3.2: locked job click shows hire-required notice.
1166
+ showHireNotice(job);
1167
+ return;
1168
+ }
901
1169
  state.selectedJob = job;
902
1170
  els['next1'].disabled = false;
903
1171
  els['job-pick-status'].textContent = `Selected: ${job.title}`;
@@ -1093,13 +1361,20 @@ async function refreshEmployees() {
1093
1361
  // string, never just the instructions slice.
1094
1362
  function deriveTitle(jobTitle, instructions) {
1095
1363
  const trimmedJob = (jobTitle || '').trim();
1096
- const stripped = (instructions || '')
1097
- .trim()
1364
+ const raw = (instructions || '').trim();
1365
+ // Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
1366
+ const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
1367
+ const stripped = raw
1098
1368
  .replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
1099
1369
  .replace(/\s+/g, ' ')
1100
1370
  .trim();
1101
1371
  const words = stripped.split(' ').filter(Boolean).slice(0, 6);
1102
1372
  const intent = words.join(' ');
1373
+ // When instructions are solely a FRAIM invocation, the slug IS the job — use it
1374
+ // regardless of jobTitle (which may be the generic "Freeform task" placeholder).
1375
+ if (!intent && fraimSlugMatch) {
1376
+ return fraimSlugMatch[1].split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1377
+ }
1103
1378
  if (!trimmedJob && !intent) return 'New job';
1104
1379
  if (!intent) return trimmedJob;
1105
1380
  // If the job title and the start of the instructions happen to be the
@@ -1177,6 +1452,8 @@ async function startRun(job, instructions, employeeId) {
1177
1452
  jobId: job.id,
1178
1453
  jobTitle: job.title,
1179
1454
  employeeId,
1455
+ // R4: assign the persona key for this job (null for free jobs or freeform).
1456
+ personaKey: (job.requiredPersonaKey != null ? job.requiredPersonaKey : null),
1180
1457
  runId: null,
1181
1458
  sessionId: null,
1182
1459
  status: 'running',
@@ -1310,6 +1587,10 @@ function foldRunIntoConversation(conv, run) {
1310
1587
  }
1311
1588
  // Track session for resumption.
1312
1589
  if (run.sessionId) conv.sessionId = run.sessionId;
1590
+ // R4: persist the persona key from the server-side run record.
1591
+ if (run.personaKey !== undefined && conv.personaKey == null) {
1592
+ conv.personaKey = run.personaKey;
1593
+ }
1313
1594
  // Update status.
1314
1595
  if (run.status === 'completed') conv.status = 'completed';
1315
1596
  else if (run.status === 'failed') conv.status = 'failed';
@@ -1438,6 +1719,10 @@ function wireEvents() {
1438
1719
  els['project-button'].addEventListener('click', pickProject);
1439
1720
  els['new-conv-btn'].addEventListener('click', openModal);
1440
1721
  els['cancel1'].addEventListener('click', closeModal);
1722
+ // Issue #385: hire-required notice back button.
1723
+ if (els['hire-notice-back']) {
1724
+ els['hire-notice-back'].addEventListener('click', hideHireNotice);
1725
+ }
1441
1726
  els['back2'].addEventListener('click', () => {
1442
1727
  els['step1'].hidden = false;
1443
1728
  els['step2'].hidden = true;
@@ -227,11 +227,11 @@ button { font: inherit; cursor: pointer; }
227
227
  background: transparent;
228
228
  border: 1px solid transparent;
229
229
  border-radius: 10px;
230
- padding: 10px 12px;
230
+ padding: 8px 10px;
231
231
  display: flex;
232
232
  align-items: center;
233
233
  justify-content: space-between;
234
- gap: 8px;
234
+ gap: 10px;
235
235
  color: var(--text);
236
236
  }
237
237
  .conv-item:hover { background: var(--soft); }
@@ -240,9 +240,47 @@ button { font: inherit; cursor: pointer; }
240
240
  border-color: var(--line);
241
241
  box-shadow: var(--shadow);
242
242
  }
243
- .conv-title { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
243
+ /* Text column: stacks persona name label above job title */
244
+ .conv-body {
245
+ flex: 1;
246
+ min-width: 0;
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 1px;
250
+ }
251
+ .conv-persona-name {
252
+ font-size: 10px;
253
+ font-weight: 700;
254
+ color: var(--accent-strong);
255
+ letter-spacing: 0.06em;
256
+ text-transform: uppercase;
257
+ white-space: nowrap;
258
+ overflow: hidden;
259
+ text-overflow: ellipsis;
260
+ }
261
+ .conv-title { font-size: 13px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
262
+ /* Persona avatar chip — enlarged to 34px to serve as a clear visual anchor */
263
+ .conv-persona-chip {
264
+ flex-shrink: 0;
265
+ width: 34px;
266
+ height: 34px;
267
+ border-radius: 50%;
268
+ background: var(--accent-soft);
269
+ border: 1.5px solid var(--accent);
270
+ display: flex;
271
+ align-items: center;
272
+ justify-content: center;
273
+ font-size: 11px;
274
+ font-weight: 700;
275
+ color: var(--accent-strong);
276
+ overflow: hidden;
277
+ line-height: 1;
278
+ text-transform: uppercase;
279
+ }
280
+ .conv-persona-chip img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
281
+ .conv-persona-chip--free { background: transparent; border-color: var(--line); color: var(--muted); }
244
282
  .conv-status {
245
- font-size: 12px;
283
+ font-size: 11px;
246
284
  color: var(--muted);
247
285
  font-weight: 500;
248
286
  flex-shrink: 0;
@@ -912,3 +950,160 @@ button.small { padding: 4px 10px; font-size: 12px; }
912
950
  .totals span { cursor: help; }
913
951
  .totals .sep { color: var(--line); }
914
952
  .totals strong { color: var(--text); font-weight: 600; }
953
+
954
+
955
+ /* ── Issue #385: Team roster, Employee selector, Lock badges, Hire notice ── */
956
+
957
+ /* Team roster — horizontal chip row above conv list */
958
+ .team-roster {
959
+ display: flex;
960
+ align-items: center;
961
+ gap: 6px;
962
+ padding: 8px 10px 4px;
963
+ flex-wrap: wrap;
964
+ }
965
+ .roster-chip {
966
+ display: flex;
967
+ align-items: center;
968
+ justify-content: center;
969
+ width: 36px;
970
+ height: 36px;
971
+ border-radius: 50%;
972
+ background: var(--accent-soft);
973
+ border: 2px solid var(--line);
974
+ font-size: 11px;
975
+ font-weight: 700;
976
+ color: var(--accent-strong);
977
+ cursor: pointer;
978
+ overflow: hidden;
979
+ flex-shrink: 0;
980
+ }
981
+ .roster-chip.active { border-color: var(--accent); }
982
+ .roster-chip img { width: 100%; height: 100%; object-fit: cover; }
983
+ .roster-chip-add {
984
+ width: 36px;
985
+ height: 36px;
986
+ border-radius: 50%;
987
+ border: 2px dashed var(--line);
988
+ background: none;
989
+ font-size: 18px;
990
+ color: var(--muted);
991
+ cursor: pointer;
992
+ display: flex;
993
+ align-items: center;
994
+ justify-content: center;
995
+ text-decoration: none;
996
+ }
997
+ .roster-chip-add:hover { border-color: var(--accent); color: var(--accent); }
998
+
999
+ /* Employee selector */
1000
+ .employee-selector { padding: 4px 8px; position: relative; }
1001
+ .emp-select-btn {
1002
+ display: flex;
1003
+ align-items: center;
1004
+ gap: 6px;
1005
+ width: 100%;
1006
+ background: var(--soft);
1007
+ border: 1px solid var(--line);
1008
+ border-radius: 8px;
1009
+ padding: 7px 10px;
1010
+ font: inherit;
1011
+ font-size: 13px;
1012
+ color: var(--text);
1013
+ cursor: pointer;
1014
+ text-align: left;
1015
+ }
1016
+ .emp-select-btn:hover { background: var(--accent-soft); border-color: var(--accent); }
1017
+ .emp-avatar-sm {
1018
+ width: 20px;
1019
+ height: 20px;
1020
+ border-radius: 50%;
1021
+ background: var(--accent-soft);
1022
+ font-size: 9px;
1023
+ font-weight: 700;
1024
+ color: var(--accent-strong);
1025
+ display: inline-flex;
1026
+ align-items: center;
1027
+ justify-content: center;
1028
+ flex-shrink: 0;
1029
+ overflow: hidden;
1030
+ }
1031
+ .emp-avatar-sm img { width: 100%; height: 100%; object-fit: cover; }
1032
+ .emp-caret { margin-left: auto; color: var(--muted); font-size: 11px; }
1033
+ .emp-dropdown {
1034
+ position: absolute;
1035
+ top: calc(100% + 2px);
1036
+ left: 8px;
1037
+ right: 8px;
1038
+ background: var(--surface);
1039
+ border: 1px solid var(--line);
1040
+ border-radius: 8px;
1041
+ box-shadow: var(--shadow-lg);
1042
+ z-index: 100;
1043
+ overflow: hidden;
1044
+ }
1045
+ .emp-option {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ gap: 8px;
1049
+ padding: 8px 12px;
1050
+ font-size: 13px;
1051
+ color: var(--text);
1052
+ cursor: pointer;
1053
+ background: none;
1054
+ border: none;
1055
+ width: 100%;
1056
+ text-align: left;
1057
+ font: inherit;
1058
+ }
1059
+ .emp-option:hover { background: var(--soft); }
1060
+ .emp-option.active { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
1061
+
1062
+ /* Lock badge — inline after job title */
1063
+ .lock-badge {
1064
+ font-size: 11px;
1065
+ font-weight: 600;
1066
+ color: var(--warn);
1067
+ margin-left: 6px;
1068
+ white-space: nowrap;
1069
+ }
1070
+ .job-option.locked { opacity: 0.85; }
1071
+ .job-option.locked:hover { background: var(--warn-soft) !important; }
1072
+
1073
+ /* Persona job filter bar inside New Job modal */
1074
+ #job-persona-filter {
1075
+ display: flex;
1076
+ flex-wrap: wrap;
1077
+ gap: 6px;
1078
+ margin-bottom: 12px;
1079
+ }
1080
+ .jf-chip {
1081
+ display: inline-flex;
1082
+ align-items: center;
1083
+ gap: 5px;
1084
+ padding: 4px 10px 4px 6px;
1085
+ border: 1.5px solid var(--line);
1086
+ border-radius: 20px;
1087
+ background: transparent;
1088
+ cursor: pointer;
1089
+ font-size: 13px;
1090
+ font-weight: 500;
1091
+ color: var(--text);
1092
+ transition: border-color 0.15s, background 0.15s;
1093
+ white-space: nowrap;
1094
+ }
1095
+ .jf-chip:hover { border-color: var(--accent); }
1096
+ .jf-chip.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
1097
+ .jf-chip img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
1098
+
1099
+ /* Hire-required notice */
1100
+ #hire-notice {
1101
+ margin: 0 0 0;
1102
+ border-left: 3px solid var(--warn);
1103
+ border-radius: 8px;
1104
+ background: var(--warn-soft);
1105
+ padding: 16px 18px;
1106
+ margin: 0 0 12px;
1107
+ }
1108
+ #hire-notice p { margin: 0 0 12px; font-size: 14px; color: var(--text); }
1109
+ .hire-notice-actions { display: flex; align-items: center; gap: 10px; }