fraim-framework 2.0.145 → 2.0.147

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
  }
@@ -253,22 +253,40 @@ function detectEmployees() {
253
253
  };
254
254
  });
255
255
  }
256
- // Rewrite a /fraim or $fraim job invocation to a direct MCP tool call instruction.
257
- // Gemini CLI has a /fraim command but it conflicts when both workspace-level and
258
- // user-level fraim.toml commands exist (Gemini renames both), making /fraim
259
- // unrecognised. Sending an explicit get_fraim_job instruction bypasses the slash
260
- // command entirely and works regardless of whether the command exists.
261
- function transformGeminiMessage(message) {
262
- const match = message.match(/^[/$]fraim\s+(\S+)\n?([\s\S]*)$/);
256
+ function parseFraimInvocation(message) {
257
+ const trimmed = message.trim();
258
+ if (!trimmed)
259
+ return null;
260
+ const match = trimmed.match(/^[/$]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
263
261
  if (!match)
262
+ return null;
263
+ return {
264
+ jobId: match[1] || null,
265
+ remainder: (match[2] || '').trim(),
266
+ };
267
+ }
268
+ // Rewrite UI-facing /fraim or $fraim invocations into direct MCP tool
269
+ // instructions for headless hosts. The Hub still shows the familiar
270
+ // invocation in the manager timeline, but the actual CLI child process
271
+ // receives an explicit instruction that works in non-interactive mode.
272
+ function transformHeadlessFraimMessage(message, kind) {
273
+ const parsed = parseFraimInvocation(message);
274
+ if (!parsed)
275
+ return message;
276
+ if (kind === 'continue') {
277
+ if (parsed.remainder) {
278
+ return `Continue the active FRAIM job with this manager coaching:\n\n${parsed.remainder}`;
279
+ }
280
+ return 'Continue the active FRAIM job using the current session context.';
281
+ }
282
+ if (!parsed.jobId)
264
283
  return message;
265
- const jobId = match[1];
266
- const instructions = match[2].trim();
267
284
  const parts = [
268
- `Call the get_fraim_job MCP tool with job "${jobId}" to get the full job instructions, then follow them exactly.`,
285
+ `Call the get_fraim_job MCP tool with job "${parsed.jobId}" to get the full job instructions, then follow them exactly.`,
269
286
  ];
270
- if (instructions)
271
- parts.push(`\n\nUser instructions: ${instructions}`);
287
+ if (parsed.remainder) {
288
+ parts.push(`\n\nUser instructions: ${parsed.remainder}`);
289
+ }
272
290
  return parts.join('');
273
291
  }
274
292
  // If ~/.gemini/settings.json has a wrong/test FRAIM_API_KEY, patch it with the
@@ -301,7 +319,7 @@ function buildStartPlan(hostId, message) {
301
319
  return {
302
320
  command: executableName('codex'),
303
321
  args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'],
304
- stdin: message,
322
+ stdin: transformHeadlessFraimMessage(message, 'start'),
305
323
  };
306
324
  }
307
325
  if (hostId === 'gemini') {
@@ -309,13 +327,13 @@ function buildStartPlan(hostId, message) {
309
327
  return {
310
328
  command: executableName('gemini'),
311
329
  args: ['--yolo', '--skip-trust'],
312
- stdin: transformGeminiMessage(message),
330
+ stdin: transformHeadlessFraimMessage(message, 'start'),
313
331
  };
314
332
  }
315
333
  return {
316
334
  command: executableName('claude'),
317
335
  args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'],
318
- stdin: message,
336
+ stdin: transformHeadlessFraimMessage(message, 'start'),
319
337
  };
320
338
  }
321
339
  function buildContinuePlan(hostId, sessionId, message) {
@@ -323,7 +341,7 @@ function buildContinuePlan(hostId, sessionId, message) {
323
341
  return {
324
342
  command: executableName('codex'),
325
343
  args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId],
326
- stdin: message,
344
+ stdin: transformHeadlessFraimMessage(message, 'continue'),
327
345
  };
328
346
  }
329
347
  if (hostId === 'gemini') {
@@ -332,13 +350,13 @@ function buildContinuePlan(hostId, sessionId, message) {
332
350
  return {
333
351
  command: executableName('gemini'),
334
352
  args: ['--yolo', '--skip-trust'],
335
- stdin: message,
353
+ stdin: transformHeadlessFraimMessage(message, 'continue'),
336
354
  };
337
355
  }
338
356
  return {
339
357
  command: executableName('claude'),
340
358
  args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '-r', sessionId],
341
- stdin: message,
359
+ stdin: transformHeadlessFraimMessage(message, 'continue'),
342
360
  };
343
361
  }
344
362
  function parseHostLine(hostId, line) {
@@ -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.'));
@@ -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.145",
3
+ "version": "2.0.147",
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": {
@@ -11,13 +11,16 @@
11
11
 
12
12
  <div class="page">
13
13
 
14
- <header class="header">
15
- <h1>AI Hub</h1>
16
- <button class="project-button" type="button" id="project-button">
17
- <span class="folder">Project</span>
18
- <strong id="project-name">Choose a folder</strong>
19
- </button>
20
- </header>
14
+ <header class="header">
15
+ <div class="header-copy">
16
+ <span class="header-eyebrow">FRAIM Hub</span>
17
+ <h1>AI Hub</h1>
18
+ </div>
19
+ <button class="project-button" type="button" id="project-button">
20
+ <span class="folder">Project</span>
21
+ <strong id="project-name">Choose a folder</strong>
22
+ </button>
23
+ </header>
21
24
 
22
25
  <section class="welcome">
23
26
  Hi <strong class="you">there</strong>, remember, you are the
@@ -63,10 +66,20 @@
63
66
  </section>
64
67
 
65
68
  <div class="layout">
66
- <aside class="rail">
67
- <button class="new-conv" type="button" id="new-conv-btn">+ New job</button>
68
- <div class="conv-list" id="conv-list"></div>
69
- </aside>
69
+ <aside class="rail">
70
+ <button class="new-conv" type="button" id="new-conv-btn">+ New job</button>
71
+ <div class="rail-note">Alpha: browser shell for directing employees across your project.</div>
72
+ <!-- R2.4: team roster — horizontal row of avatar chips per hired persona -->
73
+ <section class="rail-section rail-section--employees">
74
+ <div class="rail-section-label">Hired employees</div>
75
+ <div class="team-roster" id="team-roster" hidden></div>
76
+ </section>
77
+
78
+ <section class="rail-section rail-section--runs">
79
+ <div class="rail-section-label">Runs</div>
80
+ <div class="conv-list" id="conv-list"></div>
81
+ </section>
82
+ </aside>
70
83
 
71
84
  <main class="conversation" id="conversation">
72
85
  <div class="empty-state" id="empty">
@@ -74,16 +87,25 @@
74
87
  <p>Pick an existing job from the left, or click <strong>+ New job</strong> to give your employee something to work on.</p>
75
88
  </div>
76
89
 
77
- <div id="active-conv" hidden>
78
- <div class="conv-header">
79
- <h2 id="active-title"></h2>
80
- <div class="conv-job" id="active-job"></div>
81
- <div id="artifact-slot"></div>
82
- </div>
83
-
84
- <!-- Issue #347 R1: pizza tracker. Hidden when the active job
85
- declares no phases. Populated by renderTracker() in script.js. -->
86
- <div class="tracker" id="tracker" aria-label="Job progress" hidden>
90
+ <div id="active-conv" hidden>
91
+ <div class="conv-topline">
92
+ <div class="employee-identity" id="active-identity"></div>
93
+ <div class="run-state-pill" id="run-state-pill"></div>
94
+ </div>
95
+
96
+ <div class="conv-header">
97
+ <div class="title-block">
98
+ <h2 id="active-title"></h2>
99
+ <div class="conv-job" id="active-job"></div>
100
+ </div>
101
+ <div id="artifact-slot"></div>
102
+ </div>
103
+
104
+ <div class="summary-strip" id="summary-strip"></div>
105
+
106
+ <!-- Issue #347 R1: pizza tracker. Hidden when the active job
107
+ declares no phases. Populated by renderTracker() in script.js. -->
108
+ <div class="tracker" id="tracker" aria-label="Job progress" hidden>
87
109
  <div class="tracker-rows" id="tracker-rows"></div>
88
110
  <div class="tracker-note" id="tracker-note" hidden></div>
89
111
  </div>
@@ -93,28 +115,32 @@
93
115
  <span class="latest" id="latest"></span>
94
116
  </div>
95
117
 
96
- <div class="messages" id="messages"></div>
118
+ <section class="thread-surface" aria-label="Manager and employee thread">
119
+ <div class="thread-surface-label">Manager and employee thread</div>
120
+ <div class="messages" id="messages"></div>
121
+ </section>
97
122
 
98
123
  <div class="coach">
99
- <div class="coach-title-row">
100
- <span class="section-title">Coach the employee</span>
101
- <span class="active-employee-row">
102
- <label for="active-employee-select" class="active-employee-label">via</label>
103
- <select id="active-employee-select" class="employee-select inline"></select>
104
- </span>
105
- </div>
106
- <textarea id="coach-text" placeholder="Tell the employee what to do next…"></textarea>
107
- <div class="coach-actions">
108
- <!-- Issue #347 R2: template picker. Hidden when the project
109
- has no manager-job templates. -->
110
- <button class="ghost" type="button" id="template-picker-btn" aria-haspopup="menu" aria-expanded="false" hidden>Use a template ▾</button>
111
- <button class="send-button" type="button" id="send" disabled>Send</button>
112
- <div class="template-popover" id="template-popover" role="menu" hidden></div>
113
- </div>
114
- <!-- Issue #347 R4: run-level totals. Discoverable, not dominating.
115
- Populated by renderTotals() each poll tick. -->
116
- <div class="totals" id="totals" aria-label="Run totals" hidden></div>
117
- </div>
124
+ <div class="coach-title-row">
125
+ <span class="section-title">Coach the employee</span>
126
+ <span class="active-employee-row">
127
+ <label for="active-employee-select" class="active-employee-label">tool</label>
128
+ <select id="active-employee-select" class="employee-select inline"></select>
129
+ </span>
130
+ </div>
131
+ <textarea id="coach-text" placeholder="Tell the employee what to do next…"></textarea>
132
+ <div class="coach-actions">
133
+ <!-- Issue #347 R2: template picker. Hidden when the project
134
+ has no manager-job templates. -->
135
+ <button class="ghost" type="button" id="template-picker-btn" aria-haspopup="menu" aria-expanded="false" hidden>Use a template ▾</button>
136
+ <button class="send-button" type="button" id="send" disabled>Send</button>
137
+ <div class="template-popover" id="template-popover" role="menu" hidden></div>
138
+ </div>
139
+ <div class="coach-note" id="coach-note"></div>
140
+ <!-- Issue #347 R4: run-level totals. Discoverable, not dominating.
141
+ Populated by renderTotals() each poll tick. -->
142
+ <div class="totals" id="totals" aria-label="Run totals" hidden></div>
143
+ </div>
118
144
 
119
145
  <details class="micro" id="micro-manage">
120
146
  <summary>Micro-manage — raw host details</summary>
@@ -138,8 +164,18 @@
138
164
  </div>
139
165
  <div class="modal-body">
140
166
  <input class="search" type="text" placeholder="Search jobs…" id="job-search">
167
+ <!-- R3+: persona job filter — only shown when subscription is active -->
168
+ <div id="job-persona-filter" hidden></div>
141
169
  <div id="job-catalog"></div>
142
170
  </div>
171
+ <!-- R3: hire-required notice — shown when a locked job is clicked -->
172
+ <div id="hire-notice" hidden>
173
+ <p id="hire-notice-text"></p>
174
+ <div class="hire-notice-actions">
175
+ <a id="hire-notice-link" href="#" target="_blank" rel="noopener" class="send-button">Go to Pricing →</a>
176
+ <button id="hire-notice-back" type="button" class="ghost">← Back to all jobs</button>
177
+ </div>
178
+ </div>
143
179
  <div class="modal-footer">
144
180
  <span class="left" id="job-pick-status">Choose a job to continue</span>
145
181
  <div class="right">