fraim 2.0.185 → 2.0.186

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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
7
7
  exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
+ exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
8
9
  exports.parseUsageSignal = parseUsageSignal;
9
10
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
10
11
  exports.detectEmployees = detectEmployees;
@@ -91,6 +92,58 @@ function parseSeekMentoringSignal(line) {
91
92
  }
92
93
  return null;
93
94
  }
95
+ // Issue #710: extract the job name from a get_fraim_job tool call. This
96
+ // is structured known-job evidence; the server still validates the job id
97
+ // against the catalog before promoting any run.
98
+ function parseFraimJobLoadSignal(line) {
99
+ if (!line.includes('get_fraim_job'))
100
+ return null;
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(line);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ if (typeof parsed !== 'object' || parsed === null)
109
+ return null;
110
+ const obj = parsed;
111
+ // Codex shape: { type: 'item.started' | 'item.completed', item:
112
+ // { type: 'mcp_tool_call', tool: 'get_fraim_job', arguments: { job } } }.
113
+ if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
114
+ typeof obj.item === 'object' && obj.item !== null) {
115
+ const item = obj.item;
116
+ const tool = typeof item.tool === 'string' ? item.tool : '';
117
+ if (item.type === 'mcp_tool_call' && isGetFraimJobTool(tool)) {
118
+ const sig = readFraimJobFromArgs(item.arguments);
119
+ if (sig)
120
+ return sig;
121
+ }
122
+ }
123
+ // Claude Code shape: tool_use blocks live inside message.content with
124
+ // an MCP-prefixed name such as mcp__fraim__get_fraim_job.
125
+ const candidates = [obj];
126
+ if (Array.isArray(obj.content))
127
+ candidates.push(...obj.content);
128
+ if (typeof obj.message === 'object' && obj.message !== null) {
129
+ const msg = obj.message;
130
+ if (Array.isArray(msg.content))
131
+ candidates.push(...msg.content);
132
+ }
133
+ for (const candidate of candidates) {
134
+ if (typeof candidate !== 'object' || candidate === null)
135
+ continue;
136
+ const c = candidate;
137
+ const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
138
+ const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
139
+ if (!isToolUse || !isGetFraimJobTool(nameField))
140
+ continue;
141
+ const sig = readFraimJobFromArgs(c.input || c.arguments || c.parameters);
142
+ if (sig)
143
+ return sig;
144
+ }
145
+ return null;
146
+ }
94
147
  // Issue #347 — extract per-turn usage from the host's JSON stream.
95
148
  // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
96
149
  // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
@@ -256,6 +309,41 @@ function readAgentFromArgs(args) {
256
309
  return null;
257
310
  return { agentName, agentModel };
258
311
  }
312
+ function isGetFraimJobTool(toolName) {
313
+ return toolName === 'get_fraim_job' ||
314
+ toolName === 'mcp__fraim__get_fraim_job' ||
315
+ toolName.endsWith('get_fraim_job');
316
+ }
317
+ function readFraimJobFromArgs(rawArgs) {
318
+ const args = normalizeToolArgs(rawArgs);
319
+ if (!args)
320
+ return null;
321
+ const rawJob = stringValue(args.job) ||
322
+ stringValue(args.jobName) ||
323
+ stringValue(args.job_id) ||
324
+ stringValue(args.name);
325
+ const jobId = rawJob.trim().toLowerCase();
326
+ if (!jobId)
327
+ return null;
328
+ return { jobId, source: 'get_fraim_job' };
329
+ }
330
+ function normalizeToolArgs(rawArgs) {
331
+ if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
332
+ return rawArgs;
333
+ }
334
+ if (typeof rawArgs !== 'string' || rawArgs.trim().length === 0)
335
+ return null;
336
+ try {
337
+ const parsed = JSON.parse(rawArgs);
338
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
339
+ return parsed;
340
+ }
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ return null;
346
+ }
259
347
  function numberOrNull(v) {
260
348
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
261
349
  }
@@ -987,17 +1075,24 @@ function parseHostLine(hostId, line) {
987
1075
  const trimmed = line.trim();
988
1076
  if (!trimmed)
989
1077
  return {};
990
- // Issue #347: scan every line for the three signals the Hub UI cares
991
- // about seekMentoring (drives the tracker), turn-level usage
992
- // (drives the totals tokens/cost), and the agent identity from
993
- // fraim_connect (used to look up cost when the host doesn't emit it).
1078
+ // Scan every line for structured signals the Hub UI cares about:
1079
+ // seekMentoring (tracker), get_fraim_job (job identity promotion),
1080
+ // turn-level usage (totals), and fraim_connect agent identity
1081
+ // (cost lookup when the host doesn't emit it).
994
1082
  const seekMentoring = parseSeekMentoringSignal(trimmed);
1083
+ const fraimJob = parseFraimJobLoadSignal(trimmed);
995
1084
  const usage = parseUsageSignal(trimmed);
996
1085
  const agentIdentity = parseAgentIdentitySignal(trimmed);
997
1086
  const withSignal = (event) => {
998
- if (!seekMentoring && !usage && !agentIdentity)
1087
+ if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
999
1088
  return event;
1000
- return { ...event, ...(seekMentoring ? { seekMentoring } : {}), ...(usage ? { usage } : {}), ...(agentIdentity ? { agentIdentity } : {}) };
1089
+ return {
1090
+ ...event,
1091
+ ...(seekMentoring ? { seekMentoring } : {}),
1092
+ ...(fraimJob ? { fraimJob } : {}),
1093
+ ...(usage ? { usage } : {}),
1094
+ ...(agentIdentity ? { agentIdentity } : {}),
1095
+ };
1001
1096
  };
1002
1097
  if (hostId === 'codex') {
1003
1098
  try {
@@ -1339,9 +1434,10 @@ class ScriptedHostRuntime {
1339
1434
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1340
1435
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1341
1436
  ];
1342
- // Track each active run so the test can emit signals at it. Key is the
1343
- // sessionId we hand back on startRun; mapping sessionId handlers
1344
- // lets emitPhase() reach the right run's onEvent callback.
1437
+ // Track each active run so the test can emit signals at it. The Hub
1438
+ // passes the run id as the requested start session id in test/demo paths;
1439
+ // real resume flows still use the host session id. Resolving by the
1440
+ // supplied id first lets concurrent scripted runs receive targeted events.
1345
1441
  this.handlersBySession = new Map();
1346
1442
  // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
1347
1443
  // can resolve onSuccess routing without each test having to repeat it.
@@ -1352,8 +1448,8 @@ class ScriptedHostRuntime {
1352
1448
  }
1353
1449
  startRun(_hostId, _projectPath, _message, handlers, requestedSessionId) {
1354
1450
  const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1355
- handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1356
1451
  this.handlersBySession.set(sessionId, handlers);
1452
+ handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1357
1453
  return this.spawnDouble();
1358
1454
  }
1359
1455
  continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
@@ -1363,6 +1459,7 @@ class ScriptedHostRuntime {
1363
1459
  }
1364
1460
  startDirectRun(_hostId, _message, _projectPath, handlers, requestedSessionId) {
1365
1461
  const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1462
+ this.handlersBySession.set(sessionId, handlers);
1366
1463
  handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
1367
1464
  return this.spawnDouble();
1368
1465
  }
@@ -1420,6 +1517,18 @@ class ScriptedHostRuntime {
1420
1517
  },
1421
1518
  }, 'stdout');
1422
1519
  }
