fraim-framework 2.0.195 → 2.0.196

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.
@@ -1971,59 +1971,41 @@ class AiHubServer {
1971
1971
  }));
1972
1972
  try {
1973
1973
  // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
1974
- // via the user's API key — never a local MongoDB connection. A null result means
1975
- // no/expired key or the feature is off; render the locked fallback (not-signed-in).
1974
+ // via the user's API key — never a local MongoDB connection.
1976
1975
  const state = await this.remoteGateway.getPersonaState(apiKey);
1976
+ // A null result means we could not reach the authority (no/expired key or a
1977
+ // transport error). That is the ONLY access decision the Hub makes on its own —
1978
+ // render the locked "not-signed-in" fallback. When a state IS returned, its
1979
+ // per-persona `status` is authoritative and rendered verbatim: the hired/locked
1980
+ // decision (feature-off, legacy bypass, per-entitlement gating) lives solely in
1981
+ // persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
1982
+ // re-derive it.
1977
1983
  if (!state) {
1978
1984
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1979
1985
  }
1980
- // Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
1981
- // Mirrors the evaluatePersonaAccess legacy bypass in persona-entitlement-service.ts.
1982
- // Seats are not assignable on legacy workspaces (the hosted server is always DB-backed).
1983
- if (!state.subscriptionActive) {
1984
- const allPersonas = allBundles.map((bundle) => ({
1985
- key: bundle.personaKey,
1986
- displayName: bundle.catalogMetadata.displayName,
1987
- role: bundle.catalogMetadata.role,
1988
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
1989
- pricingLabel: '',
1990
- status: 'hired',
1991
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1992
- seatCount: 0,
1993
- seatsInUse: 0,
1994
- }));
1995
- return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
1996
- }
1997
- const hiredKeys = new Set(state.entitlements
1998
- .filter((e) => e.status === 'active')
1999
- .map((e) => e.personaKey));
2000
- // Build per-persona seat counts from entitlement records.
2001
- // jobCreditsRemaining represents the number of job-credits (seats) purchased.
2002
- const seatCountByKey = {};
2003
- for (const e of state.entitlements) {
2004
- if (e.status === 'active') {
2005
- seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
2006
- }
2007
- }
2008
- // seatsInUse for display: derived from the hosted manager team. Authoritative
2009
- // out-of-stock enforcement lives on the hosted assign endpoint (server-side),
2010
- // so this display count does not need to be workspace-wide.
1986
+ const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
1987
+ // seatsInUse (manager-team assignments) is display-only accounting fetched
1988
+ // separately; it is not part of the access verdict.
2011
1989
  const seatsInUseByKey = {};
2012
1990
  const team = await this.remoteGateway.listManagerTeam(apiKey);
2013
1991
  for (const entry of team) {
2014
1992
  seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
2015
1993
  }
2016
- const personas = allBundles.map((bundle) => ({
2017
- key: bundle.personaKey,
2018
- displayName: bundle.catalogMetadata.displayName,
2019
- role: bundle.catalogMetadata.role,
2020
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2021
- pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
2022
- status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
2023
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2024
- seatCount: seatCountByKey[bundle.personaKey] ?? 0,
2025
- seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
2026
- }));
1994
+ const personas = allBundles.map((bundle) => {
1995
+ const verdict = verdictByKey.get(bundle.personaKey);
1996
+ const status = (verdict?.status ?? 'locked');
1997
+ return {
1998
+ key: bundle.personaKey,
1999
+ displayName: bundle.catalogMetadata.displayName,
2000
+ role: bundle.catalogMetadata.role,
2001
+ avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2002
+ pricingLabel: status === 'hired' ? '' : bundle.catalogMetadata.pricingLabel,
2003
+ status,
2004
+ hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2005
+ seatCount: verdict?.seatCount ?? 0,
2006
+ seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
2007
+ };
2008
+ });
2027
2009
  return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
2028
2010
  }
2029
2011
  catch (err) {
@@ -10,13 +10,13 @@ async function getMyPersonas(req, res, dbService) {
10
10
  res.status(401).json({ error: 'Authentication required' });
11
11
  return;
12
12
  }
13
+ // Feature OFF => the persona system isn't gating anything, so report the
14
+ // ungated state (every persona accessible) rather than a 404. This makes the
15
+ // authenticated persona view a single source of truth the Hub renders
16
+ // verbatim, and matches the execution gate (maybeBuildPersonaLockResponse),
17
+ // which also treats "flag off" as "allowed". No DB lookup needed.
13
18
  if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
14
- res.status(404).json({
15
- error: 'Not found',
16
- featureFlags: {
17
- personaEntitlements: false
18
- }
19
- });
19
+ res.json((0, persona_entitlement_service_1.buildUngatedPersonaWorkspaceState)(apiKeyData.userId, apiKeyData.key));
20
20
  return;
21
21
  }
