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.
- package/dist/src/ai-hub/catalog.js +7 -1
- package/dist/src/ai-hub/hosts.js +43 -16
- package/dist/src/ai-hub/server.js +87 -21
- package/dist/src/core/utils/project-fraim-paths.js +14 -0
- package/package.json +2 -2
- package/public/ai-hub/script.js +212 -252
- package/public/ai-hub/styles.css +35 -0
|
@@ -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
|
-
|
|
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) {
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
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
|
|
2417
|
-
|
|
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
|
-
|
|
2449
|
+
codexTerminalProviderFailure = isCodexProviderAuthFailure(event.error?.message) ? event.error.message : null;
|
|
2424
2450
|
}
|
|
2425
2451
|
if (event.type === 'turn.failed' || event.type === 'error') {
|
|
2426
|
-
|
|
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
|
-
|
|
2430
|
-
|
|
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
|
-
|
|
2485
|
-
|
|
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
|
-
//
|
|
387
|
-
//
|
|
388
|
-
// Downloads
|
|
389
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
|
2484
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
3026
|
+
if (area === 'projects') return projectOnboardingReadiness(conversations);
|
|
3027
|
+
const scopeName = area === 'company' ? 'Company' : 'Manager';
|
|
3003
3028
|
const key = area === 'company' ? 'org' : 'manager';
|
|
3004
|
-
const
|
|
3005
|
-
const
|
|
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 === '
|
|
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)
|
|
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)
|
|
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.
|
|
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(
|
|
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
|
-
|
|
11097
|
-
|
|
11098
|
-
|
|
11099
|
-
|
|
11100
|
-
|
|
11101
|
-
|
|
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
|
-
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
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
|
-
//
|
|
12132
|
-
//
|
|
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.
|
|
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
|
-
*
|
|
12217
|
-
*
|
|
12218
|
-
*
|
|
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
|
|
12226
|
-
if (!
|
|
12227
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
13450
|
-
//
|
|
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
|
|
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
|
-
|
|
13479
|
-
|
|
13480
|
-
|
|
13481
|
-
|
|
13482
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
17771
|
-
|
|
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
|
|
17819
|
-
const conversationsPromise = cachedConversations ? null : hydrateConversationsFromServer();
|
|
17798
|
+
const conversationsPromise = hydrateConversationsFromServer();
|
|
17820
17799
|
await loadBootstrap(normalized.folderPath, undefined, { preferCache: true });
|
|
17821
|
-
|
|
17822
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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');
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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. */
|