1520
+ // Test API — emit the structured job-load signal that get_fraim_job
1521
+ // produces before the first seekMentoring phase call.
1522
+ emitFraimJobLoad(runId, jobId) {
1523
+ const target = this.resolveSession(runId);
1524
+ if (!target)
1525
+ return;
1526
+ target.handlers.onEvent({
1527
+ sessionId: target.sessionId,
1528
+ raw: `scripted-get_fraim_job:${jobId}`,
1529
+ fraimJob: { jobId, source: 'get_fraim_job' },
1530
+ }, 'stdout');
1531
+ }
1423
1532
  // Test API — emit a per-turn usage signal in the normalized shape
1424
1533
  // the server expects. Mirrors what parseUsageSignal would produce
1425
1534
  // from a real host's stream.
@@ -1464,13 +1573,16 @@ class ScriptedHostRuntime {
1464
1573
  this.handlersBySession.clear();
1465
1574
  this.discriminantBySession.clear();
1466
1575
  }
1467
- // The Hub server keeps its own sessionId map keyed off run.sessionId
1468
- // but at the time the test calls emitPhase(runId), we may only have
1469
- // one handler registered (the latest start). Resolve by sessionId
1470
- // first, fall back to the most recent registered handler.
1471
- resolveSession(_runId) {
1576
+ // The Hub passes run.id as the requested session id for scripted starts,
1577
+ // so tests/demo controls can target a specific run even when multiple runs
1578
+ // are active. Fall back to the latest handler for older callers that do not
1579
+ // provide a known id.
1580
+ resolveSession(runId) {
1472
1581
  if (this.handlersBySession.size === 0)
1473
1582
  return null;
1583
+ const direct = this.handlersBySession.get(runId);
1584
+ if (direct)
1585
+ return { sessionId: runId, handlers: direct };
1474
1586
  const entries = [...this.handlersBySession.entries()];
1475
1587
  const [sessionId, handlers] = entries[entries.length - 1];
1476
1588
  return { sessionId, handlers };
@@ -134,6 +134,7 @@ class OpenClawAiHubBridge {
134
134
  jobId: run.jobId,
135
135
  projectPath,
136
136
  lastSeenAt: new Date().toISOString(),
137
+ managerTexts: [inbound.text],
137
138
  };
138
139
  this.threads.set(key, state);
139
140
  this.persistThreadState();
@@ -147,6 +148,7 @@ class OpenClawAiHubBridge {
147
148
  }
148
149
  run = continued.data;
149
150
  state.lastSeenAt = new Date().toISOString();
151
+ state.managerTexts = [...(state.managerTexts ?? []), inbound.text];
150
152
  this.persistThreadState();
151
153
  }
152
154
  const settled = await this.waitForSettlement(run.id);
@@ -193,6 +195,19 @@ class OpenClawAiHubBridge {
193
195
  return this.fetchRun(runId);
194
196
  }
195
197
  presentThread(state, run) {
198
+ // The Hub records the invocation it sent as each manager bubble (#696). For a channel
199
+ // transcript we substitute the user's own words (state.managerTexts, in turn order)
200
+ // for manager-role messages, so the thread reflects what the user actually typed.
201
+ const managerTexts = state.managerTexts ?? [];
202
+ let managerIdx = 0;
203
+ const transcript = run.messages.map((message) => ({
204
+ role: message.role,
205
+ text: message.role === 'manager' && managerIdx < managerTexts.length
206
+ ? managerTexts[managerIdx++]
207
+ : message.text,
208
+ createdAt: message.createdAt,
209
+ }));
210
+ const latestManagerMessage = [...transcript].reverse().find((m) => m.role === 'manager')?.text || null;
196
211
  return {
197
212
  channelId: state.channelId,
198
213
  threadId: state.threadId,
@@ -204,13 +219,9 @@ class OpenClawAiHubBridge {
204
219
  currentPhase: run.currentPhase || null,
205
220
  stages: run.stages || [],
206
221
  totals: run.totals || null,
207
- latestManagerMessage: lastMessage(run.messages, 'manager')?.text || null,
222
+ latestManagerMessage,
208
223
  latestEmployeeMessage: lastMessage(run.messages, 'employee')?.text || null,
209
- transcript: run.messages.map((message) => ({
210
- role: message.role,
211
- text: message.text,
212
- createdAt: message.createdAt,
213
- })),
224
+ transcript,
214
225
  };
215
226
  }
216
227
  threadKey(channelId, threadId) {
@@ -227,6 +227,9 @@ function safeHttpUrl(value) {
227
227
  }
228
228
  // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
229
229
  const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
230
+ function startSessionSeedForHost(hostId, runId) {
231
+ return hostId === 'gemini' ? undefined : runId;
232
+ }
230
233
  class DeploymentStore {
231
234
  constructor(filePath) {
232
235
  this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
@@ -587,6 +590,42 @@ function emptyTotals() {
587
590
  },
588
591
  };
589
592
  }
593
+ const PHASE_STATUSES = new Set(['starting', 'complete', 'incomplete', 'failure']);
594
+ function normalizePersistedPhaseHistory(value) {
595
+ if (!Array.isArray(value))
596
+ return [];
597
+ return value
598
+ .map((entry) => {
599
+ if (!entry || typeof entry !== 'object')
600
+ return null;
601
+ const raw = entry;
602
+ if (typeof raw.phaseId !== 'string' || !raw.phaseId)
603
+ return null;
604
+ const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
605
+ ? raw.latestStatus
606
+ : null;
607
+ return {
608
+ phaseId: raw.phaseId,
609
+ enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
610
+ latestStatus,
611
+ latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
612
+ };
613
+ })
614
+ .filter((entry) => Boolean(entry));
615
+ }
616
+ function readPersistedRunProjection(conversation) {
617
+ const rawRun = conversation?.run;
618
+ if (!rawRun || typeof rawRun !== 'object')
619
+ return null;
620
+ const value = rawRun;
621
+ return {
622
+ currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
623
+ phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
624
+ stages: Array.isArray(value.stages) ? value.stages : [],
625
+ totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
626
+ runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
627
+ };
628
+ }
590
629
  // Issue #347 — apply per-turn usage from the host stream into the
591
630
  // run's tokenTotals. Idempotent on the same turn (we replace, not add)
592
631
  // because Codex's `turn.completed.usage` is cumulative, not delta.
@@ -766,20 +805,17 @@ function deriveStages(run, projectPath) {
766
805
  }
767
806
  return declaredPath.map((phase, index) => {
768
807
  let state;
769
- if (currentIndex < 0) {
770
- state = 'upcoming';
771
- }
772
- else if (index < currentIndex) {
773
- state = 'done';
774
- }
775
- else if (index === currentIndex) {
808
+ const entry = historyMap.get(phase.id);
809
+ if (index === currentIndex) {
776
810
  // If the agent has already reported this phase as 'complete', advance
777
811
  // its visual state to 'done' so the tracker doesn't look frozen while
778
812
  // waiting for the agent to start the next phase (e.g. after
779
813
  // implement-submission completes but before address-feedback starts).
780
- const entry = historyMap.get(phase.id);
781
814
  state = entry?.latestStatus === 'complete' ? 'done' : 'current';
782
815
  }
816
+ else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
817
+ state = 'done';
818
+ }
783
819
  else {
784
820
  state = 'upcoming';
785
821
  }
@@ -1399,6 +1435,7 @@ class AiHubServer {
1399
1435
  }
1400
1436
  conversationRecordFromRun(run) {
1401
1437
  const lastUpdatedAt = run.updatedAt || new Date().toISOString();
1438
+ const stages = deriveStages(run, run.projectPath);
1402
1439
  return {
1403
1440
  id: run.conversationId || run.id,
1404
1441
  projectPath: path_1.default.resolve(run.projectPath),
@@ -1433,6 +1470,13 @@ class AiHubServer {
1433
1470
  compareRunId: run.compareRunId || null,
1434
1471
  // Issue #578: preserve trigger source so the UI can render the chip.
1435
1472
  sourceTrigger: run.sourceTrigger,
1473
+ run: {
1474
+ stages,
1475
+ currentPhase: run.currentPhase || null,
1476
+ phaseHistory: run.phaseHistory || [],
1477
+ totals: run.totals || null,
1478
+ runDiscriminant: run.runDiscriminant || null,
1479
+ },
1436
1480
  };
1437
1481
  }
1438
1482
  persistRunConversation(run, activeId) {
@@ -1544,8 +1588,10 @@ class AiHubServer {
1544
1588
  }
1545
1589
  if (event.agentIdentity)
1546
1590
  applyAgentIdentitySignal(current, event.agentIdentity);
1591
+ if (event.fraimJob)
1592
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
1547
1593
  if (event.seekMentoring)
1548
- applySeekMentoringSignal(current, event.seekMentoring);
1594
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1549
1595
  if (event.usage)
1550
1596
  applyUsageSignal(current, event.usage);
1551
1597
  });
@@ -1567,7 +1613,7 @@ class AiHubServer {
1567
1613
  }
1568
1614
  this.runRegistry.dispose(childRun.id);
1569
1615
  },
1570
- }, childRun.sessionId);
1616
+ }, startSessionSeedForHost(managerRun.hostId, childRun.id));
1571
1617
  this.runRegistry.attachChildIfRunning(childRun.id, child);