22
22
  const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, apiKeyData.userId, apiKeyData.key);
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeSubscriptionActive = computeSubscriptionActive;
4
+ exports.resolvePersonaAccessStatuses = resolvePersonaAccessStatuses;
5
+ exports.buildUngatedPersonaWorkspaceState = buildUngatedPersonaWorkspaceState;
3
6
  exports.buildPersonaHireUrl = buildPersonaHireUrl;
4
7
  exports.syncPersonaEntitlementPurchase = syncPersonaEntitlementPurchase;
5
8
  exports.syncPersonaEntitlementFromCheckoutSession = syncPersonaEntitlementFromCheckoutSession;
@@ -9,6 +12,66 @@ const persona_hiring_1 = require("../config/persona-hiring");
9
12
  const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
10
13
  const workspace_identity_1 = require("./workspace-identity");
11
14
  const DEFAULT_JOB_CREDITS = 1;
15
+ // The one place the "is the persona system gating this workspace?" question is
16
+ // answered. A workspace is subscribed if it explicitly opted in (personaSystemActive)
17
+ // or has ever held an active entitlement. Shared by getWorkspacePersonaState and
18
+ // evaluatePersonaAccess so display and enforcement can never diverge.
19
+ function computeSubscriptionActive(apiKeyData, entitlements) {
20
+ return apiKeyData?.personaSystemActive === true || entitlements.some((e) => e.status === 'active');
21
+ }
22
+ // Single source of truth for per-persona access, mirroring evaluatePersonaAccess's
23
+ // gating rules exactly:
24
+ // - `ungated` (feature flag OFF, or legacy/never-subscribed workspace) => every
25
+ // persona is accessible ('hired').
26
+ // - otherwise => 'hired' iff the workspace holds an active entitlement for it.
27
+ // Pure and DB-free so it is trivially testable and callable from either code path.
28
+ function resolvePersonaAccessStatuses(ungated, entitlements) {
29
+ const activeByKey = new Map();
30
+ for (const entitlement of entitlements) {
31
+ if (entitlement.status !== 'active')
32
+ continue;
33
+ const list = activeByKey.get(entitlement.personaKey) || [];
34
+ list.push(entitlement);
35
+ activeByKey.set(entitlement.personaKey, list);
36
+ }
37
+ return (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => {
38
+ const active = activeByKey.get(bundle.personaKey) || [];
39
+ const seatCount = active.reduce((sum, e) => sum + (e.jobCreditsRemaining ?? 1), 0);
40
+ return {
41
+ personaKey: bundle.personaKey,
42
+ personaName: bundle.catalogMetadata.displayName,
43
+ personaRole: bundle.catalogMetadata.role,
44
+ hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
45
+ status: (ungated || active.length > 0) ? 'hired' : 'locked',
46
+ seatCount,
47
+ };
48
+ });
49
+ }
50
+ // The persona-entitlements feature is OFF: there is no gating, so every persona is
51
+ // accessible. Returned by /api/personas/me instead of a 404 so downstream consumers
52
+ // (the Hub especially) inherit "flag off => all employees available" rather than
53
+ // inventing their own fallback. Matches maybeBuildPersonaLockResponse, which also
54
+ // short-circuits to "allowed" when the flag is off. No DB lookup is required.
55
+ function buildUngatedPersonaWorkspaceState(userId, apiKey) {
56
+ return {
57
+ featureFlagEnabled: false,
58
+ featureFlags: { personaEntitlementsEnabled: false },
59
+ subscriptionActive: false,
60
+ workspaceId: normalizeEmail(userId),
61
+ userId: normalizeEmail(userId),
62
+ apiKey: apiKey || null,
63
+ entitlements: [],
64
+ catalog: (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => ({
65
+ personaKey: bundle.personaKey,
66
+ personaName: bundle.catalogMetadata.displayName,
67
+ personaRole: bundle.catalogMetadata.role,
68
+ hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
69
+ protectedJobs: bundle.protectedJobs,
70
+ })),
71
+ lockedPersonas: [],
72
+ personas: resolvePersonaAccessStatuses(true, []),
73
+ };
74
+ }
12
75
  function normalizeEmail(email) {
13
76
  return email.trim().toLowerCase();
14
77
  }
@@ -154,9 +217,7 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
154
217
  protectedJobs: bundle.protectedJobs
155
218
  }));
156
219
  const knownPersonaKeys = new Set(entitlements.map((entitlement) => entitlement.personaKey));
157
- // Subscribed = explicitly opted in via flag, OR has ever had an active entitlement.
158
- const subscriptionActive = apiKeyData?.personaSystemActive === true ||
159
- entitlements.some((e) => e.status === 'active');
220
+ const subscriptionActive = computeSubscriptionActive(apiKeyData, entitlements);
160
221
  return {
161
222
  featureFlagEnabled: true,
162
223
  featureFlags: {
@@ -185,7 +246,10 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
185
246
  .map((persona) => ({
186
247
  ...persona,
187
248
  status: 'locked'
188
- }))
249
+ })),
250
+ // Authoritative per-persona verdict: a non-subscribed (legacy) workspace is
251
+ // ungated, so all personas are accessible; a subscribed one gates to owned.
252
+ personas: resolvePersonaAccessStatuses(!subscriptionActive, entitlements),
189
253
  };
