fraim-hub 2.0.348 → 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 +46 -11
- package/dist/src/ai-hub/server.js +87 -21
- package/dist/src/core/utils/local-registry-resolver.js +13 -3
- package/dist/src/core/utils/project-fraim-paths.js +14 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +18 -2
- package/package.json +2 -2
- package/public/ai-hub/review.css +7 -0
- package/public/ai-hub/script.js +274 -347
- 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,29 +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
|
-
const
|
|
2405
|
-
let
|
|
2406
|
-
let
|
|
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;
|
|
2407
2436
|
const observeCodexAuth = (line, channel) => {
|
|
2408
2437
|
if (hostId !== 'codex')
|
|
2409
2438
|
return;
|
|
2410
2439
|
if (channel === 'stderr') {
|
|
2411
2440
|
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
2412
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: (.+)$/);
|
|
2413
|
-
if (diagnostic
|
|
2414
|
-
|
|
2442
|
+
if (diagnostic && CODEX_PERMANENT_REFRESH_FAILURES.has(diagnostic[1]))
|
|
2443
|
+
codexAuthDiagnostics.add(diagnostic[1]);
|
|
2415
2444
|
return;
|
|
2416
2445
|
}
|
|
2417
2446
|
try {
|
|
2418
2447
|
const event = JSON.parse(line);
|
|
2448
|
+
if (event.type === 'turn.failed') {
|
|
2449
|
+
codexTerminalProviderFailure = isCodexProviderAuthFailure(event.error?.message) ? event.error.message : null;
|
|
2450
|
+
}
|
|
2419
2451
|
if (event.type === 'turn.failed' || event.type === 'error') {
|
|
2420
|
-
|
|
2452
|
+
const message = event.type === 'turn.failed' ? event.error?.message : event.message;
|
|
2453
|
+
codexTerminalRefreshFailure = CODEX_PERMANENT_REFRESH_FAILURES.has(message) ? message : null;
|
|
2421
2454
|
}
|
|
2422
2455
|
else if (event.type === 'turn.started' || event.type === 'turn.completed') {
|
|
2423
|
-
|
|
2456
|
+
codexTerminalRefreshFailure = null;
|
|
2457
|
+
codexTerminalProviderFailure = null;
|
|
2424
2458
|
}
|
|
2425
2459
|
}
|
|
2426
2460
|
catch { /* Plain stdout cannot establish authentication failure. */ }
|
|
@@ -2474,8 +2508,9 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
|
2474
2508
|
// `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
|
|
2475
2509
|
// non-zero branch fires and recovery is attempted.
|
|
2476
2510
|
child.on('close', (code, signal) => {
|
|
2477
|
-
|
|
2478
|
-
|
|
2511
|
+
const failure = codexTerminalProviderFailure || (codexTerminalRefreshFailure && codexAuthDiagnostics.has(codexTerminalRefreshFailure) ? codexTerminalRefreshFailure : null);
|
|
2512
|
+
if (!signal && code !== null && code !== 0 && failure) {
|
|
2513
|
+
handlers.onEvent({ hostAuthenticationRequired: true, hostError: failure, raw: failure }, 'system');
|
|
2479
2514
|
}
|
|
2480
2515
|
handlers.onExit(signal ? -1 : code);
|
|
2481
2516
|
});
|
|
@@ -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.
|
|
@@ -50,6 +50,7 @@ class LocalRegistryResolver {
|
|
|
50
50
|
constructor(options) {
|
|
51
51
|
this.workspaceRoot = options.workspaceRoot;
|
|
52
52
|
this.effectiveFraimDir = options.effectiveFraimDir;
|
|
53
|
+
this.userLevelOnly = options.userLevelOnly ?? false;
|
|
53
54
|
this.remoteContentResolver = options.remoteContentResolver;
|
|
54
55
|
this.parser = new inheritance_parser_1.InheritanceParser(options.maxDepth);
|
|
55
56
|
this.shouldFilter = options.shouldFilter;
|
|
@@ -87,7 +88,14 @@ class LocalRegistryResolver {
|
|
|
87
88
|
* Check if a local override exists for the given path
|
|
88
89
|
*/
|
|
89
90
|
hasLocalOverride(path) {
|
|
90
|
-
return (0, fs_1.existsSync)(this.getOverridePath(path));
|
|
91
|
+
return !this.userLevelOnly && (0, fs_1.existsSync)(this.getOverridePath(path));
|
|
92
|
+
}
|
|
93
|
+
/** Whether an effective personalized layer should replace a remote baseline response. */
|
|
94
|
+
hasPersonalizedOverride(path) {
|
|
95
|
+
return this.hasLocalOverride(path)
|
|
96
|
+
|| this.readManagerLocalOverlayFile(path) !== null
|
|
97
|
+
|| this.readManagerOverlayFile(path) !== null
|
|
98
|
+
|| this.readOrgOverlayFile(path) !== null;
|
|
91
99
|
}
|
|
92
100
|
/**
|
|
93
101
|
* Find the actual relative path for a registry item by searching subdirectories.
|
|
@@ -108,11 +116,11 @@ class LocalRegistryResolver {
|
|
|
108
116
|
// `jobs/<category>/<name>.md`, so the job was reported not found even
|
|
109
117
|
// though the file existed.
|
|
110
118
|
const searchRoots = [
|
|
111
|
-
this.getFraimPath('personalized-employee', dir),
|
|
119
|
+
...(!this.userLevelOnly ? [this.getFraimPath('personalized-employee', dir)] : []),
|
|
112
120
|
(0, path_1.join)(this.managerLocalRoot, dir),
|
|
113
121
|
(0, path_1.join)(this.managerCacheRoot, dir),
|
|
114
122
|
(0, path_1.join)(this.orgCacheRoot, dir),
|
|
115
|
-
(0, path_1.join)(this.workspaceRoot, 'registry', dir),
|
|
123
|
+
...(!this.userLevelOnly ? [(0, path_1.join)(this.workspaceRoot, 'registry', dir)] : []),
|
|
116
124
|
// Installed projects discover categorized capabilities in machine catalogs.
|
|
117
125
|
this.getSyncedCatalogRoot('ai-employee', dir),
|
|
118
126
|
...(type !== 'skills' ? [this.getSyncedCatalogRoot('ai-manager', dir)] : []),
|
|
@@ -326,6 +334,8 @@ class LocalRegistryResolver {
|
|
|
326
334
|
}
|
|
327
335
|
}
|
|
328
336
|
readWorkspaceRegistryFile(path) {
|
|
337
|
+
if (this.userLevelOnly)
|
|
338
|
+
return null;
|
|
329
339
|
const normalizedPath = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
330
340
|
const registryRoot = (0, path_1.join)(this.workspaceRoot, 'registry');
|
|
331
341
|
const registryPath = (0, path_1.join)(registryRoot, ...normalizedPath.split('/'));
|
|
@@ -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
|
}
|
|
@@ -1535,6 +1535,7 @@ function readRejectedProposals(workspaceRoot, userId) {
|
|
|
1535
1535
|
return [];
|
|
1536
1536
|
const out = [];
|
|
1537
1537
|
const seen = new Set();
|
|
1538
|
+
const projectOwnerPath = (0, owner_identity_1.ownerPath)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot));
|
|
1538
1539
|
const headingRe = /^## (Rejected|Superseded): (.+)$/m;
|
|
1539
1540
|
const reasonRe = /^Reason: (.+)$/m;
|
|
1540
1541
|
const revisitRe = /^Revisit when: (.+)$/m;
|
|
@@ -1567,7 +1568,9 @@ function readRejectedProposals(workspaceRoot, userId) {
|
|
|
1567
1568
|
let proposal = {};
|
|
1568
1569
|
if (json) {
|
|
1569
1570
|
try {
|
|
1570
|
-
|
|
1571
|
+
const parsed = JSON.parse(json[1]);
|
|
1572
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
1573
|
+
proposal = parsed;
|
|
1571
1574
|
}
|
|
1572
1575
|
catch {
|
|
1573
1576
|
// Keep the human-readable half; the JSON only enriches it.
|
|
@@ -1576,12 +1579,24 @@ function readRejectedProposals(workspaceRoot, userId) {
|
|
|
1576
1579
|
const decision = (proposal.decision || {});
|
|
1577
1580
|
const content = (proposal.content || {});
|
|
1578
1581
|
const trace = (content.trace || {});
|
|
1582
|
+
let targetOwner;
|
|
1583
|
+
try {
|
|
1584
|
+
const owner = proposal.target?.owner;
|
|
1585
|
+
(0, owner_identity_1.validateOwner)(owner);
|
|
1586
|
+
targetOwner = { level: owner.level, path: owner.path };
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
// Older/damaged targets remain visible as unknown; never infer
|
|
1590
|
+
// their project from the shared history file's location.
|
|
1591
|
+
}
|
|
1592
|
+
if (targetOwner?.level === 'Project' && targetOwner.path !== projectOwnerPath)
|
|
1593
|
+
continue;
|
|
1579
1594
|
const claimKey = typeof proposal.claimKey === 'string' && proposal.claimKey
|
|
1580
1595
|
? proposal.claimKey
|
|
1581
1596
|
: parts[1] || heading[2];
|
|
1582
1597
|
// The same claim can appear in more than one configured base. The first
|
|
1583
1598
|
// base supplies display metadata for that same recorded decision.
|
|
1584
|
-
const key =
|
|
1599
|
+
const key = JSON.stringify([claimKey, String(decision.at || ''), targetOwner?.level, targetOwner?.path]);
|
|
1585
1600
|
if (seen.has(key))
|
|
1586
1601
|
continue;
|
|
1587
1602
|
seen.add(key);
|
|
@@ -1596,6 +1611,7 @@ function readRejectedProposals(workspaceRoot, userId) {
|
|
|
1596
1611
|
rejectedAt: typeof decision.at === 'string' ? decision.at : '',
|
|
1597
1612
|
...(typeof trace.recurrences === 'number' ? { recurrencesAtRejection: trace.recurrences } : {}),
|
|
1598
1613
|
displayPath,
|
|
1614
|
+
...(targetOwner ? { targetOwner } : {}),
|
|
1599
1615
|
});
|
|
1600
1616
|
}
|
|
1601
1617
|
}
|
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/review.css
CHANGED
|
@@ -833,6 +833,13 @@
|
|
|
833
833
|
}
|
|
834
834
|
|
|
835
835
|
/* Card body clamp */
|
|
836
|
+
.learn-prop .lr-owner {
|
|
837
|
+
min-width: 0;
|
|
838
|
+
max-width: 100%;
|
|
839
|
+
flex-shrink: 1;
|
|
840
|
+
overflow-wrap: anywhere;
|
|
841
|
+
}
|
|
842
|
+
|
|
836
843
|
.learn-prop .lp-body.lp-clamped {
|
|
837
844
|
display: -webkit-box;
|
|
838
845
|
-webkit-line-clamp: 3;
|