fraim-hub 2.0.348 → 2.0.349

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.
@@ -2399,11 +2399,14 @@ function detectCodexCompactTaskFailure(line) {
2399
2399
  return null;
2400
2400
  }
2401
2401
  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.
2402
+ // Exec's JSON error has no auth code. Refresh failures need corroborating
2403
+ // stderr; workspace-routing 401 needs the exact terminal provider event.
2404
+ // Both require a failed process exit; never scan tool/prose text.
2404
2405
  const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
2406
+ const codexWorkspaceRoutingFailure = 'workspace routing discovery unauthorized (401)';
2405
2407
  let codexAuthDiagnostic = false;
2406
2408
  let codexTerminalAuthFailure = false;
2409
+ let codexTerminalWorkspaceAuthFailure = false;
2407
2410
  const observeCodexAuth = (line, channel) => {
2408
2411
  if (hostId !== 'codex')
2409
2412
  return;
@@ -2416,11 +2419,15 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2416
2419
  }
2417
2420
  try {
2418
2421
  const event = JSON.parse(line);
2422
+ if (event.type === 'turn.failed') {
2423
+ codexTerminalWorkspaceAuthFailure = event.error?.message === codexWorkspaceRoutingFailure;
2424
+ }
2419
2425
  if (event.type === 'turn.failed' || event.type === 'error') {
2420
2426
  codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
2421
2427
  }
2422
2428
  else if (event.type === 'turn.started' || event.type === 'turn.completed') {
2423
2429
  codexTerminalAuthFailure = false;
2430
+ codexTerminalWorkspaceAuthFailure = false;
2424
2431
  }
2425
2432
  }
2426
2433
  catch { /* Plain stdout cannot establish authentication failure. */ }
@@ -2474,8 +2481,9 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2474
2481
  // `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
2475
2482
  // non-zero branch fires and recovery is attempted.