1572
1618
  }
1573
1619
  notifyManagerOfDelegatedChild(managerRunId, childRun) {
@@ -1691,8 +1737,10 @@ class AiHubServer {
1691
1737
  }
1692
1738
  if (event.agentIdentity)
1693
1739
  applyAgentIdentitySignal(current, event.agentIdentity);
1740
+ if (event.fraimJob)
1741
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
1694
1742
  if (event.seekMentoring)
1695
- applySeekMentoringSignal(current, event.seekMentoring);
1743
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1696
1744
  if (event.usage)
1697
1745
  applyUsageSignal(current, event.usage);
1698
1746
  });
@@ -1764,14 +1812,48 @@ class AiHubServer {
1764
1812
  };
1765
1813
  }
1766
1814
  resolveHubJob(projectPath, jobId) {
1815
+ if (!jobId || jobId === '__freeform__')
1816
+ return null;
1767
1817
  const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
1768
- if (employeeJob)
1769
- return { id: employeeJob.id, stubPath: employeeJob.stubPath };
1818
+ if (employeeJob) {
1819
+ return {
1820
+ id: employeeJob.id,
1821
+ title: employeeJob.title,
1822
+ stubPath: employeeJob.stubPath,
1823
+ personaKey: employeeJob.requiredPersonaKey ?? getProtectedPersonaForHubJob(employeeJob.id),
1824
+ };
1825
+ }
1770
1826
  const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
1771
- if (managerTemplate)
1772
- return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
1827
+ if (managerTemplate) {
1828
+ return {
1829
+ id: managerTemplate.id,
1830
+ title: managerTemplate.title,
1831
+ stubPath: managerTemplate.stubPath,
1832
+ personaKey: getProtectedPersonaForHubJob(managerTemplate.id),
1833
+ };
1834
+ }
1773
1835
  return null;
1774
1836
  }