190
254
  }
191
255
  async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnTo) {
@@ -205,9 +269,9 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
205
269
  });
206
270
  const workspaceEntitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
207
271
  // Legacy bypass: if the workspace has never subscribed to the persona system,
208
- // all jobs are freely accessible — no lock enforcement.
209
- const subscriptionActive = apiKeyData?.personaSystemActive === true ||
210
- workspaceEntitlements.some((e) => e.status === 'active');
272
+ // all jobs are freely accessible — no lock enforcement. Same rule the Hub
273
+ // display inherits via resolvePersonaAccessStatuses(!subscriptionActive, ...).
274
+ const subscriptionActive = computeSubscriptionActive(apiKeyData, workspaceEntitlements);
211
275
  if (!subscriptionActive) {
212
276
  return {
213
277
  allowed: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.195",
3
+ "version": "2.0.196",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -3277,6 +3277,10 @@ function convAwaitingReview(conv) {
3277
3277
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
3278
3278
  // stakeholder-status-reporting) may legitimately surface artifacts for human review.
3279
3279
  if (delegationLedgerForConversation(conv)) return false;
3280
+ const runArtifacts = Array.isArray(conv.artifacts) ? conv.artifacts : [];
3281
+ if (conv.status === 'completed' && runArtifacts.some((artifact) => artifactLocalPath(artifact) || artifactExternalUrl(artifact))) {
3282
+ return true;
3283
+ }
3280
3284
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
3281
3285
  // only belongs when the employee actually submits for review (a review_handoff,
3282
3286
  // emitted by the submit phase). A turn that just finished (a question, a pause
@@ -3483,12 +3487,32 @@ function hasReviewHandoffSignal(text) {
3483
3487
  return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
3484
3488
  }
3485
3489
 
3490
+ function reviewSubmissionClaimText(text) {
3491
+ return String(stripMarkdownForDisplay(text) || '')
3492
+ .replace(/\s+/g, ' ')
3493
+ .trim();
3494
+ }
3495
+
3496
+ function hasExplicitReviewSubmissionClaim(text) {
3497
+ const cleaned = reviewSubmissionClaimText(text);
3498
+ if (!cleaned) return false;
3499
+ if (/\b(?:not\s+ready|not\s+yet\s+ready|will\s+be\s+ready|soon\s+be\s+ready)\b.{0,60}\breview\b/i.test(cleaned)) {
3500
+ return false;
3501
+ }
3502
+ return /\bready\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
3503
+ || /\bplease\s+review\b/i.test(cleaned)
3504
+ || /\bsubmitted\s+(?:the\s+|this\s+|my\s+)?(?:work|deliverable|artifact|bundle|pr|pull request|document|doc|file|files)\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
3505
+ || /\b(?:review|open)\s+(?:the\s+)?(?:pr|pull request|artifact|document|doc|file|files|deliverable|bundle)\b/i.test(cleaned);
3506
+ }
3507
+
3486
3508
  function reviewHandoffIssueForConversation(conv) {
3487
3509
  if (!conv) return '';
3488
3510
  if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
3489
3511
  return 'The review handoff contract is invalid.';
3490
3512
  }
3491
3513
  const messages = conv.messages || [];
3514
+ const hasRunArtifacts = Array.isArray(conv.artifacts) && conv.artifacts.length > 0;
3515
+ const latestEmployee = [...messages].reverse().find((message) => message.role === 'employee');
3492
3516
  for (let i = messages.length - 1; i >= 0; i -= 1) {
3493
3517
  if (messages[i].role !== 'employee') continue;
3494
3518
  if (!hasReviewHandoffSignal(messages[i].text)) continue;
@@ -3496,6 +3520,9 @@ function reviewHandoffIssueForConversation(conv) {
3496
3520
  return 'The review handoff contract is malformed or missing required fields.';
3497
3521
  }
3498
3522
  }
3523
+ if (!conv.reviewHandoff && !hasRunArtifacts && latestEmployee && hasExplicitReviewSubmissionClaim(latestEmployee.text)) {
3524
+ return 'This run says it is ready for review, but did not include a structured review handoff.';
3525
+ }
3499
3526
  return '';
3500
3527
  }
3501
3528
 
@@ -3851,7 +3878,7 @@ function renderReviewExperience(conv) {
3851
3878
  if (handoff) {
3852
3879
  const hasArtifact = !!(fmt.actions && fmt.actions.length);
3853
3880
  handoff.textContent = fmt.key === 'contract_error'
3854
- ? 'The review handoff contract is invalid. Ask the employee to resend it before review.'
3881
+ ? `${fmt.issue || 'The review handoff contract is invalid.'} Ask the employee to resend it before review.`
3855
3882
  : fmt.key === 'pull_request'
3856
3883
  ? 'Comment on the PR to leave inline notes, or approve / type changes below.'
3857
3884
  : isManagerTemplateJob(conv && conv.jobId)