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.
@@ -213,6 +213,7 @@ function summarizeProject(projectPath) {
213
213
  path: '',
214
214
  exists: false,
215
215
  hasFraim: false,
216
+ isOnboarded: false,
216
217
  message: 'Choose a project path to load FRAIM jobs.',
217
218
  };
218
219
  }
@@ -221,17 +222,20 @@ function summarizeProject(projectPath) {
221
222
  path: projectPath,
222
223
  exists: false,
223
224
  hasFraim: false,
225
+ isOnboarded: (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath),
224
226
  message: 'Project path does not exist on this machine.',
225
227
  };
226
228
  }
227
229
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
228
230
  const hasFraim = fs_1.default.existsSync(fraimDir);
231
+ const isOnboarded = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath);
229
232
  if (!hasFraim) {
230
233
  return {
231
234
  path: projectPath,
232
235
  exists: true,
233
236
  hasFraim: false,
234
- needsOnboarding: true,
237
+ isOnboarded,
238
+ needsOnboarding: isOnboarded === false,
235
239
  message: 'This folder has not been set up with FRAIM yet. Onboarding will get your AI employee started.',
236
240
  };
237
241
  }
@@ -239,6 +243,8 @@ function summarizeProject(projectPath) {
239
243
  path: projectPath,
240
244
  exists: true,
241
245
  hasFraim: true,
246
+ isOnboarded,
247
+ needsOnboarding: isOnboarded === false,
242
248
  };
243
249
  }
244
250
  function resolveLayerRoot(projectPath, layer) {
@@ -2398,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
- // Exec's JSON error has no auth code. Require independent provider stderr
2403
- // provenance AND the same terminal protocol error; never scan tool/prose text.
2404
- const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
2405
- let codexAuthDiagnostic = false;
2406
- let codexTerminalAuthFailure = 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;
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?.[1] === codexRefreshFailure)
2414
- codexAuthDiagnostic = true;
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
- codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
2452
+ const message = event.type === 'turn.failed' ? event.error?.message : event.message;
2453
+ codexTerminalRefreshFailure = CODEX_PERMANENT_REFRESH_FAILURES.has(message) ? message : null;
2421
2454
  }