1837
+ applySeekMentoringSignalToRun(run, signal) {
1838
+ this.maybePromoteFreeformRunToJob(run, signal.jobId || signal.jobName);
1839
+ applySeekMentoringSignal(run, signal);
1840
+ }
1841
+ applyFraimJobSignalToRun(run, signal) {
1842
+ this.maybePromoteFreeformRunToJob(run, signal.jobId);
1843
+ }
1844
+ maybePromoteFreeformRunToJob(run, signalJobId) {
1845
+ const normalizedJobId = (signalJobId || '').trim().toLowerCase();
1846
+ if (run.jobId !== '__freeform__' || !normalizedJobId)
1847
+ return;
1848
+ const metadata = this.resolveHubJob(run.projectPath, normalizedJobId);
1849
+ if (!metadata)
1850
+ return;
1851
+ run.jobId = metadata.id;
1852
+ run.jobTitle = metadata.title;
1853
+ run.personaKey = metadata.personaKey;
1854
+ run.updatedAt = new Date().toISOString();
1855
+ run.events.push((0, hosts_1.createHubEvent)('system', `Recognized FRAIM job ${metadata.id} from structured telemetry.`));
1856
+ }
1775
1857
  // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
1776
1858
  // content) export routes so a conversational deliverable with no on-disk file
1777
1859
  // can still be downloaded for Word annotation.
@@ -2355,11 +2437,15 @@ class AiHubServer {
2355
2437
  throw new Error('Choose a FRAIM job before starting a run.');
2356
2438
  }
2357
2439
  const startTimestamp = new Date().toISOString();
2440
+ const jobMetadata = this.resolveHubJob(projectPath, jobId);
2441
+ const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
2442
+ ? req.body.jobTitle.trim()
2443
+ : jobId;
2358
2444
  const run = {
2359
2445
  id: (0, crypto_1.randomUUID)(),
2360
2446
  conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
2361
2447
  conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
2362
- jobTitle: typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim() ? req.body.jobTitle.trim() : jobId,
2448
+ jobTitle: jobMetadata?.title || fallbackJobTitle,
2363
2449
  jobId,
2364
2450
  hostId,
2365
2451
  projectPath,
@@ -2373,7 +2459,7 @@ class AiHubServer {
2373
2459
  phaseHistory: [],
2374
2460
  totals: emptyTotals(),
2375
2461
  lastStatusChangeAt: startTimestamp,
2376
- personaKey: getProtectedPersonaForHubJob(jobId),
2462
+ personaKey: jobMetadata?.personaKey ?? getProtectedPersonaForHubJob(jobId),
2377
2463
  // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
2378
2464
  ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
2379
2465
  // #0: trigger source — defaults to 'manager' when not provided by the caller.
@@ -2426,8 +2512,10 @@ class AiHubServer {
2426
2512
  }
2427
2513
  if (event.agentIdentity)
2428
2514
  applyAgentIdentitySignal(current, event.agentIdentity);
2515
+ if (event.fraimJob)
2516
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2429
2517
  if (event.seekMentoring)
2430
- applySeekMentoringSignal(current, event.seekMentoring);
2518
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2431
2519
  if (event.usage)
2432
2520
  applyUsageSignal(current, event.usage);
2433
2521
  });
@@ -2460,7 +2548,7 @@ class AiHubServer {
2460
2548
  if (latest)
2461
2549
  this.drainPendingDelegatedReviews(latest);
2462
2550
  },
2463
- }, run.sessionId);
2551
+ }, startSessionSeedForHost(hostId, run.id));
2464
2552
  this.runRegistry.attachChildIfRunning(run.id, child);
2465
2553
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
2466
2554
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
@@ -2489,7 +2577,7 @@ class AiHubServer {
2489
2577
  });
2490
2578
  this.runRegistry.dispose(directId);
2491
2579
  },
2492
- }, directRun.sessionId);
2580
+ }, startSessionSeedForHost(hostId, directRun.id));
2493
2581
  this.runRegistry.attachChildIfRunning(directRun.id, directChild);
2494
2582
  }
2495
2583
  const existingPreferences = this.preferencesStore.load(projectPath);
@@ -2615,8 +2703,10 @@ class AiHubServer {
2615
2703
  }
2616
2704
  if (event.agentIdentity)
2617
2705
  applyAgentIdentitySignal(current, event.agentIdentity);
2706
+ if (event.fraimJob)
2707
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2618
2708
  if (event.seekMentoring)
2619
- applySeekMentoringSignal(current, event.seekMentoring);
2709
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2620
2710
  if (event.usage)
2621
2711
  applyUsageSignal(current, event.usage);
2622
2712
  });
@@ -2677,16 +2767,27 @@ class AiHubServer {
2677
2767
  if (!employee?.available) {
2678
2768
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
2679
2769
  }
2770
+ const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
2771
+ ? body.conversationId.trim()
2772
+ : undefined;
2773
+ const persistedConversation = conversationId
2774
+ ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
2775
+ : undefined;
2776
+ const persistedRun = readPersistedRunProjection(persistedConversation);
2680
2777
  const now = new Date().toISOString();
2681
2778
  const run = {
2682
2779
  id: (0, crypto_1.randomUUID)(),
2683
- conversationId: typeof body.conversationId === 'string' && body.conversationId.trim() ? body.conversationId.trim() : undefined,
2780
+ conversationId,
2684
2781
  conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
2685
2782
  jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
2686
2783
  jobId, hostId, projectPath, status: 'running', sessionId,
2687
2784
  createdAt: now, updatedAt: now, messages: [],
2688
2785
  events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
2689
- currentPhase: null, phaseHistory: [], totals: emptyTotals(), lastStatusChangeAt: now,
2786
+ currentPhase: persistedRun?.currentPhase || null,
2787
+ phaseHistory: persistedRun?.phaseHistory || [],
2788
+ totals: persistedRun?.totals || emptyTotals(),
2789
+ lastStatusChangeAt: now,
2790
+ runDiscriminant: persistedRun?.runDiscriminant || undefined,
2690
2791
  personaKey: getProtectedPersonaForHubJob(jobId),
2691
2792
  };
2692
2793
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
@@ -2710,8 +2811,10 @@ class AiHubServer {
2710
2811
  }
2711
2812
  if (event.agentIdentity)
2712
2813
  applyAgentIdentitySignal(current, event.agentIdentity);
2814
+ if (event.fraimJob)
2815
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2713
2816
  if (event.seekMentoring)
2714
- applySeekMentoringSignal(current, event.seekMentoring);
2817
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2715
2818
  if (event.usage)
2716
2819
  applyUsageSignal(current, event.usage);
2717
2820
  });
