fraim-hub 2.0.349 → 2.0.350

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.
@@ -213,6 +213,7 @@ function summarizeProject(projectPath) {
213
213
  path: '',
214
214
  exists: false,
215
215
  hasFraim: false,
216
+ isOnboarded: false,
216
217
  message: 'Choose a project path to load FRAIM jobs.',
217
218
  };
218
219
  }
@@ -221,17 +222,20 @@ function summarizeProject(projectPath) {
221
222
  path: projectPath,
222
223
  exists: false,
223
224
  hasFraim: false,
225
+ isOnboarded: (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath),
224
226
  message: 'Project path does not exist on this machine.',
225
227
  };
226
228
  }
227
229
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
228
230
  const hasFraim = fs_1.default.existsSync(fraimDir);
231
+ const isOnboarded = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath);
229
232
  if (!hasFraim) {
230
233
  return {
231
234
  path: projectPath,
232
235
  exists: true,
233
236
  hasFraim: false,
234
- needsOnboarding: true,
237
+ isOnboarded,
238
+ needsOnboarding: isOnboarded === false,
235
239
  message: 'This folder has not been set up with FRAIM yet. Onboarding will get your AI employee started.',
236
240
  };
237
241
  }
@@ -239,6 +243,8 @@ function summarizeProject(projectPath) {
239
243
  path: projectPath,
240
244
  exists: true,
241
245
  hasFraim: true,
246
+ isOnboarded,
247
+ needsOnboarding: isOnboarded === false,
242
248
  };
243
249
  }
244
250
  function resolveLayerRoot(projectPath, layer) {
@@ -2398,36 +2398,63 @@ function detectCodexCompactTaskFailure(line) {
2398
2398
  }
2399
2399
  return null;
2400
2400
  }