2422
2455
  else if (event.type === 'turn.started' || event.type === 'turn.completed') {
2423
- codexTerminalAuthFailure = false;
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
- if (!signal && code !== null && code !== 0 && codexAuthDiagnostic && codexTerminalAuthFailure) {
2478
- handlers.onEvent({ hostAuthenticationRequired: true, hostError: codexRefreshFailure, raw: codexRefreshFailure }, 'system');
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
- // Issue #1253: existence alone is not validity. Eligibility is a positive rule —
387
- // a recorded path qualifies only if it IS a FRAIM project. The temp/Home/Desktop/
388
- // Downloads filters narrow that rule further; they do not define it. Framing
389
- // eligibility as an exclusion list is what let C:\Windows stay pinned forever:
390
- // it was simply not on the list, and such a list is unbounded by construction.
386
+ // A recorded folder must be a discoverable FRAIM project or a deliberately added
387
+ // project list entry. The latter remains selectable before onboarding (#2039).
388
+ // Existing temporary/Home/Desktop/Downloads safeguards still apply; a stale bare
389
+ // recorded path is not enough to turn an arbitrary directory into a project.
391
390
  function resolveHubProjectPath(preferencesStore, options = {}) {
392
- const recorded = preferencesStore.load('').projectPath;
391
+ const preferences = preferencesStore.load('');
392
+ const recorded = preferences.projectPath;
393
393
  if (typeof recorded === 'string' && recorded.trim().length > 0) {
394
394
  const resolved = path_1.default.resolve(recorded);
395
395
  const allowedTemporary = options.allowTemporaryRelatedTo
396
396
  ? isRelatedTemporaryProjectPath(resolved, options.allowTemporaryRelatedTo)
397
397
  : false;
398
398
  if (!isLikelyTemporaryProjectPath(resolved) || allowedTemporary) {
399
- if (directoryExists(resolved) && (0, project_fraim_paths_1.workspaceFraimExists)(resolved))
399
+ const explicitlyAdded = preferences.projects?.some((project) => sameDirectoryPath(project.folderPath, resolved));
400
+ if (directoryExists(resolved) && ((0, project_fraim_paths_1.workspaceFraimExists)(resolved) || explicitlyAdded))
400
401
  return { projectPath: resolved };
401
402
  // A missing folder can be a moved project. Report it separately from a
402
403
  // non-FRAIM folder so the Hub can show the rehome path instead of leaving
@@ -419,10 +420,15 @@ function resolveHubProjectPath(preferencesStore, options = {}) {
419
420
  * handed to `preferencesStore.save()`.
420
421
  */
421
422
  function decorateProjectsWithFraimStatus(projects) {
422
- return projects.map((project) => ({
423
- ...project,
424
- hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
425
- }));
423
+ return projects.map((project) => {
424
+ const isOnboarded = project.folderPath ? (0, project_fraim_paths_1.workspaceOnboardingReady)(path_1.default.resolve(project.folderPath)) : false;
425
+ return {
426
+ ...project,
427
+ hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
428
+ isOnboarded,
429
+ needsOnboarding: isOnboarded === false,
430
+ };
431
+ });
426
432
  }
427
433
  class AiHubRunRegistry {
428
434
  constructor() {
@@ -2411,6 +2417,18 @@ function normalizedDirectoryPath(projectPath) {
2411
2417
  function sameDirectoryPath(left, right) {
2412
2418
  return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
2413
2419
  }
2420
+ function onboardingConversationIsActive(conversation) {
2421
+ if (conversation.status === 'running')
2422
+ return true;
2423
+ if (conversation.pauseReason === 'done' || conversation.reviewApproved === true)
2424
+ return false;
2425
+ const handoff = normalizeReviewHandoff(conversation.reviewHandoff);
2426
+ if (handoff)
2427
+ return handoff.reviewRequired;
2428
+ if (conversation.pauseReason === 'awaiting_review')
2429
+ return false;
2430
+ return conversation.status === 'completed' && (conversation.artifacts || []).some((artifact) => Boolean(artifact.path || artifact.url || (('where' in artifact) && artifact.where && artifact.name)));
2431
+ }
2414
2432
  function isPathInsideDirectory(candidatePath, parentPath) {
2415
2433
  const relative = path_1.default.relative(path_1.default.resolve(parentPath), path_1.default.resolve(candidatePath));
2416
2434
  return relative === '' || (!!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
@@ -2480,8 +2498,9 @@ function resolveApiKey() {
2480
2498
  }
2481
2499
  // Read persisted Get-started step states from ~/.fraim/install-state.json
2482
2500
  // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
2483
- // missing key falls back to derivation. Persisted `true` is sticky so the rail
2484
- // never re-shows once a step is completed (R13.4).
2501
+ // missing key falls back to derivation. Persisted `true` is sticky for install,
2502
+ // company, and hire (R13.4). computeFirstRun ignores the legacy project flag:
2503
+ // project readiness is derived live from the selected project's config presence.
2485
2504
  function readPersistedFirstRun() {
2486
2505
  const out = {};
2487
2506
  const apply = (source) => {
@@ -3000,6 +3019,7 @@ class AiHubServer {
3000
3019
  constructor(options = {}) {
3001
3020
  this.app = (0, express_1.default)();
3002
3021
  this.runRegistry = new AiHubRunRegistry();
3022
+ this.projectSelectionGeneration = 0;
3003
3023
  this.activeAvailabilityWarnings = new Set();
3004
3024
  // Issue #925: the last identity successfully resolved from the hosted authority,
3005
3025
  // keyed by the exact apiKey it was resolved for. When the authority is transiently
@@ -3584,10 +3604,26 @@ class AiHubServer {
3584
3604
  this.conversationStore.moveProjectBucket(oldProjectPath, newProjectPath);
3585
3605
  }
3586
3606
  }
3587
- async bootstrapResponse(projectPath) {
3607
+ async bootstrapResponse(projectPath, explicitSelection = false) {
3588
3608
  // #866 R2: guard against path.resolve('') === cwd; '' means "no active project".
3589
3609
  const activeProjectPath = projectPath || this.projectPath;
3590
3610
  const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
3611
+ if (explicitSelection) {
3612
+ this.projectSelectionGeneration += 1;
3613
+ // Record the chosen directory before slow employee/persona reads. A
3614
+ // pathless refresh during that work must already resolve this selection.
3615
+ if (directoryExists(normalizedProjectPath)) {
3616
+ const selectedPreferences = this.preferencesStore.load('');
3617
+ this.preferencesStore.save({
3618
+ ...selectedPreferences,
3619
+ projectPath: normalizedProjectPath,
3620
+ projects: (0, preferences_1.normalizeAiHubProjectList)(selectedPreferences.projects || [], normalizedProjectPath, {
3621
+ removedProjectPaths: selectedPreferences.removedProjectPaths || [],
3622
+ }),
3623
+ });
3624
+ }
3625
+ }
3626
+ const selectionGeneration = this.projectSelectionGeneration;
3591
3627
  // Issue #1010: prefer the non-blocking parallel probe on the first-paint path. On a
3592
3628
  // cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
3593
3629
  // event loop, making every concurrent request wait. Falls back to the sync form for
@@ -3616,7 +3652,8 @@ class AiHubServer {
3616
3652
  const firstAvailable = employees.find((e) => e.available);
3617
3653
  if (firstAvailable) {
3618
3654
  preferences = { ...preferences, employeeId: firstAvailable.id };
3619
- this.preferencesStore.save(preferences);
3655
+ if (selectionGeneration === this.projectSelectionGeneration)
3656
+ this.preferencesStore.save(preferences);
3620
3657
  }
3621
3658
  }
3622
3659
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
@@ -3682,7 +3719,8 @@ class AiHubServer {
3682
3719
  // shell is visible.
3683
3720
  const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
3684
3721
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
3685
- this.preferencesStore.save(preferences);
3722
+ if (selectionGeneration === this.projectSelectionGeneration)
3723
+ this.preferencesStore.save(preferences);
3686
3724
  // Issue #1253: `hasFraim` is decorated AFTER the save above, never before. The
3687
3725
  // line that persists `preferences` takes whatever list it is handed, so a
3688
3726
  // derived field attached upstream of it silently becomes persisted state. See
@@ -5174,8 +5212,7 @@ class AiHubServer {
5174
5212
  const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
5175
5213
  // hire = any persona hired (entitlement active).
5176
5214
  const hireDone = personas.some((p) => p.status === 'hired');
5177
- // project = a project brief exists OR the workspace has FRAIM jobs set up.
5178
- const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
5215
+ const projectDone = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath) === true;
5179
5216
  const derived = {
5180
5217
  install: true, // the Hub is running, so the toolchain install step is satisfied.
5181
5218
  company: companyDone,
@@ -5187,7 +5224,7 @@ class AiHubServer {
5187
5224
  install: persisted.install ?? derived.install,
5188
5225
  company: persisted.company || derived.company,
5189
5226
  hire: persisted.hire || derived.hire,
5190
- project: persisted.project || derived.project,
5227
+ project: derived.project,
5191
5228
  };
5192
5229
  }
5193
5230
  // Issue #512 (S3, R3) — presence + display paths of the three-layer context
@@ -5722,7 +5759,7 @@ class AiHubServer {
5722
5759
  }
5723
5760
  // #719 / local Hub package: a bare reload must land on the user-level saved
5724
5761
  // Hub project, not whatever directory happened to launch the process.
5725
- res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
5762
+ res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath(), !!projectPath));
5726
5763
  });
5727
5764
  this.app.get('/api/ai-hub/personas', async (req, res) => {
5728
5765
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
@@ -6540,7 +6577,7 @@ class AiHubServer {
6540
6577
  .filter((id) => id.length > 0));
6541
6578
  const retainedProjects = this.knownProjects(projectPath).filter((project) => !(project.id && incomingProjectIds.has(project.id)));
6542
6579
  const projects = this.preferencesStore.saveProjects(projectPath, [...retainedProjects, ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
6543
- return res.json({ projectPath, projects, source: 'disk' });
6580
+ return res.json({ projectPath, projects: decorateProjectsWithFraimStatus(projects), source: 'disk' });
6544
6581
  }
6545
6582
  catch (error) {
6546
6583
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
@@ -7160,6 +7197,35 @@ class AiHubServer {
7160
7197
  if (!jobId) {
7161
7198
  throw new Error('Choose a FRAIM job before starting a run.');
7162
7199
  }
7200
+ // A project has one active onboarding workflow, including review gates.
7201
+ // Disk records cover review after restart; optimistic records without a
7202
+ // run id cannot block their own first launch.
7203
+ if (jobId === 'project-onboarding' && scope === 'project') {
7204
+ const conversations = this.conversationStore.loadProject(projectPath).conversations;
7205
+ const liveRun = this.runRegistry.all().find((run) => {
7206
+ if (run.jobId !== jobId || run.scope === 'manager' || run.scope === 'company'
7207
+ || !sameDirectoryPath(run.projectPath, projectPath))
7208
+ return false;
7209
+ const stored = conversations.find((conversation) => conversation.id === (run.conversationId || run.id));
7210
+ return onboardingConversationIsActive({
7211
+ ...run,
7212
+ reviewApproved: stored?.reviewApproved,
7213
+ pauseReason: stored?.pauseReason === 'done' ? 'done' : run.pauseReason,
7214
+ });
7215
+ });
7216
+ const existing = conversations.find((conversation) => !!conversation.runId
7217
+ && conversation.jobId === jobId && conversation.scope !== 'manager' && conversation.scope !== 'company'
7218
+ && !!conversation.projectPath && sameDirectoryPath(conversation.projectPath, projectPath)
7219
+ && onboardingConversationIsActive(conversation));
7220
+ if (liveRun || existing) {
7221
+ return res.status(409).json({
7222
+ code: 'project_onboarding_active',
7223
+ error: 'Project onboarding is already in progress.',
7224
+ existingConversationId: liveRun ? (liveRun.conversationId || liveRun.id) : existing.id,
7225
+ existingRunId: liveRun?.id || existing?.runId,
7226
+ });
7227
+ }
7228
+ }
7163
7229
  // #1394: Dedup guard — return an already-running identical run rather than spawning a second
7164
7230
  // process. Key includes managerDisplay (messages[0].text) so runs for different issues are
7165
7231
  // never blocked even when they share the same jobId + hostId + projectPath.
@@ -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
- proposal = JSON.parse(json[1]);
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 = `${claimKey}:${String(decision.at || '')}`;
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.348",
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.348",
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",
@@ -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;