@@ -3139,8 +3242,10 @@ class AiHubServer {
3139
3242
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3140
3243
  if (event.agentIdentity)
3141
3244
  applyAgentIdentitySignal(current, event.agentIdentity);
3245
+ if (event.fraimJob)
3246
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
3142
3247
  if (event.seekMentoring)
3143
- applySeekMentoringSignal(current, event.seekMentoring);
3248
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3144
3249
  if (event.usage)
3145
3250
  applyUsageSignal(current, event.usage);
3146
3251
  });
@@ -3153,7 +3258,7 @@ class AiHubServer {
3153
3258
  });
3154
3259
  this.runRegistry.dispose(run.id);
3155
3260
  },
3156
- }, run.sessionId);
3261
+ }, startSessionSeedForHost(hostId, run.id));
3157
3262
  // Update the registry entry with the real child process handle.
3158
3263
  this.runRegistry.attachChildIfRunning(run.id, child);
3159
3264
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
@@ -3259,8 +3364,10 @@ class AiHubServer {
3259
3364
  }
3260
3365
  if (event.agentIdentity)
3261
3366
  applyAgentIdentitySignal(current, event.agentIdentity);
3367
+ if (event.fraimJob)
3368
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
3262
3369
  if (event.seekMentoring)
3263
- applySeekMentoringSignal(current, event.seekMentoring);
3370
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3264
3371
  if (event.usage)
3265
3372
  applyUsageSignal(current, event.usage);
3266
3373
  });
@@ -3280,7 +3387,7 @@ class AiHubServer {
3280
3387
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
3281
3388
  this.runRegistry.dispose(run.id);
3282
3389
  },
3283
- });
3390
+ }, startSessionSeedForHost(deployment.hostId, run.id));
3284
3391
  this.runRegistry.create(run, child);
3285
3392
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
3286
3393
  return run;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.185",
3
+ "version": "2.0.186",
4
4
  "description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -16,6 +16,9 @@ const TREE_WIDTH_MAX = 380;
16
16
  const TREE_WIDTH_DEFAULT = 216;
17
17
  const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis']);
18
18
  // Jobs scoped to the Company/Manager area — never shown in the Projects workspace rail.
19
+ // These are the truly area-only jobs. Issue #702: persona jobs (e.g. Ashley's) are NOT
20
+ // listed here — a persona can run a job from a project OR from the Manager tab, and a
21
+ // run's placement is decided per-invocation by conv.invokedArea, not by job id.
19
22
  const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
20
23
 