2401
+ const CODEX_PERMANENT_REFRESH_FAILURES = new Set([
2402
+ 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.',
2403
+ 'Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.',
2404
+ 'Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
2405
+ 'Your access token could not be refreshed. Please log out and sign in again.',
2406
+ 'Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.',
2407
+ ]);
2408
+ // Exec has no typed auth code. Scope transport 401s to Codex's own OpenAI
2409
+ // Responses endpoints; tools, MCP servers and custom relays can also return 401.
2410
+ function isCodexProviderAuthFailure(message) {
2411
+ if (typeof message !== 'string')
2412
+ return false;
2413
+ if (message === 'workspace routing discovery unauthorized (401)')
2414
+ return true;
2415
+ const transport = /^unexpected status 401 Unauthorized: [\s\S]*?, url: ([^\s,]+)(?:, [\s\S]*)?$/.exec(message);
2416
+ if (!transport)
2417
+ return false;
2418
+ try {
2419
+ const endpoint = new URL(transport[1]);
2420
+ if (!['https:', 'wss:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.port || endpoint.hash)
2421
+ return false;
2422
+ const responsePath = endpoint.hostname === 'api.openai.com' ? '/v1/responses'
2423
+ : endpoint.hostname === 'chatgpt.com' ? '/backend-api/codex/responses' : null;
2424
+ return responsePath !== null && (endpoint.pathname === responsePath || endpoint.pathname === `${responsePath}/compact`);
2425
+ }
2426
+ catch {
2427
+ return false;
2428
+ }
2429
+ }
2401
2430
  function wireHostProcess(hostId, child, handlers, spawnCwd) {
2402
- // Exec's JSON error has no auth code. Refresh failures need corroborating
2403
- // stderr; workspace-routing 401 needs the exact terminal provider event.
2404
- // Both require a failed process exit; never scan tool/prose text.
2405
- const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
2406
- const codexWorkspaceRoutingFailure = 'workspace routing discovery unauthorized (401)';
2407
- let codexAuthDiagnostic = false;
2408
- let codexTerminalAuthFailure = false;
2409
- let codexTerminalWorkspaceAuthFailure = false;
2431
+ // Refresh failures need matching auth-manager stderr. Provider 401s need a
2432
+ // terminal turn.failed. Both require normal failed exit; never scan prose.
2433
+ const codexAuthDiagnostics = new Set();
2434
+ let codexTerminalRefreshFailure = null;
2435
+ let codexTerminalProviderFailure = null;
2410
2436
  const observeCodexAuth = (line, channel) => {
2411
2437
  if (hostId !== 'codex')
2412
2438
  return;
2413
2439
  if (channel === 'stderr') {
2414
2440
  const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
2415
2441
  const diagnostic = clean.match(/^(?:\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+)?ERROR\s+codex_login::auth::manager:\s+Failed to refresh token: (.+)$/);
2416
- if (diagnostic?.[1] === codexRefreshFailure)
2417
- codexAuthDiagnostic = true;
2442
+ if (diagnostic && CODEX_PERMANENT_REFRESH_FAILURES.has(diagnostic[1]))
2443
+ codexAuthDiagnostics.add(diagnostic[1]);
2418
2444
  return;
2419
2445
  }
2420
2446
  try {
2421
2447
  const event = JSON.parse(line);
2422
2448
  if (event.type === 'turn.failed') {
2423
- codexTerminalWorkspaceAuthFailure = event.error?.message === codexWorkspaceRoutingFailure;
2449
+ codexTerminalProviderFailure = isCodexProviderAuthFailure(event.error?.message) ? event.error.message : null;
2424
2450
  }
2425
2451
  if (event.type === 'turn.failed' || event.type === 'error') {
2426
- codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
2452
+ const message = event.type === 'turn.failed' ? event.error?.message : event.message;
2453
+ codexTerminalRefreshFailure = CODEX_PERMANENT_REFRESH_FAILURES.has(message) ? message : null;
2427
2454
  }
2428
2455
  else if (event.type === 'turn.started' || event.type === 'turn.completed') {
2429
- codexTerminalAuthFailure = false;
2430
- codexTerminalWorkspaceAuthFailure = false;
2456
+ codexTerminalRefreshFailure = null;
2457
+ codexTerminalProviderFailure = null;
2431
2458
  }
2432
2459
  }
2433
2460
  catch { /* Plain stdout cannot establish authentication failure. */ }
@@ -2481,8 +2508,8 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2481
2508
  // `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
2482
2509
  // non-zero branch fires and recovery is attempted.
2483
2510
  child.on('close', (code, signal) => {
2484
- if (!signal && code !== null && code !== 0 && ((codexAuthDiagnostic && codexTerminalAuthFailure) || codexTerminalWorkspaceAuthFailure)) {
2485
- const failure = codexTerminalWorkspaceAuthFailure ? codexWorkspaceRoutingFailure : codexRefreshFailure;
2511
+ const failure = codexTerminalProviderFailure || (codexTerminalRefreshFailure && codexAuthDiagnostics.has(codexTerminalRefreshFailure) ? codexTerminalRefreshFailure : null);
2512
+ if (!signal && code !== null && code !== 0 && failure) {
2486
2513
  handlers.onEvent({ hostAuthenticationRequired: true, hostError: failure, raw: failure }, 'system');
2487
2514
  }
2488
2515
  handlers.onExit(signal ? -1 : code);
@@ -383,20 +383,21 @@ function directoryExists(projectPath) {
383
383
  // project list. This deliberately never falls back to process.cwd() — the launch
384
384
  // directory must not masquerade as a project.
385
385
  //
386
- // Issue #1253: existence alone is not validity. Eligibility is a positive rule —
387
- // a recorded path qualifies only if it IS a FRAIM project. The temp/Home/Desktop/
388
- // Downloads filters narrow that rule further; they do not define it. Framing
389
- // eligibility as an exclusion list is what let C:\Windows stay pinned forever:
390
- // it was simply not on the list, and such a list is unbounded by construction.
386
+ // A recorded folder must be a discoverable FRAIM project or a deliberately added
387
+ // project list entry. The latter remains selectable before onboarding (#2039).
388
+ // Existing temporary/Home/Desktop/Downloads safeguards still apply; a stale bare
389
+ // recorded path is not enough to turn an arbitrary directory into a project.
391
390
  function resolveHubProjectPath(preferencesStore, options = {}) {
392
- const recorded = preferencesStore.load('').projectPath;
391
+ const preferences = preferencesStore.load('');
392
+ const recorded = preferences.projectPath;
393
393
  if (typeof recorded === 'string' && recorded.trim().length > 0) {
394
394
  const resolved = path_1.default.resolve(recorded);
395
395
  const allowedTemporary = options.allowTemporaryRelatedTo
396
396
  ? isRelatedTemporaryProjectPath(resolved, options.allowTemporaryRelatedTo)
397
397
  : false;
398
398
  if (!isLikelyTemporaryProjectPath(resolved) || allowedTemporary) {
399
- if (directoryExists(resolved) && (0, project_fraim_paths_1.workspaceFraimExists)(resolved))
399
+ const explicitlyAdded = preferences.projects?.some((project) => sameDirectoryPath(project.folderPath, resolved));
400
+ if (directoryExists(resolved) && ((0, project_fraim_paths_1.workspaceFraimExists)(resolved) || explicitlyAdded))
400
401
  return { projectPath: resolved };
401
402
  // A missing folder can be a moved project. Report it separately from a
402
403
  // non-FRAIM folder so the Hub can show the rehome path instead of leaving
@@ -419,10 +420,15 @@ function resolveHubProjectPath(preferencesStore, options = {}) {
419
420
  * handed to `preferencesStore.save()`.
420
421
  */
421
422
  function decorateProjectsWithFraimStatus(projects) {
422
- return projects.map((project) => ({
423
- ...project,
424
- hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
425
- }));
423
+ return projects.map((project) => {
424
+ const isOnboarded = project.folderPath ? (0, project_fraim_paths_1.workspaceOnboardingReady)(path_1.default.resolve(project.folderPath)) : false;
425
+ return {
426
+ ...project,
427
+ hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
428
+ isOnboarded,
429
+ needsOnboarding: isOnboarded === false,
430
+ };
431
+ });
426
432
  }
427
433
  class AiHubRunRegistry {
428
434
  constructor() {
@@ -2411,6 +2417,18 @@ function normalizedDirectoryPath(projectPath) {
2411
2417
  function sameDirectoryPath(left, right) {
2412
2418
  return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
2413
2419
  }
2420
+ function onboardingConversationIsActive(conversation) {
2421
+ if (conversation.status === 'running')
2422
+ return true;
2423
+ if (conversation.pauseReason === 'done' || conversation.reviewApproved === true)
2424
+ return false;
2425
+ const handoff = normalizeReviewHandoff(conversation.reviewHandoff);
2426
+ if (handoff)
2427
+ return handoff.reviewRequired;
2428
+ if (conversation.pauseReason === 'awaiting_review')
2429
+ return false;
2430
+ return conversation.status === 'completed' && (conversation.artifacts || []).some((artifact) => Boolean(artifact.path || artifact.url || (('where' in artifact) && artifact.where && artifact.name)));
2431
+ }
2414
2432
  function isPathInsideDirectory(candidatePath, parentPath) {
2415
2433
  const relative = path_1.default.relative(path_1.default.resolve(parentPath), path_1.default.resolve(candidatePath));
2416
2434
  return relative === '' || (!!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
@@ -2480,8 +2498,9 @@ function resolveApiKey() {
2480
2498
  }
2481
2499
  // Read persisted Get-started step states from ~/.fraim/install-state.json
2482
2500
  // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
2483
- // missing key falls back to derivation. Persisted `true` is sticky so the rail
2484
- // never re-shows once a step is completed (R13.4).
2501
+ // missing key falls back to derivation. Persisted `true` is sticky for install,
2502
+ // company, and hire (R13.4). computeFirstRun ignores the legacy project flag:
2503
+ // project readiness is derived live from the selected project's config presence.
2485
2504
  function readPersistedFirstRun() {
2486
2505
  const out = {};
2487
2506
  const apply = (source) => {
@@ -3000,6 +3019,7 @@ class AiHubServer {
3000
3019
  constructor(options = {}) {
3001
3020
  this.app = (0, express_1.default)();
3002
3021
  this.runRegistry = new AiHubRunRegistry();
3022
+ this.projectSelectionGeneration = 0;
3003
3023
  this.activeAvailabilityWarnings = new Set();
3004
3024
  // Issue #925: the last identity successfully resolved from the hosted authority,
3005
3025
  // keyed by the exact apiKey it was resolved for. When the authority is transiently
@@ -3584,10 +3604,26 @@ class AiHubServer {
3584
3604
  this.conversationStore.moveProjectBucket(oldProjectPath, newProjectPath);
3585
3605
  }
3586
3606
  }
3587
- async bootstrapResponse(projectPath) {
3607
+ async bootstrapResponse(projectPath, explicitSelection = false) {
3588
3608
  // #866 R2: guard against path.resolve('') === cwd; '' means "no active project".
3589
3609
  const activeProjectPath = projectPath || this.projectPath;
3590
3610
  const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
3611
+ if (explicitSelection) {
3612
+ this.projectSelectionGeneration += 1;
3613
+ // Record the chosen directory before slow employee/persona reads. A
3614
+ // pathless refresh during that work must already resolve this selection.
3615
+ if (directoryExists(normalizedProjectPath)) {
3616
+ const selectedPreferences = this.preferencesStore.load('');
3617
+ this.preferencesStore.save({
3618
+ ...selectedPreferences,
3619
+ projectPath: normalizedProjectPath,
3620
+ projects: (0, preferences_1.normalizeAiHubProjectList)(selectedPreferences.projects || [], normalizedProjectPath, {
3621
+ removedProjectPaths: selectedPreferences.removedProjectPaths || [],
3622
+ }),
3623
+ });
3624
+ }
3625
+ }
3626
+ const selectionGeneration = this.projectSelectionGeneration;
3591
3627
  // Issue #1010: prefer the non-blocking parallel probe on the first-paint path. On a
3592
3628
  // cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
3593
3629
  // event loop, making every concurrent request wait. Falls back to the sync form for
@@ -3616,7 +3652,8 @@ class AiHubServer {
3616
3652
  const firstAvailable = employees.find((e) => e.available);
3617
3653
  if (firstAvailable) {
3618
3654
  preferences = { ...preferences, employeeId: firstAvailable.id };
3619
- this.preferencesStore.save(preferences);
3655
+ if (selectionGeneration === this.projectSelectionGeneration)
3656
+ this.preferencesStore.save(preferences);
3620
3657
  }
3621
3658
  }
3622
3659
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
@@ -3682,7 +3719,8 @@ class AiHubServer {
3682
3719
  // shell is visible.
3683
3720
  const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
3684
3721
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
3685
- this.preferencesStore.save(preferences);
3722
+ if (selectionGeneration === this.projectSelectionGeneration)
3723
+ this.preferencesStore.save(preferences);
3686
3724
  // Issue #1253: `hasFraim` is decorated AFTER the save above, never before. The
3687
3725
  // line that persists `preferences` takes whatever list it is handed, so a
3688
3726
  // derived field attached upstream of it silently becomes persisted state. See
@@ -5174,8 +5212,7 @@ class AiHubServer {
5174
5212
  const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
5175
5213
  // hire = any persona hired (entitlement active).
5176
5214
  const hireDone = personas.some((p) => p.status === 'hired');
5177
- // project = a project brief exists OR the workspace has FRAIM jobs set up.
5178
- const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
5215
+ const projectDone = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath) === true;
5179
5216
  const derived = {
5180
5217
  install: true, // the Hub is running, so the toolchain install step is satisfied.
5181
5218
  company: companyDone,
@@ -5187,7 +5224,7 @@ class AiHubServer {
5187
5224
  install: persisted.install ?? derived.install,
5188
5225
  company: persisted.company || derived.company,
5189
5226
  hire: persisted.hire || derived.hire,
5190
- project: persisted.project || derived.project,
5227
+ project: derived.project,
5191
5228
  };
5192
5229
  }
5193
5230
  // Issue #512 (S3, R3) — presence + display paths of the three-layer context
@@ -5722,7 +5759,7 @@ class AiHubServer {
5722
5759
  }
5723
5760
  // #719 / local Hub package: a bare reload must land on the user-level saved
5724
5761
  // Hub project, not whatever directory happened to launch the process.
5725
- res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
5762
+ res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath(), !!projectPath));
5726
5763
  });
5727
5764
  this.app.get('/api/ai-hub/personas', async (req, res) => {
5728
5765
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
@@ -6540,7 +6577,7 @@ class AiHubServer {
6540
6577
  .filter((id) => id.length > 0));
6541
6578
  const retainedProjects = this.knownProjects(projectPath).filter((project) => !(project.id && incomingProjectIds.has(project.id)));
6542
6579
  const projects = this.preferencesStore.saveProjects(projectPath, [...retainedProjects, ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
6543
- return res.json({ projectPath, projects, source: 'disk' });
6580
+ return res.json({ projectPath, projects: decorateProjectsWithFraimStatus(projects), source: 'disk' });
6544
6581
  }
6545
6582
  catch (error) {
6546
6583
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
@@ -7160,6 +7197,35 @@ class AiHubServer {
7160
7197
  if (!jobId) {
7161
7198
  throw new Error('Choose a FRAIM job before starting a run.');
7162
7199
  }
7200
+ // A project has one active onboarding workflow, including review gates.
7201
+ // Disk records cover review after restart; optimistic records without a
7202
+ // run id cannot block their own first launch.
7203
+ if (jobId === 'project-onboarding' && scope === 'project') {
7204
+ const conversations = this.conversationStore.loadProject(projectPath).conversations;
7205
+ const liveRun = this.runRegistry.all().find((run) => {
7206
+ if (run.jobId !== jobId || run.scope === 'manager' || run.scope === 'company'
7207
+ || !sameDirectoryPath(run.projectPath, projectPath))
7208
+ return false;
7209
+ const stored = conversations.find((conversation) => conversation.id === (run.conversationId || run.id));
7210
+ return onboardingConversationIsActive({
7211
+ ...run,
7212
+ reviewApproved: stored?.reviewApproved,
7213
+ pauseReason: stored?.pauseReason === 'done' ? 'done' : run.pauseReason,
7214
+ });
7215
+ });
7216
+ const existing = conversations.find((conversation) => !!conversation.runId
7217
+ && conversation.jobId === jobId && conversation.scope !== 'manager' && conversation.scope !== 'company'
7218
+ && !!conversation.projectPath && sameDirectoryPath(conversation.projectPath, projectPath)
7219
+ && onboardingConversationIsActive(conversation));
7220
+ if (liveRun || existing) {
7221
+ return res.status(409).json({
7222
+ code: 'project_onboarding_active',
7223
+ error: 'Project onboarding is already in progress.',
7224
+ existingConversationId: liveRun ? (liveRun.conversationId || liveRun.id) : existing.id,
7225
+ existingRunId: liveRun?.id || existing?.runId,
7226
+ });
7227
+ }
7228
+ }
7163
7229
  // #1394: Dedup guard — return an already-running identical run rather than spawning a second
7164
7230
  // process. Key includes managerDisplay (messages[0].text) so runs for different issues are
7165
7231
  // never blocked even when they share the same jobId + hostId + projectPath.
@@ -7,6 +7,7 @@ exports.WORKSPACE_SYNCED_CONTENT_DIRS = exports.WORKSPACE_FRAIM_DIRNAME = void 0
7
7
  exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
8
8
  exports.workspaceFraimExists = workspaceFraimExists;
9
9
  exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
10
+ exports.workspaceOnboardingReady = workspaceOnboardingReady;
10
11
  exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
11
12
  exports.assertWorkspaceFraimRoot = assertWorkspaceFraimRoot;
12
13
  exports.assertWorkspaceFraimPath = assertWorkspaceFraimPath;
@@ -75,6 +76,19 @@ function workspaceFraimExists(projectRoot = process.cwd()) {
75
76
  function getWorkspaceConfigPath(projectRoot = process.cwd()) {
76
77
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), 'config.json');
77
78
  }
79
+ /** Config presence is readiness; unreadable filesystem state is not absence. */
80
+ function workspaceOnboardingReady(projectRoot) {
81
+ if (!projectRoot)
82
+ return false;
83
+ try {
84
+ (0, fs_1.statSync)(getWorkspaceConfigPath(projectRoot));
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ const code = error.code;
89
+ return code === 'ENOENT' || code === 'ENOTDIR' ? false : null;
90
+ }
91
+ }
78
92
  function getWorkspaceFraimPath(projectRoot = process.cwd(), ...parts) {
79
93
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), ...parts);
80
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.349",
3
+ "version": "2.0.350",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -246,7 +246,7 @@
246
246
  "electron-updater": "^6.8.9",
247
247
  "express": "^5.2.1",
248
248
  "extract-zip": "^2.0.1",
249
- "fraim": "2.0.349",
249
+ "fraim": "2.0.350",
250
250
  "mongodb": "^7.0.0",
251
251
  "node-cron": "4.2.1",
252
252
  "node-edge-tts": "^1.2.10",
@@ -289,6 +289,7 @@ async function requestJson(url, options, timeoutMs) {
289
289
  // #719: callers branch on the HTTP status (e.g. DELETE 404 already-removed vs
290
290
  // 409 sequencing error) — carry it on the thrown error.
291
291
  error.status = response.status;
292
+ error.payload = payload;
292
293
  throw error;
293
294
  }
294
295
  return payload;
@@ -434,7 +435,7 @@ function applyBootstrap(bootstrap, docUrl) {
434
435
  }
435
436
  // Render header project name + status line.
436
437
  els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
437
- if (!bootstrap.project.exists || !bootstrap.project.hasFraim) showStatus(bootstrap.project.message, true);
438
+ if (bootstrap.project.path && !bootstrap.project.exists) showStatus(bootstrap.project.message, true);
438
439
  // Update the compact project label shown in task-pane surface rail.
439
440
  const tpLabel = document.getElementById('tp-project-label');
440
441
  if (tpLabel) {
@@ -1748,6 +1749,8 @@ async function hydrateConversationsFromServer() {
1748
1749
  .filter((conv) => conv && !deletedConversationIds.has(conv.id));
1749
1750
  const mergedHeaders = mergeConversationListHeaders(state.conversations[projectPath] || [], headers);
1750
1751
  state.conversations[projectPath] = mergedHeaders;
1752
+ state._projectConversationFacts = state._projectConversationFacts || {};
1753
+ state._projectConversationFacts[tfCanonicalProjectPath(projectPath)] = true;
1751
1754
  pickActive(mergedHeaders, headerPayload.activeId);
1752
1755
  state.conversationDiskAvailable = true;
1753
1756
  state.conversationBodiesPending = projectPath;
@@ -1769,6 +1772,8 @@ async function hydrateProjectConversationHeaders(projectPath) {
1769
1772
  const incoming = (Array.isArray(payload.conversations) ? payload.conversations : [])
1770
1773
  .filter((conv) => conv && !deletedConversationIds.has(conv.id));
1771
1774
  const existing = state.conversations[projectPath] || [];
1775
+ state._projectConversationFacts = state._projectConversationFacts || {};
1776
+ state._projectConversationFacts[tfCanonicalProjectPath(projectPath)] = true;
1772
1777
  const merged = mergeConversationListHeaders(existing, incoming);
1773
1778
  const changed = existing.length !== merged.length
1774
1779
  || merged.some((conv, index) => conversationProjectionChanged(existing[index], conv));
@@ -2994,21 +2999,37 @@ function appendEmployeeGroupWithActions(container, details) {
2994
2999
  function renderRail() {
2995
3000
  renderTeamRoster();
2996
3001
  renderScopeEmployees(els['conv-list'], 'projects');
3002
+ tfRenderProjectOnboardingAction();
2997
3003
  }
2998
3004
 
2999
3005
  // Availability is a projection of the same readiness used by each Info view.
3000
3006
  // It gates rail assignment only; historical work and onboarding stay reachable.
3007
+ function projectOnboardingReadiness(conversations = projectConversations()) {
3008
+ const projectKey = tfCanonicalProjectPath(state.projectPath);
3009
+ const project = state.bootstrap && state.bootstrap.project;
3010
+ const current = project && tfCanonicalProjectPath(project.path) === projectKey;
3011
+ const known = !!(current && typeof project.isOnboarded === 'boolean');
3012
+ const pending = conversations.find(c => c && c.jobId === 'project-onboarding'
3013
+ && convScope(c) === 'project' && tfCanonicalProjectPath(c.projectPath) === projectKey
3014
+ && (c.status === 'running' || convAwaitingReview(c)));
3015
+ const ready = known && project.isOnboarded;
3016
+ const runsKnown = !!(state._projectConversationFacts && state._projectConversationFacts[projectKey]);
3017
+ return {
3018
+ ready, pending, known, runsKnown,
3019
+ needsSetup: known && !ready && runsKnown && !pending,
3020
+ message: ready ? '' : pending ? 'Project onboarding is in progress'
3021
+ : known ? 'Project onboarding needs to be run' : 'Project onboarding status is unavailable',
3022
+ };
3023
+ }
3024
+
3001
3025
  function employeeRailReadiness(area, conversations) {
3002
- const scopeName = area === 'projects' ? 'Project' : area === 'company' ? 'Company' : 'Manager';
3026
+ if (area === 'projects') return projectOnboardingReadiness(conversations);
3027
+ const scopeName = area === 'company' ? 'Company' : 'Manager';
3003
3028
  const key = area === 'company' ? 'org' : 'manager';
3004
- const project = state.bootstrap && state.bootstrap.project;
3005
- const context = area === 'projects' ? null : tfCtxCache()[key];
3006
- const currentProject = project && tfCanonicalProjectPath(project.path) === tfCanonicalProjectPath(state.projectPath);
3007
- const ready = area === 'projects'
3008
- ? currentProject && project.hasFraim && !project.needsOnboarding
3009
- : context && context.present;
3029
+ const context = tfCtxCache()[key];
3030
+ const ready = context && context.present;
3010
3031
  if (ready) return { ready: true, message: '' };
3011
- const jobId = area === 'projects' ? 'project-onboarding' : area === 'company' ? 'organization-onboarding' : 'manager-agreements';
3032
+ const jobId = area === 'company' ? 'organization-onboarding' : 'manager-agreements';
3012
3033
  const pending = conversations.find(c => c.jobId === jobId && (c.status === 'running' || convAwaitingReview(c)));
3013
3034
  return { ready: false, message: scopeName + ' onboarding is incomplete.', pending };
3014
3035
  }
@@ -3018,6 +3039,12 @@ function renderScopeEmployees(host, area) {
3018
3039
  if (!host) return;
3019
3040
  const isProject = area === 'projects';
3020
3041
  const activeRailProjectKey = isProject ? (tf.activeProjectId || state.projectPath || 'current') : area;
3042
+ const focusedSummary = isProject && host.contains(document.activeElement)
3043
+ && document.activeElement.matches('summary.conv-employee-tab') ? document.activeElement : null;
3044
+ const focusedGroup = focusedSummary && focusedSummary.closest('details');
3045
+ const focusedKey = focusedGroup && focusedGroup.dataset.projectKey === activeRailProjectKey
3046
+ ? focusedGroup.dataset.employeeKey : null;
3047
+ const tooltipWasVisible = focusedSummary && focusedSummary.querySelector('.employee-readiness-tooltip:not([hidden])');
3021
3048
  const employeeGroupOpenState = new Map();
3022
3049
  host.querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
3023
3050
  const key = details.dataset ? details.dataset.employeeKey : '';
@@ -3209,7 +3236,7 @@ function renderScopeEmployees(host, area) {
3209
3236
  }
3210
3237
  host.appendChild(inProgressSec);
3211
3238
  const readinessId = 'employee-readiness-' + area;
3212
- if (!readiness.ready && sortedDoneGroups.some(group => !group.unowned)) {
3239
+ if (!isProject && !readiness.ready && sortedDoneGroups.some(group => !group.unowned)) {
3213
3240
  const note = document.createElement('p');
3214
3241
  note.id = readinessId;
3215
3242
  note.className = 'employee-readiness-note';
@@ -3239,13 +3266,39 @@ function renderScopeEmployees(host, area) {
3239
3266
  const details = document.createElement('details');
3240
3267
  details.className = 'conv-employee-group';
3241
3268
  if (group.unowned) details.classList.add('conv-employee-group--adhoc');
3242
- else if (!readiness.ready) details.classList.add('conv-employee-group--unavailable');
3269
+ else if (!readiness.ready) {
3270
+ details.classList.add('conv-employee-group--unavailable');
3271
+ if (isProject) details.classList.add('conv-employee-group--project-unavailable');
3272
+ }
3243
3273
  details.dataset.employeeKey = group.key;
3244
3274
  details.dataset.projectKey = activeRailProjectKey;
3245
3275
  details.open = preservedEmployeeGroupOpen(group.key, !!group.unowned);
3246
3276
  const summary = document.createElement('summary');
3247
3277
  summary.className = 'conv-employee-tab';
3248
- if (!group.unowned && !readiness.ready) summary.title = readiness.message;
3278
+ if (!group.unowned && !readiness.ready) {
3279
+ if (isProject) {
3280
+ const tip = document.createElement('span');
3281
+ tip.id = readinessId + '-' + sortedDoneGroups.indexOf(group);
3282
+ tip.className = 'employee-readiness-tooltip';
3283
+ tip.setAttribute('role', 'tooltip');
3284
+ tip.textContent = readiness.message;
3285
+ tip.hidden = true;
3286
+ summary.setAttribute('aria-describedby', tip.id);
3287
+ const show = () => {
3288
+ tip.hidden = false;
3289
+ const rect = summary.getBoundingClientRect();
3290
+ tip.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - tip.offsetWidth - 8)) + 'px';
3291
+ tip.style.top = Math.max(8, Math.min(rect.bottom, window.innerHeight - tip.offsetHeight - 8)) + 'px';
3292
+ };
3293
+ const hide = () => { tip.hidden = true; };
3294
+ summary.addEventListener('mouseenter', show);
3295
+ summary.addEventListener('focus', show);
3296
+ summary.addEventListener('mouseleave', () => { if (document.activeElement !== summary) hide(); });
3297
+ summary.addEventListener('blur', hide);
3298
+ summary.addEventListener('keydown', (event) => { if (event.key === 'Escape') { hide(); event.stopPropagation(); } });
3299
+ summary.appendChild(tip);
3300
+ } else summary.title = readiness.message;
3301
+ }
3249
3302
  const avatar = group.unowned ? buildNoFraimEmployeeAvatar('conv-employee-avatar') : buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
3250
3303
  const copy = document.createElement('span');
3251
3304
  copy.className = 'conv-employee-tab-copy';
@@ -3274,7 +3327,8 @@ function renderScopeEmployees(host, area) {
3274
3327
  if (!readiness.ready) {
3275
3328
  addBtn.title = readiness.message;
3276
3329
  addBtn.setAttribute('aria-disabled', 'true');
3277
- addBtn.setAttribute('aria-describedby', readinessId);
3330
+ if (isProject) { addBtn.disabled = true; addBtn.tabIndex = -1; }
3331
+ else addBtn.setAttribute('aria-describedby', readinessId);
3278
3332
  }
3279
3333
  addBtn.addEventListener('click', (e) => {
3280
3334
  e.preventDefault();
@@ -3358,6 +3412,15 @@ function renderScopeEmployees(host, area) {
3358
3412
  if (runsSection) runsSection.hidden = false;
3359
3413
  // #824: re-attach an open run-delete confirm that this rebuild would otherwise drop.
3360
3414
  tfReopenPendingRunDelete(host);
3415
+ if (focusedKey) {
3416
+ const replacement = [...host.querySelectorAll('details[data-employee-key]')]
3417
+ .find(details => details.dataset.employeeKey === focusedKey)?.querySelector(':scope > summary');
3418
+ if (replacement) {
3419
+ replacement.focus({ preventScroll: true });
3420
+ const tooltip = replacement.querySelector('.employee-readiness-tooltip');
3421
+ if (tooltip && !tooltipWasVisible) tooltip.hidden = true;
3422
+ }
3423
+ }
3361
3424
  }
3362
3425
 
3363
3426
  function statusLabel(s) {
@@ -9206,8 +9269,10 @@ async function checkAgentReady(hubId) {
9206
9269
  }
9207
9270
 
9208
9271
  async function refreshEmployees() {
9272
+ const projectPath = state.projectPath;
9209
9273
  try {
9210
- const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
9274
+ const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(projectPath)}`);
9275
+ if (tfCanonicalProjectPath(state.projectPath) !== tfCanonicalProjectPath(projectPath)) return;
9211
9276
  applyBootstrap(bootstrap);
9212
9277
  renderCpAgentPicker();
9213
9278
  // Issue #1256 (slice b): re-render the install panel + status cards too, not just the
@@ -9280,6 +9345,14 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
9280
9345
  // looking at. stubPath resolution and FRAIM invocation are now server-side.
9281
9346
  const effectiveInstructions = await withWordContext(instructions);
9282
9347
 
9348
+ // Repeated onboarding starts open the same project-owned work, including a
9349
+ // locally queued request that has not received its server run id yet.
9350
+ if (job.id === 'project-onboarding' && context.area === 'projects'
9351
+ && tfCanonicalProjectPath(context.projectPath) === tfCanonicalProjectPath(state.projectPath)) {
9352
+ const pending = projectOnboardingReadiness().pending;
9353
+ if (pending) { await switchToConversation(pending.id); return { ok: true }; }
9354
+ }
9355
+
9283
9356
  const assignedPersonaKey = assignedPersonaKeyForJob(job);
9284
9357
  const assignedPersona = assignedPersonaKey && assignedPersonaKey.startsWith('custom:')
9285
9358
  ? (typeof tfPersonaByKey === 'function' ? tfPersonaByKey(assignedPersonaKey) : null)
@@ -9331,7 +9404,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
9331
9404
  return (a === 'manager' || a === 'company') ? a : 'project';
9332
9405
  })(),
9333
9406
  };
9334
- upsertConversation(conv);
9407
+ upsertConversation(conv, { disk: job.id !== 'project-onboarding' });
9335
9408
  state.activeId = conv.id;
9336
9409
  tf.areaSelection[tfAreaSelectionKey(context.area)] = conv.id;
9337
9410
  persistActiveConversationSelection(conv.id);
@@ -9383,6 +9456,14 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
9383
9456
  }
9384
9457
  return { ok: true, conversation: conv, run };
9385
9458
  } catch (error) {
9459
+ if (job.id === 'project-onboarding' && error.status === 409
9460
+ && error.payload?.code === 'project_onboarding_active' && error.payload.existingConversationId) {
9461
+ const key = convBucketKey(conv);
9462
+ const bucket = state.conversations[key];
9463
+ if (Array.isArray(bucket)) state.conversations[key] = bucket.filter(c => c.id !== conv.id);
9464
+ await openExistingProjectOnboarding(error.payload.existingConversationId, conv.projectPath);
9465
+ return { ok: true };
9466
+ }
9386
9467
  const message = error && error.message ? error.message : 'Could not start the run.';
9387
9468
  conv.status = 'failed';
9388
9469
  conv.events.push({ channel: 'system', text: message });
@@ -11093,59 +11174,13 @@ function beginHirePendingPoll() {
11093
11174
  }
11094
11175
 
11095
11176
  async function autoOnboardProject() {
11096
- const employeeId = state.selectedEmployeeId || 'claude';
11097
- const onboardingMessage = "Your AI employee is onboarding to this project. They'll explore the codebase, learn how it's structured, and may ask a few questions so they can contribute meaningfully.";
11098
- const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
11099
- if (job) {
11100
- await startRun(job, onboardingMessage, employeeId);
11101
- return;
11102
- }
11103
- // Fallback: project-onboarding job not in catalog (fraim/ doesn't exist yet) — POST directly.
11104
- try {
11105
- const conversationId = newConversationId();
11106
- const run = await requestJson('/api/ai-hub/runs', {
11107
- method: 'POST',
11108
- headers: { 'Content-Type': 'application/json' },
11109
- body: JSON.stringify({
11110
- projectPath: state.projectPath,
11111
- hostId: baseHostIdForAgent(employeeId),
11112
- configuredAgentId: employeeId,
11113
- jobId: 'project-onboarding',
11114
- jobTitle: 'Project Onboarding',
11115
- conversationId,
11116
- conversationTitle: 'Project Onboarding',
11117
- message: onboardingMessage,
11118
- }),
11119
- });
11120
- const startedAt = Date.now();
11121
- const conv = {
11122
- id: conversationId,
11123
- projectPath: state.projectPath,
11124
- title: 'Project Onboarding',
11125
- jobId: 'project-onboarding',
11126
- jobTitle: 'Project Onboarding',
11127
- agentName: employeeId,
11128
- configuredAgentId: employeeId,
11129
- configuredAgentLabel: configuredAgentLabel(employeeId),
11130
- baseHostId: baseHostIdForAgent(employeeId),
11131
- runId: run.id,
11132
- sessionId: run.sessionId || null,
11133
- status: 'running',
11134
- messages: [{ role: 'manager', text: onboardingMessage, at: Date.now() }],
11135
- events: [],
11136
- artifacts: [],
11137
- createdAt: startedAt,
11138
- lastUpdatedAt: startedAt,
11139
- };
11140
- foldRunIntoConversation(conv, run);
11141
- upsertConversation(conv);
11142
- state.activeId = conv.id;
11143
- persistActiveConversationSelection(conv.id);
11144
- renderRail();
11145
- renderActive();
11146
- startPolling();
11147
- } catch (err) {
11148
- showStatus(err.message, true);
11177
+ return tfStartProjectOnboarding();
11178
+ }
11179
+
11180
+ async function openExistingProjectOnboarding(conversationId, projectPath) {
11181
+ await hydrateProjectConversationHeaders(projectPath);
11182
+ if (tfCanonicalProjectPath(state.projectPath) === tfCanonicalProjectPath(projectPath)) {
11183
+ await switchToConversation(conversationId);
11149
11184
  }
11150
11185
  }
11151
11186
 
@@ -11257,57 +11292,10 @@ function renderFirstRunLanding(mode) {
11257
11292
  }
11258
11293
  return;
11259
11294
  }
11260
- // Legacy first-run path: auto-start project-onboarding.
11261
- startBtn.textContent = 'Starting…';
11262
- renderActive();
11263
- const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
11264
- const employeeId = state.selectedEmployeeId || 'claude';
11265
- if (job) {
11266
- await startRun(job, 'Onboard this project', employeeId);
11267
- } else {
11268
- try {
11269
- const run = await requestJson('/api/ai-hub/runs', {
11270
- method: 'POST',
11271
- headers: { 'Content-Type': 'application/json' },
11272
- body: JSON.stringify({
11273
- projectPath: state.projectPath,
11274
- hostId: baseHostIdForAgent(employeeId),
11275
- configuredAgentId: employeeId,
11276
- jobId: 'project-onboarding',
11277
- message: 'Onboard this project',
11278
- }),
11279
- });
11280
- const startedAt = Date.now();
11281
- const conv = {
11282
- id: newConversationId(),
11283
- projectPath: state.projectPath,
11284
- title: 'Project Onboarding',
11285
- jobId: 'project-onboarding',
11286
- jobTitle: 'Project Onboarding',
11287
- agentName: employeeId,
11288
- configuredAgentId: employeeId,
11289
- configuredAgentLabel: configuredAgentLabel(employeeId),
11290
- baseHostId: baseHostIdForAgent(employeeId),
11291
- runId: run.id,
11292
- sessionId: run.sessionId || null,
11293
- status: 'running',
11294
- messages: [{ role: 'manager', text: 'Onboard this project', at: Date.now() }],
11295
- events: [],
11296
- artifacts: [],
11297
- createdAt: startedAt,
11298
- lastUpdatedAt: startedAt,
11299
- };
11300
- foldRunIntoConversation(conv, run);
11301
- upsertConversation(conv);
11302
- state.activeId = conv.id;
11303
- persistActiveConversationSelection(conv.id);
11304
- renderRail();
11305
- renderActive();
11306
- startPolling();
11307
- } catch (err) {
11308
- showStatus(err.message, true);
11309
- }
11310
- }
11295
+ const selected = tfEnsureCurrentProject();
11296
+ tfShowArea('projects');
11297
+ await tfSelectProjectView('workspace', selected && selected.id, { confirmSetup: false });
11298
+ await tfStartProjectOnboarding();
11311
11299
  });
11312
11300
  card.appendChild(startBtn);
11313
11301
 
@@ -12128,11 +12116,10 @@ function tfNormalizeProject(raw, fallbackPath) {
12128
12116
  // Roster is derived from hired personas at render time (tfProjectTeamKeys),
12129
12117
  // never stored per machine. Any legacy `team` on stored state is dropped.
12130
12118
  updatedAt: raw.updatedAt || undefined,
12131
- // Issue #1253: the server's response-only marker for whether this folder is
12132
- // actually a FRAIM project. `undefined` means the server did not say — only an
12133
- // explicit false marks an entry as not set up, so a locally minted entry is
12134
- // never mistaken for one.
12119
+ // Discovery and onboarding readiness are separate response-only facts.
12120
+ // Undefined means the server did not provide a known result.
12135
12121
  hasFraim: typeof raw.hasFraim === 'boolean' ? raw.hasFraim : undefined,
12122
+ isOnboarded: typeof raw.isOnboarded === 'boolean' ? raw.isOnboarded : undefined,
12136
12123
  };
12137
12124
  }
12138
12125
 
@@ -12157,21 +12144,9 @@ function tfProjectForPath(folderPath) {
12157
12144
  return tf.projects.find((project) => tfCanonicalProjectPath(project.folderPath || project.path || project.folder) === key) || null;
12158
12145
  }
12159
12146
 
12160
- /**
12161
- * Issue #1253: is this LISTED project a FRAIM project?
12162
- *
12163
- * Tri-state on purpose. `hasFraim` is a response-only decoration, so `undefined`
12164
- * means "the server did not say" — a locally minted entry, or a list from before the
12165
- * field existed — and must NOT read as "not set up". Only an explicit `false` does.
12166
- * Writing `!project.hasFraim` inline instead would quietly mark every undecorated
12167
- * entry as unset, which is why this is a named predicate rather than a comparison
12168
- * repeated at each site.
12169
- *
12170
- * Note this takes a project-list ENTRY. `bootstrap.project` is a different shape
12171
- * (`AiHubProjectSummary`), where `hasFraim` is a required boolean.
12172
- */
12147
+ // Listed readiness is response-only. Unknown facts do not label a project unset.
12173
12148
  function tfProjectIsSetUp(project) {
12174
- return !project || project.hasFraim !== false;
12149
+ return !project || project.isOnboarded !== false;
12175
12150
  }
12176
12151
 
12177
12152
  /**
@@ -12186,6 +12161,7 @@ function tfProjectIsSetUp(project) {
12186
12161
  function tfAskProjectSetup(folderPath) {
12187
12162
  const modal = document.getElementById('setup-project-modal');
12188
12163
  if (!modal) return Promise.resolve('cancel');
12164
+ const previousFocus = document.activeElement;
12189
12165
  const folder = document.getElementById('spm-folder');
12190
12166
  if (folder) folder.textContent = folderPath || '';
12191
12167
  modal.hidden = false;
@@ -12194,8 +12170,17 @@ function tfAskProjectSetup(folderPath) {
12194
12170
  const finish = (choice) => {
12195
12171
  modal.hidden = true;
12196
12172
  for (const [el, handler] of wired) el.removeEventListener('click', handler);
12173
+ document.removeEventListener('keydown', onEscape, true);
12174
+ if (previousFocus && previousFocus.isConnected) previousFocus.focus({ preventScroll: true });
12197
12175
  resolve(choice);
12198
12176
  };
12177
+ const onEscape = (event) => {
12178
+ if (event.key !== 'Escape') return;
12179
+ event.preventDefault();
12180
+ event.stopPropagation();
12181
+ finish('cancel');
12182
+ };
12183
+ document.addEventListener('keydown', onEscape, true);
12199
12184
  const bind = (id, choice) => {
12200
12185
  const el = document.getElementById(id);
12201
12186
  if (!el) return;
@@ -12207,24 +12192,25 @@ function tfAskProjectSetup(folderPath) {
12207
12192
  bind('spm-choose', 'choose');
12208
12193
  bind('spm-cancel', 'cancel');
12209
12194
  bind('spm-close', 'cancel');
12195
+ document.getElementById('spm-cancel')?.focus();
12210
12196
  });
12211
12197
  }
12212
12198
 
12213
12199
  /**
12214
12200
  * Issue #1253 R3: the single place that decides whether to scaffold a folder.
12215
12201
  *
12216
- * `needsOnboarding` is the server's own `exists && !hasFraim`, so this fires on every
12217
- * path that lands on such a bootstrap — the page load, and a project switch from the
12218
- * Overview or a project tab — rather than being duplicated per entry point. Nothing
12219
- * is written unless the user chooses Set up; Cancel and dismissal both leave the
12220
- * folder exactly as it was.
12221
- *
12202
+ * Config presence and same-project active work come from the same readiness
12203
+ * projection as the employee rail. Refresh run headers before offering setup;
12204
+ * missing facts defer the dialog. Cancel preserves the selected folder.
12222
12205
  * Returns true when setup was started.
12223
12206
  */
12224
12207
  async function tfMaybeConfirmProjectSetup() {
12225
- const project = (state.bootstrap && state.bootstrap.project) || null;
12226
- if (!project || !project.needsOnboarding || !state.projectPath) return false;
12227
- const choice = await tfAskProjectSetup(state.projectPath);
12208
+ const projectPath = state.projectPath;
12209
+ if (!projectPath) return false;
12210
+ try { await hydrateProjectConversationHeaders(projectPath); } catch (_) { return false; }
12211
+ if (state.projectPath !== projectPath || !projectOnboardingReadiness().needsSetup) return false;
12212
+ const choice = await tfAskProjectSetup(projectPath);
12213
+ if (state.projectPath !== projectPath) return false;
12228
12214
  if (choice === 'setup') {
12229
12215
  await autoOnboardProject();
12230
12216
  return true;
@@ -12566,7 +12552,7 @@ function tfShowArea(area) {
12566
12552
  window.scrollTo(0, 0);
12567
12553
  }
12568
12554
 
12569
- async function tfSelectProjectView(view, projectId) {
12555
+ async function tfSelectProjectView(view, projectId, options) {
12570
12556
  tf.projectView = view;
12571
12557
  if (projectId) tf.activeProjectId = projectId;
12572
12558
  // Always return to the conversation when changing views.
@@ -12593,13 +12579,14 @@ async function tfSelectProjectView(view, projectId) {
12593
12579
  if (folderPath && tfCanonicalProjectPath(folderPath) !== tfCanonicalProjectPath(state.projectPath)) {
12594
12580
  try {
12595
12581
  tfSetProjectSwitching(true, proj);
12596
- await tfSwitchProjectFolder(folderPath);
12582
+ await tfSwitchProjectFolder(folderPath, options);
12597
12583
  } catch (e) {
12598
12584
  console.warn('[512] tfSwitchProjectFolder failed:', e);
12599
12585
  } finally {
12600
12586
  tfSetProjectSwitching(false);
12601
12587
  }
12602
12588
  }
12589
+ if (folderPath && tfCanonicalProjectPath(folderPath) !== tfCanonicalProjectPath(state.projectPath)) return;
12603
12590
  // #521 fix: invalidate the project-scoped context cache whenever the active
12604
12591
  // project changes — covers new-project creation (where state.projectPath is
12605
12592
  // already updated, so tfSwitchProjectFolder's guard skips its own clear) and
@@ -13348,45 +13335,14 @@ function tfSelectAssignmentConversation(assignment) {
13348
13335
  // conversation pane — always present, individually collapsible — replacing the
13349
13336
  // old over-conversation panels. The editable brief rows and the learnings list
13350
13337
  // are unchanged; only their home moved.
13351
- // #521: state of the project-onboarding run for THIS project — drives the
13352
- // "team is processing / here's their understanding" framing in the Brief.
13353
- function tfProjectOnboardingRun() {
13354
- let running = null, awaiting = null;
13355
- for (const c of projectConversations()) {
13356
- if (!c || c.jobId !== 'project-onboarding') continue;
13357
- if (c.status === 'running') running = c;
13358
- else if (typeof convAwaitingReview === 'function' && convAwaitingReview(c)) awaiting = c;
13359
- }
13360
- return { running, awaiting };
13361
- }
13362
-
13363
13338
  function tfRenderProjectContextTop() {
13364
13339
  const heading = document.getElementById('project-info-heading');
13365
13340
  const project = tf.projects.find((entry) => entry.id === tf.activeProjectId);
13366
13341
  if (heading) heading.textContent = project ? project.name : ((state.projectPath || '').split(/[\\/]/).filter(Boolean).pop() || 'Project');
13367
13342
  // Issue #1198: which folder this project points at, always on screen.
13368
13343
  tfRenderProjectFolderRow();
13369
- // Onboarding loop framing renders ABOVE the accordions (#proj-onboarding-banner)
13370
- // so it's visible even when the Brief is collapsed — collapsing the Brief by
13371
- // default is what gives the conversation thread room to breathe.
13372
13344
  const obBannerHost = document.getElementById('proj-onboarding-banner');
13373
- if (obBannerHost) {
13374
- obBannerHost.innerHTML = '';
13375
- const ob = tfProjectOnboardingRun();
13376
- if (ob.running || ob.awaiting) {
13377
- const banner = document.createElement('div');
13378
- banner.className = 'ob-brief-banner' + (ob.awaiting ? ' ob-review' : ' ob-processing');
13379
- if (ob.awaiting) {
13380
- // #521: the run + Approve now live in the conversation view (open the
13381
- // Project Onboarding run from the sidebar). This page is for reviewing
13382
- // and editing the captured context — edits save to the project.
13383
- banner.innerHTML = '<span class="obb-ico">✅</span><div><strong>Onboarding finished.</strong> Review the captured context below and edit anything that\'s off. Your edits save to the project. Then open the <strong>Project Onboarding</strong> run in the sidebar to <strong>Approve</strong> (or request changes).</div>';
13384
- } else {
13385
- banner.innerHTML = '<span class="obb-ico">🤝</span><div><strong>Your FRAIM team is processing the project context.</strong> They\'ll fill in the Brief below for you to review and correct. Track progress in the <strong>Project Onboarding</strong> run in the sidebar.</div>';
13386
- }
13387
- obBannerHost.appendChild(banner);
13388
- }
13389
- }
13345
+ if (obBannerHost) obBannerHost.replaceChildren();
13390
13346
  const briefHost = document.getElementById('proj-brief-profile');
13391
13347
  if (briefHost) tfRenderBriefProfile(briefHost);
13392
13348
  const learnHost = document.getElementById('project-learnings');
@@ -13397,7 +13353,7 @@ function tfRenderProjectContextTop() {
13397
13353
  // #693 R1: the retired "Project Updates" section's actions now live where their
13398
13354
  // output lands — Run Project Onboarding in the Brief section, Sleep on learnings
13399
13355
  // in the Learnings section.
13400
- tfEnsureAccAction('proj-brief-acc', '🚀', 'Run Project Onboarding', () => tfOpenOnboardInput('run'));
13356
+ tfRenderProjectOnboardingAction();
13401
13357
  tfEnsureAccActions('proj-learnings-acc', [
13402
13358
  { ico: '🧠', label: 'Sleep on learnings', onClick: () => tfRunShareLearnings('project') },
13403
13359
  { ico: '↑', label: 'Share with others', onClick: () => {
@@ -13416,6 +13372,16 @@ function tfRenderProjectContextTop() {
13416
13372
  // #693 R1: insert (idempotently) a single lifecycle-action button at the top of a
13417
13373
  // ctx accordion's body. Used to place onboarding/sleep actions inside the Brief,
13418
13374
  // Learnings, and Company/Manager context sections they populate.
13375
+ function tfRenderProjectOnboardingAction() {
13376
+ const onboarding = projectOnboardingReadiness();
13377
+ const label = onboarding.pending ? 'Continue onboarding' : onboarding.ready ? 'Reprocess project onboarding' : 'Run Project Onboarding';
13378
+ tfEnsureAccAction('proj-brief-acc', '', label, () => {
13379
+ const current = projectOnboardingReadiness();
13380
+ if (current.pending) switchToConversation(current.pending.id);
13381
+ else tfOpenOnboardInput(current.ready ? 'reprocess' : 'run');
13382
+ });
13383
+ }
13384
+
13419
13385
  function tfEnsureAccAction(accId, ico, label, onClick) {
13420
13386
  tfEnsureAccActions(accId, [{ ico, label, onClick }]);
13421
13387
  }
@@ -13446,12 +13412,11 @@ function tfEnsureAccActions(accId, actions) {
13446
13412
  }
13447
13413
  }
13448
13414
 
13449
- // #521: the Brief section is the project's real captured context + rules — the two
13450
- // files Project Onboarding writes (project_context.md, project_rules.md). There is
13451
- // no separate "brief" row (it was redundant with context). When neither file has
13452
- // content yet, show a single "Run Project Onboarding" call-to-action instead of
13453
- // empty rows; once there is content, show the rows + a quiet "Re-run".
13415
+ // Brief renders project-owned context and rules; the action row is shared with
13416
+ // readiness updates so its single CTA remains current while runs change state.
13454
13417
  async function tfRenderBriefProfile(host) {
13418
+ const projectPath = tfCanonicalProjectPath(state.projectPath);
13419
+ const renderId = host._briefRenderId = (host._briefRenderId || 0) + 1;
13455
13420
  host.innerHTML = '';
13456
13421
  const loading = document.createElement('div');
13457
13422
  loading.className = 'ctx-loading';
@@ -13464,42 +13429,27 @@ async function tfRenderBriefProfile(host) {
13464
13429
  return c ? Promise.resolve(c) : tfFetchContext(key).catch(() => null);
13465
13430
  };
13466
13431
  const [ctx, rules] = await Promise.all([getCtx('projectContext'), getCtx('projectRules')]);
13467
- if (!host.isConnected) return; // re-rendered while awaiting
13432
+ if (!host.isConnected || host._briefRenderId !== renderId || projectPath !== tfCanonicalProjectPath(state.projectPath)) return;
13468
13433
  host.innerHTML = '';
13469
13434
 
13470
13435
  const has = (d) => !!(d && d.present && d.content && String(d.content).trim());
13471
13436
  if (!has(ctx) && !has(rules)) {
13472
- // Nothing captured yet → just the Run Project Onboarding CTA (shared .ctx-cta
13473
- // shape so the Brief and Learnings empty states look identical).
13474
13437
  const cta = document.createElement('div');
13475
13438
  cta.className = 'ctx-cta';
13476
13439
  const msg = document.createElement('div');
13477
13440
  msg.className = 'ctx-cta-msg';
13478
- msg.textContent = "Nothing captured yet. Run Project Onboarding and your team will explore the project and fill in its context and rules here.";
13479
- const btn = document.createElement('button');
13480
- btn.className = 'ctx-cta-btn';
13481
- btn.type = 'button';
13482
- btn.textContent = '🚀 Run Project Onboarding';
13483
- btn.addEventListener('click', () => { tfOpenOnboardInput('run'); });
13484
- cta.appendChild(msg); cta.appendChild(btn);
13441
+ const onboarding = projectOnboardingReadiness();
13442
+ msg.textContent = onboarding.pending ? 'Your team is processing the project context. Continue onboarding to see progress or review the work.'
13443
+ : onboarding.ready ? 'No project context or rules have been captured yet.'
13444
+ : 'Nothing captured yet. Run Project Onboarding to fill in this project’s context and rules.';
13445
+ cta.appendChild(msg);
13485
13446
  host.appendChild(cta);
13486
13447
  return;
13487
13448
  }
13488
13449
 
13489
- // Have real content → show Context + Rules (the cache is warm, so these render
13490
- // immediately), plus a quiet Re-run for when the project changes.
13450
+ // Captured context and rules remain directly editable.
13491
13451
  host.appendChild(tfContextRow('projectContext', 'Context', 'No context captured yet. Run Project Onboarding, or click Edit to write it yourself.', null));
13492
13452
  host.appendChild(tfContextRow('projectRules', 'Rules', 'No rules captured yet. Run Project Onboarding, or click Edit to add them yourself.', null));
13493
- const foot = document.createElement('div');
13494
- foot.className = 'syn-row ctx-rerun-row';
13495
- const rerun = document.createElement('button');
13496
- rerun.className = 'syn-edit';
13497
- rerun.type = 'button';
13498
- rerun.textContent = 'Reprocess project onboarding';
13499
- rerun.title = 'Re-run onboarding: your team reads the current context & rules (including your edits) and refines them';
13500
- rerun.addEventListener('click', () => { tfOpenOnboardInput('reprocess'); });
13501
- foot.appendChild(rerun);
13502
- host.appendChild(foot);
13503
13453
  }
13504
13454
  // Back-compat shims: the brief/learnings now live in always-present top sections.
13505
13455
  // Expand the relevant section if asked; the old "hide panels" call is a no-op.
@@ -17456,14 +17406,32 @@ function tfStartOrgLearningSynthesis() {
17456
17406
  );
17457
17407
  }
17458
17408
 
17459
- async function tfStartProjectOnboarding(userInput) {
17409
+ async function tfStartProjectOnboarding(userInput, mode = 'run') {
17410
+ const projectPath = state.projectPath;
17411
+ if (!projectPath) { showStatus('Choose a project folder before starting onboarding.', true); return; }
17412
+ try {
17413
+ // Project selection already fetched these facts. Reuse them without a second
17414
+ // background fetch; the launch guard and job sync recheck authoritative state.
17415
+ await Promise.all([loadBootstrap(projectPath, '', { preferCache: true, backgroundRefresh: false }), hydrateProjectConversationHeaders(projectPath)]);
17416
+ } catch (error) { showStatus(error.message, true); return; }
17417
+ if (state.projectPath !== projectPath) return;
17418
+ const readiness = projectOnboardingReadiness();
17419
+ if (readiness.pending) return switchToConversation(readiness.pending.id);
17420
+ if (!readiness.known) {
17421
+ showStatus('Project onboarding status is unavailable. Check access to the project folder and try again.', true);
17422
+ return;
17423
+ }
17424
+ if (readiness.ready && mode !== 'reprocess') {
17425
+ showStatus('Project is already onboarded. Use Reprocess project onboarding in the Brief to update it.');
17426
+ return;
17427
+ }
17460
17428
  // The project's captured context/rules live in real files (project_context.md,
17461
17429
  // project_rules.md) and the Brief section edits them directly. So onboarding is
17462
17430
  // seeded from those files — the job reads them and preserves manager edits,
17463
17431
  // refining/extending rather than regenerating. We don't pass stale in-memory
17464
17432
  // intent/outcome here; the files are the source of truth.
17465
17433
  const parts = [
17466
- 'Onboard this project.',
17434
+ mode === 'reprocess' ? 'Reprocess project onboarding.' : 'Onboard this project.',
17467
17435
  'Read the existing fraim/personalized-employee/context/project_context.md and fraim/personalized-employee/rules/project_rules.md if they are present, and treat them as the source of truth. Preserve everything I have already written there (including any URLs or constraints I added by hand).',
17468
17436
  'Then capture or refine durable project context and project rules and write the updated project_context.md and project_rules.md (a superset of the existing content, never a regeneration from scratch).',
17469
17437
  ];
@@ -17472,14 +17440,18 @@ async function tfStartProjectOnboarding(userInput) {
17472
17440
  if (userInput && userInput.trim()) {
17473
17441
  parts.push('My direction for this onboarding pass:\n' + userInput.trim());
17474
17442
  }
17475
- await tfStartOnboardingJob('project-onboarding', parts.join('\n\n'), 'projects');
17443
+ const job = ((state.bootstrap && state.bootstrap.jobs) || []).find(j => j.id === 'project-onboarding')
17444
+ || { id: 'project-onboarding', title: 'Project Onboarding' };
17445
+ await startRun(job, parts.join('\n\n'), state.selectedEmployeeId || 'claude', undefined, 'projects');
17476
17446
  }
17477
17447
 
17478
17448
  // #594 R7: #onboard-input-modal retired. tfOpenOnboardInput now opens #np-modal
17479
17449
  // with the folder pre-filled and locked so the manager can only supply a direction
17480
17450
  // message (the intent field) before starting the re-run.
17481
- function tfOpenOnboardInput() {
17482
- tfOpenNewProjectRerun();
17451
+ function tfOpenOnboardInput(mode = 'run') {
17452
+ const readiness = projectOnboardingReadiness();
17453
+ if (readiness.pending) { switchToConversation(readiness.pending.id); return; }
17454
+ tfOpenNewProjectRerun(mode);
17483
17455
  }
17484
17456
  function tfCloseOnboardInput() {
17485
17457
  tfCloseNewProject();
@@ -17487,12 +17459,13 @@ function tfCloseOnboardInput() {
17487
17459
  function tfSubmitOnboardInput() {
17488
17460
  const intent = document.getElementById('np-intent');
17489
17461
  const val = intent ? intent.value : '';
17462
+ const mode = document.getElementById('np-modal')?.dataset.onboardingMode || 'run';
17490
17463
  tfCloseNewProject();
17491
- tfStartProjectOnboarding(val);
17464
+ tfStartProjectOnboarding(val, mode);
17492
17465
  }
17493
- function tfOpenNewProjectRerun() {
17466
+ function tfOpenNewProjectRerun(mode = 'run') {
17494
17467
  const modal = document.getElementById('np-modal');
17495
- if (!modal) { tfStartProjectOnboarding(); return; }
17468
+ if (!modal) { tfStartProjectOnboarding('', mode); return; }
17496
17469
  const name = document.getElementById('np-name');
17497
17470
  const folder = document.getElementById('np-folder');
17498
17471
  const np1Next = document.getElementById('np1-next');
@@ -17528,6 +17501,7 @@ function tfOpenNewProjectRerun() {
17528
17501
  const np1Hint = document.querySelector('#np1 .np-field-hint');
17529
17502
  if (np1Hint) { np1Hint.dataset.originalText = np1Hint.textContent; np1Hint.textContent = 'This project folder is already set. Only the direction below will change.'; }
17530
17503
  modal.dataset.rerun = '1';
17504
+ modal.dataset.onboardingMode = mode;
17531
17505
  tfNpGo(1);
17532
17506
  modal.hidden = false;
17533
17507
  if (np1Next) setTimeout(() => np1Next.focus(), 0);
@@ -17561,7 +17535,7 @@ function tfOpenNewProject() {
17561
17535
  }
17562
17536
  function tfCloseNewProject() {
17563
17537
  const m = document.getElementById('np-modal');
17564
- if (m) { m.hidden = true; delete m.dataset.rerun; }
17538
+ if (m) { m.hidden = true; delete m.dataset.rerun; delete m.dataset.onboardingMode; }
17565
17539
  const disc = document.getElementById('np-discard');
17566
17540
  if (disc) disc.hidden = true;
17567
17541
  tf._npPendingClose = null;
@@ -17698,6 +17672,10 @@ function tfRenderNpSummary() {
17698
17672
  host.appendChild(note);
17699
17673
  }
17700
17674
  async function tfCreateProject() {
17675
+ if (document.getElementById('np-modal')?.dataset.rerun === '1') {
17676
+ tfSubmitOnboardInput();
17677
+ return;
17678
+ }
17701
17679
  const val = (id) => ((document.getElementById(id) || {}).value || '').trim();
17702
17680
  const folderPath = val('np-folder');
17703
17681
  if (!folderPath) { alert('Please choose a project folder first.'); return; }
@@ -17740,7 +17718,9 @@ async function tfCreateProject() {
17740
17718
  // new-project wizard, which IS the consent, and this function starts onboarding
17741
17719
  // itself a few lines below. Asking again would be a second prompt for a decision
17742
17720
  // already made.
17743
- await tfSwitchProjectFolder(folderPath, { confirmSetup: false });
17721
+ tfShowArea('projects');
17722
+ await tfSelectProjectView('workspace', savedProject.id, { confirmSetup: false });
17723
+ if (tfCanonicalProjectPath(state.projectPath) !== tfCanonicalProjectPath(folderPath)) return;
17744
17724
  const currentProject = tfProjectForPath(folderPath) || savedProject;
17745
17725
  if (currentProject) {
17746
17726
  tf.activeProjectId = currentProject.id;
@@ -17767,14 +17747,8 @@ async function tfCreateProject() {
17767
17747
  `Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
17768
17748
  ].filter(Boolean).join('\n\n');
17769
17749
 
17770
- if (onboardingContext) {
17771
- const job = ((state.bootstrap && state.bootstrap.jobs) || []).find((j) => j.id === 'project-onboarding')
17772
- || { id: 'project-onboarding', intent: 'Onboard this project.' };
17773
- try {
17774
- await startRun(job, onboardingContext || 'Set up this project.', state.selectedEmployeeId || 'claude');
17775
- } catch (e) {
17776
- console.warn('[512] project-onboarding start failed (non-blocking):', e);
17777
- }
17750
+ if (tfCanonicalProjectPath(state.projectPath) === tfCanonicalProjectPath(folderPath)) {
17751
+ await tfStartProjectOnboarding(onboardingContext);
17778
17752
  }
17779
17753
  }
17780
17754
 
@@ -17799,6 +17773,9 @@ async function tfSwitchProjectFolder(folderPath, options) {
17799
17773
  return;
17800
17774
  }
17801
17775
  state.projectPath = normalized.folderPath;
17776
+ const switchPath = normalized.folderPath;
17777
+ const selectedProject = tfProjectForPath(switchPath);
17778
+ if (selectedProject) tf.activeProjectId = selectedProject.id;
17802
17779
  tfInvalidateRejections();
17803
17780
  // Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
17804
17781
  // project's active conversation on switch. Otherwise the just-finished run stays state.activeId
@@ -17813,40 +17790,22 @@ async function tfSwitchProjectFolder(folderPath, options) {
17813
17790
  // re-fetch for this folder; org/manager keys are user-level and stay.
17814
17791
  const cache = tfCtxCache();
17815
17792
  for (const k of ['projectContext', 'projectBrief', 'projectRules']) delete cache[k];
17793
+ // Clear old rendered context synchronously, before a setup modal can open.
17794
+ tfRenderProjectTabs();
17795
+ tfRenderProjectContextTop();
17816
17796
  // loadBootstrap sends projectPath as a query param — the server records it
17817
17797
  // in its preferences store so this folder becomes the default on next load.
17818
- const cachedConversations = Array.isArray(state.conversations[normalized.folderPath]);
17819
- const conversationsPromise = cachedConversations ? null : hydrateConversationsFromServer();
17798
+ const conversationsPromise = hydrateConversationsFromServer();
17820
17799
  await loadBootstrap(normalized.folderPath, undefined, { preferCache: true });
17821
- if (cachedConversations) {
17822
- renderRail();
17823
- renderActive();
17824
- hydrateConversationsFromServer().catch((error) =>
17825
- console.warn('[512] Could not refresh project conversations:', error)
17826
- );
17827
- } else {
17828
- await conversationsPromise;
17829
- }
17800
+ await conversationsPromise;
17801
+ if (state.projectPath !== switchPath) return;
17830
17802
  tfAdoptServerProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
17831
17803
  const currentProject = tfEnsureCurrentProject();
17832
17804
  if (currentProject) tf.activeProjectId = currentProject.id;
17833
17805
  tfRefreshAfterBootstrap();
17834
- // Issue #1253 R3: entering a folder with no fraim/ is what starts scaffolding it,
17835
- // so the same confirmation the page load uses fires here. Declining leaves the
17836
- // folder untouched and returns to the Overview rather than parking the user in a
17837
- // workspace with nothing in it.
17806
+ // Setup is optional for this valid selection; cancelling leaves it selected.
17838
17807
  if (confirmSetup && state.bootstrap && state.bootstrap.project && state.bootstrap.project.needsOnboarding) {
17839
- const started = await tfMaybeConfirmProjectSetup();
17840
- if (!started) {
17841
- // The switch above already recorded this folder server-side. Declining must
17842
- // undo that, not just navigate away: a folder the user declined to set up is
17843
- // exactly the kind of value #1253 is about, and leaving it recorded would put
17844
- // it back in front of them on the next launch.
17845
- state.projectPath = '';
17846
- tf.activeProjectId = null;
17847
- await loadBootstrap('');
17848
- await tfSelectProjectView('overview');
17849
- }
17808
+ await tfMaybeConfirmProjectSetup();
17850
17809
  }
17851
17810
  }
17852
17811
 
@@ -18594,6 +18553,7 @@ function tfWireShell() {
18594
18553
  ];
18595
18554
  document.addEventListener('keydown', (e) => {
18596
18555
  if (e.key !== 'Escape') return;
18556
+ document.querySelectorAll('.employee-readiness-tooltip').forEach(tip => { tip.hidden = true; });
18597
18557
  // When the discard confirm is up, Escape means "keep editing" — dismiss it,
18598
18558
  // never fall through and re-trigger it on the wizard beneath.
18599
18559
  const npDiscardEl = document.getElementById('np-discard');
@@ -658,6 +658,38 @@ img.conv-employee-avatar {
658
658
  .employee-readiness-note { margin: 8px 4px; color: var(--muted); font-size: 11px; line-height: 1.45; }
659
659
  .employee-readiness-note .ctx-acc-action { display: flex; margin-top: 6px; max-width: 100%; white-space: normal; text-align: left; color: var(--accent-strong); }
660
660
  .employee-readiness-note .ctx-acc-action:hover { background: var(--accent-strong); color: var(--surface); }
661
+ .conv-employee-group--project-unavailable > summary {
662
+ background: var(--line);
663
+ color: var(--muted);
664
+ border-radius: 7px;
665
+ }
666
+ .conv-employee-group--project-unavailable > summary .conv-employee-avatar {
667
+ background: var(--soft);
668
+ color: var(--muted);
669
+ filter: grayscale(1);
670
+ }
671
+ .conv-employee-group--project-unavailable > summary .conv-employee-tab-label { font-weight: 500; }
672
+ .conv-employee-group--project-unavailable .taught-badge { background: transparent; color: var(--muted); border-color: var(--line); }
673
+ .conv-employee-row:has(.conv-employee-group--project-unavailable) .conv-employee-add:disabled { opacity: .55; }
674
+ .employee-readiness-tooltip {
675
+ position: fixed;
676
+ z-index: 10000;
677
+ width: 260px;
678
+ max-width: calc(100vw - 16px);
679
+ padding: 10px 12px;
680
+ border: 1px solid var(--line);
681
+ border-radius: 8px;
682
+ background: var(--surface);
683
+ color: var(--text);
684
+ box-shadow: var(--shadow-lg);
685
+ font-size: 13px;
686
+ font-weight: 400;
687
+ line-height: 1.5;
688
+ white-space: normal;
689
+ text-transform: none;
690
+ letter-spacing: normal;
691
+ }
692
+ .employee-readiness-tooltip[hidden] { display: none; }
661
693
  .conv-employee-tab-detail {
662
694
  font-size: 10px;
663
695
  font-weight: 500;
@@ -6098,6 +6130,9 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6098
6130
  border: 1px solid rgba(0,113,227,.2);
6099
6131
  }
6100
6132
  .ctx-acc-action:hover { background: var(--accent); color: #fff; }
6133
+ #proj-brief-acc .ctx-acc-action,
6134
+ #proj-brief-acc .syn-edit { color: var(--accent-strong); }
6135
+ #proj-brief-acc .ctx-acc-action:hover { background: var(--accent-strong); color: var(--surface); }
6101
6136
  .ctx-acc-action .caa-ico { font-size: 14px; line-height: 1; }
6102
6137
 
6103
6138
  /* #693.2 — consolidated New Assignment dialog: segmented Scheduled/Triggered. */