2476
2483
  child.on('close', (code, signal) => {
2477
- if (!signal && code !== null && code !== 0 && codexAuthDiagnostic && codexTerminalAuthFailure) {
2478
- handlers.onEvent({ hostAuthenticationRequired: true, hostError: codexRefreshFailure, raw: codexRefreshFailure }, 'system');
2484
+ if (!signal && code !== null && code !== 0 && ((codexAuthDiagnostic && codexTerminalAuthFailure) || codexTerminalWorkspaceAuthFailure)) {
2485
+ const failure = codexTerminalWorkspaceAuthFailure ? codexWorkspaceRoutingFailure : codexRefreshFailure;
2486
+ handlers.onEvent({ hostAuthenticationRequired: true, hostError: failure, raw: failure }, 'system');
2479
2487
  }
2480
2488
  handlers.onExit(signal ? -1 : code);
2481
2489
  });
@@ -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('/'));
@@ -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.349",
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.349",
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;
@@ -7346,85 +7346,9 @@ function renderLearningProposalTurn(conv) {
7346
7346
  const host = els['messages'];
7347
7347
  if (!host) return;
7348
7348
  renderLearningProposalHistory(conv);
7349
+ // Issue #2045: sleep conversational cards disabled — not working as expected.
7349
7350
  const existing = host.querySelector('#learn-proposal-turn');
7350
- if (!tfConvHasProposals(conv)) {
7351
- if (existing) existing.remove();
7352
- return;
7353
- }
7354
-
7355
- // Issue #1857: the scope arrives on the set rather than being re-derived from
7356
- // the kickoff text, so a decision reaches the corpus its run was scoped to even
7357
- // when the kickoff wording is ambiguous.
7358
- const scope = conv.proposalSet.runScope || tfProposalScopeForConversation(conv);
7359
- const proposals = tfPendingProposals(conv);
7360
- if (!proposals.length) {
7361
- if (existing) existing.remove();
7362
- return;
7363
- }
7364
- const signature = JSON.stringify([conv.id, tfProposalSubmissions.has(conv.id), conv.proposalSet, proposals.map((p) => {
7365
- const key = tfProposalStateKey(p);
7366
- const override = tfProposalOverrides[key] || {};
7367
- return [tfProposalDecisions[key], override.level, override.skipped];
7368
- })]);
7369
- if (existing && existing.proposalSignature === signature) return;
7370
7351
  if (existing) existing.remove();
7371
-
7372
- const row = document.createElement('article');
7373
- row.className = 'message employee review-turn learn-proposal-turn';
7374
- row.id = 'learn-proposal-turn';
7375
- row.dataset.testid = 'learn-proposal-turn';
7376
- row.proposalSignature = signature;
7377
- const bubble = document.createElement('div');
7378
- bubble.className = 'bubble';
7379
-
7380
- const label = document.createElement('div');
7381
- label.className = 'review-turn-label';
7382
- const undecided = proposals.filter((p) => !tfProposalDecisions[tfProposalStateKey(p)]).length;
7383
- label.textContent = undecided
7384
- ? `${undecided} lesson${undecided === 1 ? '' : 's'} waiting on your decision`
7385
- : 'Lessons reviewed';
7386
- bubble.appendChild(label);
7387
-
7388
- if (undecided) bubble.appendChild(tfLearningBatchBar(proposals, scope));
7389
- const list = document.createElement('div');
7390
- list.className = 'learn-list';
7391
- for (const p of proposals) list.appendChild(tfProposalCard(p, scope));
7392
- bubble.appendChild(list);
7393
-
7394
- // Issue #1857 R7. A claim the manager already decided is not a card, because
7395
- // re-asking a settled question is the cost this issue exists to remove. It is
7396
- // also not silence: suppression the manager cannot see is a system quietly
7397
- // deciding on their behalf. One line each, after the batch.
7398
- const suppressed = tfSuppressedClaims(conv);
7399
- if (suppressed.length) {
7400
- const note = document.createElement('div');
7401
- note.className = 'learn-suppressed';
7402
- note.dataset.testid = 'learn-suppressed';
7403
- const lead = document.createElement('div');
7404
- lead.className = 'ls-lead';
7405
- lead.textContent = `Not shown: ${suppressed.length} claim${suppressed.length === 1 ? '' : 's'} `
7406
- + `matched ${suppressed.length === 1 ? 'a decision' : 'decisions'} you already made.`;
7407
- note.appendChild(lead);
7408
- for (const claim of suppressed) {
7409
- const line = document.createElement('div');
7410
- line.className = 'ls-row';
7411
- // D2: a claim a rule or skill now owns was promoted, not refused. Saying
7412
- // "you rejected this" about work the manager approved elsewhere is wrong.
7413
- line.textContent = claim.kind === 'superseded'
7414
- ? `"${claim.claimKey}" superseded ${claim.rejectedAt}: ${claim.reason}`
7415
- : `"${claim.claimKey}" rejected ${claim.rejectedAt}: "${claim.reason}"`
7416
- + (claim.newSourceCount ? ` - ${claim.newSourceCount} new source${claim.newSourceCount === 1 ? '' : 's'}` : '');
7417
- note.appendChild(line);
7418
- }
7419
- bubble.appendChild(note);
7420
- }
7421
-
7422
- row.appendChild(bubble);
7423
- if (tfProposalSubmissions.has(conv.id)) {
7424
- row.querySelectorAll('button, textarea').forEach((control) => { control.disabled = true; });
7425
- }
7426
- // Below the thread, above the deliverables/next-job furniture.
7427
- host.insertBefore(row, host.querySelector('#deliverables-bar, #next-job-section'));
7428
7352
  }
7429
7353
 
7430
7354
  // Like ordinary reviewTurns, acknowledged proposals belong to conversation history.