21
24
  const state = {
@@ -509,9 +512,10 @@ async function bgRefreshConversations() {
509
512
  existing.push(conv);
510
513
  changed = true;
511
514
  } else {
512
- // Update status/messages on known conversations (e.g. running completed)
515
+ // Update known conversations when the server projection changes. This
516
+ // includes job/persona recognition for running ad-hoc jobs.
513
517
  const idx = existing.findIndex((c) => c.id === conv.id);
514
- if (idx !== -1 && existing[idx].status !== conv.status) {
518
+ if (idx !== -1 && conversationProjectionChanged(existing[idx], conv)) {
515
519
  existing[idx] = conv;
516
520
  changed = true;
517
521
  }
@@ -533,7 +537,7 @@ function stopBgConvPoll() {
533
537
  if (_bgConvPollHandle) { window.clearInterval(_bgConvPollHandle); _bgConvPollHandle = null; }
534
538
  }
535
539
 
536
- function needsConversationRefresh() {
540
+ function needsDelegatedConversationRefresh() {
537
541
  const active = activeConversation();
538
542
  if (!active) return false;
539
543
  if (isManagedDelegationChild(active) && active.status === 'running') return true;
@@ -548,12 +552,49 @@ function needsConversationRefresh() {
548
552
  });
549
553
  }
550
554
 
555
+ function needsConversationRefresh() {
556
+ return runningProjectConversations().length > 0 || needsDelegatedConversationRefresh();
557
+ }
558
+
559
+ async function refreshRunningConversations() {
560
+ const running = runningProjectConversations();
561
+ if (!running.length) return false;
562
+ let changed = false;
563
+ for (const conv of running) {
564
+ try {
565
+ const run = await requestJson(`/api/ai-hub/runs/${encodeURIComponent(conv.runId)}`);
566
+ foldRunIntoConversation(conv, run);
567
+ upsertConversation(conv);
568
+ changed = true;
569
+ } catch (_) {
570
+ // The run may have just completed and left the in-memory registry before
571
+ // the conversation store hydrated. The next conversation refresh catches it.
572
+ }
573
+ }
574
+ if (changed) {
575
+ renderRail();
576
+ renderActive();
577
+ if (tf.area === 'company') tfRenderCompany();
578
+ else if (tf.area === 'manager') tfRenderManager();
579
+ }
580
+ return changed;
581
+ }
582
+
583
+ async function refreshConversationProjections() {
584
+ await refreshRunningConversations();
585
+ if (needsDelegatedConversationRefresh()) {
586
+ await hydrateConversationsFromServer();
587
+ } else {
588
+ syncConversationRefreshPolling();
589
+ }
590
+ }
591
+
551
592
  function syncConversationRefreshPolling() {
552
593
  const shouldPoll = needsConversationRefresh();
553
594
  if (shouldPoll && !state.conversationRefreshHandle) {
554
595
  state.conversationRefreshHandle = window.setInterval(() => {
555
- hydrateConversationsFromServer().catch((error) =>
556
- console.warn('Could not refresh delegated conversations:', error));
596
+ refreshConversationProjections().catch((error) =>
597
+ console.warn('Could not refresh conversations:', error));
557
598
  }, 1500);
558
599
  } else if (!shouldPoll && state.conversationRefreshHandle) {
559
600
  window.clearInterval(state.conversationRefreshHandle);
@@ -578,6 +619,32 @@ function projectConversations() {
578
619
  return state.conversations[key] || [];
579
620
  }
580
621
 
622
+ function conversationProjectionChanged(existing, incoming) {
623
+ if (!existing || !incoming) return true;
624
+ const fields = [
625
+ 'jobId',
626
+ 'jobTitle',
627
+ 'personaKey',
628
+ 'runId',
629
+ 'sessionId',
630
+ 'status',
631
+ 'managedReviewStatus',
632
+ 'compareRunId',
633
+ 'sourceTrigger',
634
+ ];
635
+ if (fields.some((field) => (existing[field] ?? null) !== (incoming[field] ?? null))) return true;
636
+ const existingMessages = Array.isArray(existing.messages) ? existing.messages.length : 0;
637
+ const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages.length : 0;
638
+ if (existingMessages !== incomingMessages) return true;
639
+ const existingEvents = Array.isArray(existing.events) ? existing.events.length : 0;
640
+ const incomingEvents = Array.isArray(incoming.events) ? incoming.events.length : 0;
641
+ return existingEvents !== incomingEvents;
642
+ }
643
+
644
+ function runningProjectConversations() {
645
+ return projectConversations().filter((conv) => conv && conv.status === 'running' && conv.runId);
646
+ }
647
+
581
648
  function setProjectConversations(list) {
582
649
  const key = state.projectPath || '';
583
650
  state.conversations[key] = list;
@@ -854,7 +921,9 @@ function renderRail() {
854
921
  const list = allProjectConversations.filter((conv) =>
855
922
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
856
923
  !isManagedDelegationChild(conv) &&
857
- !AREA_SCOPED_JOBS.has(conv.jobId)
924
+ !AREA_SCOPED_JOBS.has(conv.jobId) &&
925
+ // #702: runs invoked from the Manager/Company tab surface there, not in a project.
926
+ conv.invokedArea !== 'manager' && conv.invokedArea !== 'company'
858
927
  );
859
928
 
860
929
  // Issue #550: Two-path routing for ad-hoc (freeform) conversations.
@@ -2034,11 +2103,29 @@ function renderTracker(conv) {
2034
2103
 
2035
2104
  const stages = Array.isArray(conv.run.stages) ? conv.run.stages : [];
2036
2105
  if (stages.length === 0) {
2037
- tracker.hidden = true;
2106
+ // Issue #711 — adhoc runs (jobId '__freeform__') declare no phases, so
2107
+ // there is no pizza tracker. We still surface the run totals (tokens,
2108
+ // cost, duration) that the server derives for every run. Keep the tracker
2109
+ // container visible in "totals-only" mode so renderTotals has a place to
2110
+ // render; otherwise hide the whole section.
2111
+ if (conv.run.totals) {
2112
+ tracker.hidden = false;
2113
+ tracker.classList.add('tracker--totals-only');
2114
+ tracker.classList.remove('tracker-compact');
2115
+ const rowsHost = els['tracker-rows'];
2116
+ if (rowsHost) rowsHost.innerHTML = '';
2117
+ const activeLabel = tracker.querySelector('.tracker-active-label');
2118
+ if (activeLabel) activeLabel.textContent = '';
2119
+ const note = els['tracker-note'];
2120
+ if (note) { note.hidden = true; note.textContent = ''; }
2121
+ } else {
2122
+ tracker.hidden = true;
2123
+ }
2038
2124
  renderedTrackerKey = null;
2039
2125
  return;
2040
2126
  }
2041
2127
 
2128
+ tracker.classList.remove('tracker--totals-only');
2042
2129
  tracker.hidden = false;
2043
2130
  tracker.dataset.stageCount = String(stages.length);
2044
2131
  const rowCapacity = trackerRowCapacity(tracker, stages);
@@ -4753,7 +4840,7 @@ function deriveTitle(jobTitle, instructions) {
4753
4840
  // The browser sends raw manager instructions. AI Hub normalizes assigned
4754
4841
  // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
4755
4842
  // share one start/continue contract.
4756
- async function startRun(job, instructions, employeeId, preassignedConvId) {
4843
+ async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
4757
4844
  const isFreeform = job.id === '__freeform__';
4758
4845
  // In task-pane/extension mode: get a fresh selection snapshot and prepend
4759
4846
  // document context to the instructions so the agent knows what the user is
@@ -4792,6 +4879,10 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
4792
4879
  compareRun: null,
4793
4880
  // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
4794
4881
  wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
4882
+ // Issue #702: remember which area this run was launched from so its run surfaces
4883
+ // where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
4884
+ // Defaults to the current area.
4885
+ invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
4795
4886
  };
4796
4887
  upsertConversation(conv);
4797
4888
  state.activeId = conv.id;
@@ -4958,6 +5049,18 @@ async function continueRun(text) {
4958
5049
  }
4959
5050
 
4960
5051
  function foldRunIntoConversation(conv, run) {
5052
+ // Issue #710: server run identity is canonical. The browser may have
5053
+ // created this conversation as provisional __freeform__ state, but every
5054
+ // server snapshot can promote it to a real catalog job.
5055
+ if (run.jobId) {
5056
+ conv.jobId = run.jobId;
5057
+ }
5058
+ if (run.jobTitle !== undefined && run.jobTitle !== null) {
5059
+ conv.jobTitle = run.jobTitle;
5060
+ }
5061
+ if (run.personaKey !== undefined) {
5062
+ conv.personaKey = run.personaKey;
5063
+ }
4961
5064
  // Issue #347: keep the latest server snapshot under conv.run so the
4962
5065
  // tracker / totals renderers can read it without re-doing work. Stages,
4963
5066
  // currentPhase, phaseHistory, and totals are all server-derived per
@@ -4967,6 +5070,7 @@ function foldRunIntoConversation(conv, run) {
4967
5070
  currentPhase: run.currentPhase || null,
4968
5071
  phaseHistory: run.phaseHistory || [],
4969
5072
  totals: run.totals || null,
5073
+ runDiscriminant: run.runDiscriminant || null,
4970
5074
  };
4971
5075
  // Replace the conversation's events with the run's events (single source of truth on the server).
4972
5076
  // If this conversation was restarted on a different agent, prepend the prior run's events so
@@ -4984,10 +5088,6 @@ function foldRunIntoConversation(conv, run) {
4984
5088
  conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
4985
5089
  // Track session for resumption.
4986
5090
  if (run.sessionId) conv.sessionId = run.sessionId;
4987
- // R4: persist the persona key from the server-side run record.
4988
- if (run.personaKey !== undefined && conv.personaKey == null) {
4989
- conv.personaKey = run.personaKey;
4990
- }
4991
5091
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
4992
5092
  if (runHandoff) {
4993
5093
  conv.reviewHandoff = runHandoff;
@@ -6233,11 +6333,18 @@ function tfConvForAssignment(assignment) {
6233
6333
  function tfConversationsForJob(jobId, opts) {
6234
6334
  const out = [];
6235
6335
  const projectPath = opts && opts.projectPath;
6336
+ // #702: optional invokedArea filter so the Manager rail shows only manager-invoked
6337
+ // runs of a persona job (project-invoked runs of the same job stay in the project).
6338
+ const invokedArea = opts && opts.invokedArea;
6236
6339
  const lists = projectPath
6237
6340
  ? [state.conversations[projectPath] || []]
6238
6341
  : Object.values(state.conversations || {});
6239
6342
  for (const list of lists) {
6240
- for (const c of (list || [])) if (c && c.jobId === jobId) out.push(c);
6343
+ for (const c of (list || [])) {
6344
+ if (!c || c.jobId !== jobId) continue;
6345
+ if (invokedArea && (c.invokedArea || 'projects') !== invokedArea) continue;
6346
+ out.push(c);
6347
+ }
6241
6348
  }
6242
6349
  out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
6243
6350
  return out;
@@ -8009,10 +8116,12 @@ function tfActiveOrgConv() {
8009
8116
  );
8010
8117
  }
8011
8118
 
8012
- // #594 R2: return the org-scoped manager-agreements conversation to show in Manager area.
8119
+ // #594 R2: return the manager-scoped conversation to show in Manager area.
8120
+ // #702: manager-tab runs are those invoked from the manager area (any persona job),
8121
+ // plus the area-only manager-agreements job (older runs predate invokedArea). An active
8122
+ // manager-invoked run surfaces in the Manager conversation host (Coach panel included).
8013
8123
  function tfActiveMgrConv() {
8014
- const mgrJobs = new Set(['manager-agreements']);
8015
- const convs = Object.values(state.conversations || {}).flat().filter((c) => mgrJobs.has(c.jobId));
8124
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (c.invokedArea === 'manager' || c.jobId === 'manager-agreements'));
8016
8125
  return (
8017
8126
  convs.find((c) => c.status === 'running') ||
8018
8127
  convs.find((c) => c.status === 'waiting') ||
@@ -8021,6 +8130,93 @@ function tfActiveMgrConv() {
8021
8130
  );
8022
8131
  }
8023
8132
 
8133
+ // #702: is a persona hired (has a company seat) so its manager-tab jobs should show?
8134
+ // (MANAGER_ASHLEY_JOBS is defined once at the top of this file as the single source of truth.)
8135
+ function tfIsPersonaHired(personaKey) {
8136
+ const personas = (state.bootstrap && state.bootstrap.personas) || [];
8137
+ const p = personas.find((x) => x && x.key === personaKey);
8138
+ return !!(p && (p.status === 'hired' || (p.seatCount || 0) > 0));
8139
+ }
8140
+
8141
+ // #702: launch a persona job from the Manager tab. Reuses the area pre-flight modal +
8142
+ // run routing (targetArea 'manager'), so the run opens in #manager-conv-host with the
8143
+ // Coach panel and is tagged invokedArea='manager' — exactly like manager-agreements.
8144
+ function tfStartManagerPersonaJob(jobId) {
8145
+ tfOpenAreaOnboardModal(jobId, 'Run ' + jobId + ' across the projects I manage and my calendar.', 'manager');
8146
+ }
8147
+
8148
+ // #702: run-item button matching the Projects rail (conv-item), for the manager
8149
+ // employee group. Mirrors the core of renderRail's buildRunButton so runs look
8150
+ // identical on both tabs.
8151
+ function tfBuildManagerRunItem(conv) {
8152
+ const btn = document.createElement('button');
8153
+ btn.className = 'conv-item';
8154
+ btn.type = 'button';
8155
+ btn.dataset.conv = conv.id;
8156
+ const body = document.createElement('span'); body.className = 'conv-body';
8157
+ const title = document.createElement('span'); title.className = 'conv-title';
8158
+ title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
8159
+ body.appendChild(title); btn.appendChild(body);
8160
+ const dotClass = conversationStateDotClass(conv);
8161
+ const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
8162
+ if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
8163
+ btn.appendChild(dot);
8164
+ const status = document.createElement('span'); status.className = 'conv-status';
8165
+ status.textContent = conversationStateLabel(conv);
8166
+ status.classList.add(conversationUiState(conv));
8167
+ btn.appendChild(status);
8168
+ tfAttachRunDelete(btn, conv, { tree: false });
8169
+ btn.addEventListener('click', () => switchToConversation(conv.id));
8170
+ return btn;
8171
+ }
8172
+
8173
+ // #702: render a hired persona as an employee group on the Manager rail — the SAME
8174
+ // visual as the Projects rail employee groups (avatar + name + role + count + "+" that
8175
+ // opens the job palette), with the persona's MANAGER-invoked runs listed under it.
8176
+ // Keeps a persona looking like an employee, consistent across tabs.
8177
+ function tfRenderManagerPersonaGroup(rail, persona) {
8178
+ const sample = { personaKey: persona.key };
8179
+ const runs = Object.values(state.conversations || {}).flat()
8180
+ .filter((c) => c && c.personaKey === persona.key && c.invokedArea === 'manager')
8181
+ .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
8182
+ const details = document.createElement('details');
8183
+ details.className = 'conv-employee-group';
8184
+ details.open = true;
8185
+ const summary = document.createElement('summary');
8186
+ summary.className = 'conv-employee-tab';
8187
+ const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
8188
+ const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
8189
+ const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
8190
+ label.textContent = persona.displayName || persona.key;
8191
+ const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
8192
+ detail.textContent = persona.role || 'AI Employee';
8193
+ copy.appendChild(label); copy.appendChild(detail);
8194
+ const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
8195
+ addBtn.textContent = '+';
8196
+ addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
8197
+ addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
8198
+ addBtn.addEventListener('click', (e) => {
8199
+ e.preventDefault(); e.stopPropagation();
8200
+ // Launch from the Manager tab: palette prefiltered to this persona. Runs started
8201
+ // while the manager tab is active default to invokedArea='manager'.
8202
+ openPalette({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key });
8203
+ });
8204
+ const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
8205
+ count.textContent = String(runs.length);
8206
+ summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
8207
+ details.appendChild(summary);
8208
+ const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
8209
+ if (!runs.length) {
8210
+ const hint = document.createElement('div'); hint.className = 'eh-empty';
8211
+ hint.textContent = 'No runs yet — + to launch a job.';
8212
+ listDiv.appendChild(hint);
8213
+ } else {
8214
+ for (const c of runs) listDiv.appendChild(tfBuildManagerRunItem(c));
8215
+ }
8216
+ details.appendChild(listDiv);
8217
+ rail.appendChild(details);
8218
+ }
8219
+
8024
8220
  // Move the shared .page conversation panel into the specified area's workspace-conv host.
8025
8221
  // All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
8026
8222
  // because the area-conv-host elements carry the workspace-conv class.
@@ -8216,6 +8412,30 @@ function tfRenderManager() {
8216
8412
  rail.appendChild(infoBtn);
8217
8413
  // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
8218
8414
  // Manager agreements now runs from the "Context & rules" section it populates.
8415
+ // Issue #702: for each hired persona with catalog jobs, render an EMPLOYEE GROUP on
8416
+ // the Manager rail — the same avatar + name + runs + "+" presentation as the Projects
8417
+ // rail (tfRenderManagerPersonaGroup reuses conv-employee-group), so a persona looks
8418
+ // identical on both tabs. Data-driven from bootstrap job metadata (job.requiredPersonaKey),
8419
+ // not a hardcoded list. Runs launched here are tagged invokedArea='manager'; the same
8420
+ // employee's job launched inside a project stays in that project.
8421
+ const mgrPersonas = (state.bootstrap && state.bootstrap.personas) || [];
8422
+ const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
8423
+ let anyMgrEmployee = false;
8424
+ for (const persona of mgrPersonas) {
8425
+ if (!tfIsPersonaHired(persona.key)) continue;
8426
+ if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
8427
+ if (!anyMgrEmployee) {
8428
+ const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
8429
+ rail.appendChild(head);
8430
+ anyMgrEmployee = true;
8431
+ }
8432
+ tfRenderManagerPersonaGroup(rail, persona);
8433
+ }
8434
+ if (anyMgrEmployee) {
8435
+ const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
8436
+ pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work — its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
8437
+ rail.appendChild(pNote);
8438
+ }
8219
8439
  }
8220
8440
  const profile = document.getElementById('manager-profile');
8221
8441
  const learn = document.getElementById('manager-learnings');
@@ -8847,6 +9067,15 @@ const AREA_ONBOARD_CONFIG = {
8847
9067
  title: 'Manager agreements',
8848
9068
  desc: 'FRAIM will learn how you work and write manager_context.md and manager_rules.md. Add any specific direction below.',
8849
9069
  },
9070
+ // Issue #702: Ashley's manager-tab jobs.
9071
+ 'executive-assistant': {
9072
+ title: 'Ashley — what needs you today',
9073
+ desc: "Ashley reviews the projects you manage plus your calendar and briefs you on what needs your attention. Add any specific focus below.",
9074
+ },
9075
+ 'weekly-operating-review': {
9076
+ title: 'Ashley — weekly operating review',
9077
+ desc: 'Ashley produces your weekly portfolio operating brief. Add any specific focus areas below.',
9078
+ },
8850
9079
  };
8851
9080
 
8852
9081
  function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
@@ -8892,7 +9121,7 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
8892
9121
  const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
8893
9122
  if (job && typeof startRun === 'function') {
8894
9123
  if (targetArea) tfShowArea(targetArea);
8895
- await startRun(job, finalMessage, employeeId);
9124
+ await startRun(job, finalMessage, employeeId, undefined, targetArea);
8896
9125
  // After run creation refresh the area panel so the conversation becomes visible.
8897
9126
  if (targetArea === 'company') tfRenderCompany();
8898
9127
  else if (targetArea === 'manager') tfRenderManager();
@@ -2157,6 +2157,17 @@ button.small { padding: 4px 10px; font-size: 12px; }
2157
2157
  .totals .sep { color: var(--line); }
2158
2158
  .totals strong { color: var(--text); font-weight: 600; }
2159
2159
 
2160
+ /* Issue #711 — adhoc runs have no pizza tracker; render the run totals line on
2161
+ its own without the empty stage rows or a redundant top divider. */
2162
+ .tracker--totals-only {
2163
+ padding: 10px 4px;
2164
+ }
2165
+ .tracker--totals-only .totals {
2166
+ border-top: none;
2167
+ padding-top: 0;
2168
+ margin-top: 0;
2169
+ }
2170
+
2160
2171
 
2161
2172
  /* ── Issue #385: Team roster, Employee selector, Lock badges, Hire notice ── */
2162
2173