@@ -15389,17 +15313,42 @@ async function tfFetchPreservedLearnings(scope, level) {
15389
15313
  * `undefined` = not loaded yet, so a first render can say "Loading" rather than
15390
15314
  * claiming there are no rejections.
15391
15315
  */
15392
- function tfRejectionsCache() { return state._rejections; }
15316
+ let tfRejectionsGeneration = 0;
15317
+ function tfRejectionsCache() {
15318
+ return state._rejectionsProject === tfCanonicalProjectPath(state.projectPath || '') ? state._rejections : undefined;
15319
+ }
15320
+ function tfInvalidateRejections() {
15321
+ tfRejectionsGeneration += 1;
15322
+ state._rejections = undefined;
15323
+ state._rejectionsPending = undefined;
15324
+ state._rejectionsProject = undefined;
15325
+ }
15393
15326
  async function tfFetchRejections() {
15327
+ const projectPath = state.projectPath || '';
15328
+ const projectKey = tfCanonicalProjectPath(projectPath);
15329
+ if (state._rejectionsPending?.projectKey === projectKey) return state._rejectionsPending.promise;
15330
+ const generation = ++tfRejectionsGeneration;
15394
15331
  const params = new URLSearchParams();
15395
- if (state.projectPath) params.set('projectPath', state.projectPath);
15396
- try {
15397
- const data = await requestJson('/api/ai-hub/rejections?' + params.toString());
15398
- state._rejections = Array.isArray(data.entries) ? data.entries : [];
15399
- } catch {
15400
- state._rejections = [];
15401
- }
15402
- return state._rejections;
15332
+ if (projectPath) params.set('projectPath', projectPath);
15333
+ const promise = (async () => {
15334
+ let entries;
15335
+ try {
15336
+ const data = await requestJson('/api/ai-hub/rejections?' + params.toString());
15337
+ entries = Array.isArray(data.entries) ? data.entries : [];
15338
+ } catch {
15339
+ entries = [];
15340
+ }
15341
+ // Both identity and generation matter: an A -> B -> A switch must not
15342
+ // resurrect a response from the first visit to A.
15343
+ if (generation === tfRejectionsGeneration && projectKey === tfCanonicalProjectPath(state.projectPath || '')) {
15344
+ state._rejections = entries;
15345
+ state._rejectionsProject = projectKey;
15346
+ state._rejectionsPending = undefined;
15347
+ }
15348
+ return entries;
15349
+ })();
15350
+ state._rejectionsPending = { projectKey, promise };
15351
+ return promise;
15403
15352
  }
15404
15353
 
15405
15354
  /**
@@ -15408,13 +15357,13 @@ async function tfFetchRejections() {
15408
15357
  * because a refused claim is settled and the library is about what the employee
15409
15358
  * knows, not what it was told no about.
15410
15359
  */
15411
- function tfRejectedSection(rejections) {
15360
+ function tfRejectedSection(rejections, unknownScope = false) {
15412
15361
  const box = document.createElement('details');
15413
15362
  box.className = 'learn-dormant-section learn-rejected-section';
15414
- box.dataset.testid = 'rejected-section';
15363
+ box.dataset.testid = unknownScope ? 'unknown-rejection-section' : 'rejected-section';
15415
15364
  const summary = document.createElement('summary');
15416
15365
  summary.className = 'learn-dormant-header';
15417
- summary.textContent = `Rejected (${rejections.length})`;
15366
+ summary.textContent = `${unknownScope ? 'History with unknown scope' : 'Rejected'} (${rejections.length})`;
15418
15367
  box.appendChild(summary);
15419
15368
 
15420
15369
  for (const r of rejections) {
@@ -15445,6 +15394,14 @@ function tfRejectedSection(rejections) {
15445
15394
 
15446
15395
  const meta = document.createElement('div');
15447
15396
  meta.className = 'lp-stats';
15397
+ const owner = document.createElement('span');
15398
+ owner.className = 'lp-score lr-owner';
15399
+ owner.dataset.testid = 'lr-owner';
15400
+ const targetOwner = r.targetOwner;
15401
+ owner.textContent = targetOwner
15402
+ ? `${targetOwner.level === 'Org' ? 'Company' : targetOwner.level}: ${targetOwner.path}`
15403
+ : 'Scope unknown';
15404
+ meta.appendChild(owner);
15448
15405
  if (r.rejectedAt) {
15449
15406
  const when = document.createElement('span');
15450
15407
  when.className = 'lp-score';
@@ -16096,14 +16053,23 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
16096
16053
  if (preserved === undefined) return;
16097
16054
  }
16098
16055
 
16099
- // Issue #1857 R19. Rejections are personal to the manager, so only their own
16100
- // surface asks for them; the company and reverse-mentoring lists are other
16101
- // people's corpora and a refusal there is not this manager's to review.
16056
+ // The reader scopes Project targets to the request's project. Each Info
16057
+ // surface then selects its own recorded level from that project-keyed cache.
16058
+ // Org history delivery is separate from these personal/project surfaces.
16102
16059
  const showsRejections = scope === 'manager';
16103
16060
  if (showsRejections && tfRejectionsCache() === undefined) {
16104
16061
  tfFetchRejections().then(() => tfReRenderScope(scope, level)).catch(() => {});
16105
16062
  }
16106
- const rejections = showsRejections && Array.isArray(tfRejectionsCache()) ? tfRejectionsCache() : [];
16063
+ const history = showsRejections && Array.isArray(tfRejectionsCache()) ? tfRejectionsCache() : [];
16064
+ const ownerLevel = level === 'project' ? 'Project' : level === 'machine' ? 'Manager' : undefined;
16065
+ const rejections = history.filter(entry => ownerLevel && entry.targetOwner?.level === ownerLevel);
16066
+ // Unknown ownership has one inspection home, separate from Manager's owned
16067
+ // rejection count. Keeping it visible does not assign it Manager ownership.
16068
+ const unknownHistory = level === 'machine' ? history.filter(entry => !entry.targetOwner) : [];
16069
+ const appendRejectionHistory = () => {
16070
+ if (rejections.length) host.appendChild(tfRejectedSection(rejections));
16071
+ if (unknownHistory.length) host.appendChild(tfRejectedSection(unknownHistory, true));
16072
+ };
16107
16073
 
16108
16074
  const addCategories = scope === 'reverse' ? ['coaching']
16109
16075
  : scope === 'org' ? ['avoid', 'preference', 'repeat', 'coaching']
@@ -16209,7 +16175,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
16209
16175
 
16210
16176
  if (list.children.length) {
16211
16177
  host.appendChild(list);
16212
- if (rejections.length && !shareMode) host.appendChild(tfRejectedSection(rejections));
16178
+ if (!shareMode) appendRejectionHistory();
16213
16179
  if (!shareMode) { host.appendChild(addBtn); wireAdd(); }
16214
16180
  if (shareMode) tfUpdateShareBar(host, scope, level);
16215
16181
  return;
@@ -16232,7 +16198,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
16232
16198
  empty.appendChild(emoji); empty.appendChild(title); empty.appendChild(sub);
16233
16199
  host.appendChild(empty);
16234
16200
  if (shareMode) { tfUpdateShareBar(host, scope, level); return; }
16235
- if (rejections.length) host.appendChild(tfRejectedSection(rejections));
16201
+ appendRejectionHistory();
16236
16202
  host.appendChild(addBtn);
16237
16203
  wireAdd();
16238
16204
  }
@@ -17833,6 +17799,7 @@ async function tfSwitchProjectFolder(folderPath, options) {
17833
17799
  return;
17834
17800
  }
17835
17801
  state.projectPath = normalized.folderPath;
17802
+ tfInvalidateRejections();
17836
17803
  // Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
17837
17804
  // project's active conversation on switch. Otherwise the just-finished run stays state.activeId
17838
17805
  // while state.projectPath is the new project, and a debounced conversation PUT during the