fraim-framework 2.0.194 → 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) {
@@ -2252,9 +2234,20 @@ class AiHubServer {
2252
2234
  if (!Array.isArray(body.conversations)) {
2253
2235
  return res.status(400).json({ error: 'conversations array required' });
2254
2236
  }
2237
+ // The client strips server-owned heavy fields (messages/events/artifacts/run/
2238
+ // delegation) from its PUT payload to stay under the body-size limit. Merge them
2239
+ // back from the stored record so a client-owned change (e.g. reviewApproved) never
2240
+ // erases run history. List membership still comes from the incoming array, so
2241
+ // deletes are honored (a conversation absent here is dropped).
2242
+ const prior = this.conversationStore.loadProject(key);
2243
+ const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
2244
+ const conversations = body.conversations.map((incoming) => {
2245
+ const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
2246
+ return existing ? { ...existing, ...incoming } : incoming;
2247
+ });
2255
2248
  const saved = this.conversationStore.replaceProject(key, {
2256
2249
  activeId: body.activeId ?? null,
2257
- conversations: body.conversations,
2250
+ conversations,
2258
2251
  });
2259
2252
  return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
2260
2253
  }
@@ -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.194",
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": {
@@ -33,18 +33,18 @@
33
33
  <div class="am-email" id="am-email"></div>
34
34
  </div>
35
35
  </div>
36
- <button class="am-item" id="am-account" type="button">
36
+ <button class="am-item" id="am-account" type="button">
37
37
  <span class="am-ico">👤</span>
38
38
  <div><div class="am-label">Account</div><div class="am-sub">API key, sessions, billing</div></div>
39
- </button>
39
+ </button>
40
40
  <button class="am-item" id="am-brain" type="button">
41
41
  <span class="am-ico">🧠</span>
42
42
  <div><div class="am-label">Brain</div><div class="am-sub">Everything your team knows</div></div>
43
43
  </button>
44
- <button class="am-item" id="am-analytics" type="button">
44
+ <button class="am-item" id="am-analytics" type="button">
45
45
  <span class="am-ico">📊</span>
46
46
  <div><div class="am-label">Analytics</div><div class="am-sub">Usage, cost, and ROI</div></div>
47
- </button>
47
+ </button>
48
48
  <button class="am-item am-theme" id="am-theme-toggle" type="button" role="switch" aria-checked="false">
49
49
  <span class="am-ico" id="am-theme-ico">🌙</span>
50
50
  <div><div class="am-label">Dark mode</div><div class="am-sub" id="am-theme-sub">Off</div></div>
@@ -141,21 +141,21 @@
141
141
  </div>
142
142
  </section>
143
143
 
144
- <section class="hub-area" id="area-connected" hidden>
145
- <div class="connected-shell">
146
- <div class="connected-toolbar">
147
- <div class="connected-title-block">
148
- <div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
149
- <div class="connected-title" id="connected-title">Account</div>
150
- </div>
151
- <div class="connected-origin" id="connected-origin"></div>
152
- <button class="connected-close" id="connected-close" type="button">Back</button>
153
- </div>
154
- <iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
155
- </div>
156
- </section>
157
-
158
- <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
144
+ <section class="hub-area" id="area-connected" hidden>
145
+ <div class="connected-shell">
146
+ <div class="connected-toolbar">
147
+ <div class="connected-title-block">
148
+ <div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
149
+ <div class="connected-title" id="connected-title">Account</div>
150
+ </div>
151
+ <div class="connected-origin" id="connected-origin"></div>
152
+ <button class="connected-close" id="connected-close" type="button">Back</button>
153
+ </div>
154
+ <iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
155
+ </div>
156
+ </section>
157
+
158
+ <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
159
159
  <section class="hub-area on" id="area-projects">
160
160
  <div class="proj-tabs" id="proj-tabs">
161
161
  <button class="ptab on" id="ptab-overview" type="button" data-view="overview">Overview</button>
@@ -868,6 +868,6 @@
868
868
  </div>
869
869
  </div>
870
870
 
871
- <script src="./script.js"></script>
871
+ <script src="./script.js?v=conv-persist-20260705"></script>
872
872
  </body>
873
873
  </html>
@@ -507,11 +507,29 @@ function normalizeGeminiConversationMessages(conv) {
507
507
  return changed;
508
508
  }
509
509
 
510
+ // Server-owned conversation fields. The server re-derives these from the run
511
+ // registry and writes them to the conversation store on every run update
512
+ // (persistRunConversation), so the client must NOT re-upload them: a large run's
513
+ // messages/events/artifacts push the PUT body past the server's size limit, the
514
+ // request 413s, the error is swallowed, and the client-owned flags in the SAME
515
+ // payload (e.g. reviewApproved from "Mark complete") never persist. Strip them
516
+ // here; the server's PUT handler merges them back from the stored record.
517
+ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation'];
518
+ function slimConversationForPersist(conv) {
519
+ if (!conv || typeof conv !== 'object') return conv;
520
+ const slim = { ...conv };
521
+ for (const field of SERVER_OWNED_CONV_FIELDS) delete slim[field];
522
+ return slim;
523
+ }
524
+ function slimConversationsForPersist(list) {
525
+ return Array.isArray(list) ? list.map(slimConversationForPersist) : list;
526
+ }
527
+
510
528
  function projectConversationPayload() {
511
529
  return {
512
530
  projectPath: state.projectPath || '',
513
531
  activeId: state.activeId || null,
514
- conversations: projectConversations(),
532
+ conversations: slimConversationsForPersist(projectConversations()),
515
533
  };
516
534
  }
517
535
 
@@ -537,7 +555,7 @@ function scheduleConversationDiskPersist() {
537
555
  await requestJson('/api/ai-hub/conversations', {
538
556
  method: 'PUT',
539
557
  headers: { 'Content-Type': 'application/json' },
540
- body: JSON.stringify({ scope, activeId: null, conversations: bucket }),
558
+ body: JSON.stringify({ scope, activeId: null, conversations: slimConversationsForPersist(bucket) }),
541
559
  });
542
560
  }
543
561
  state.conversationDiskAvailable = true;
@@ -3259,6 +3277,10 @@ function convAwaitingReview(conv) {
3259
3277
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
3260
3278
  // stakeholder-status-reporting) may legitimately surface artifacts for human review.
3261
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
+ }
3262
3284
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
3263
3285
  // only belongs when the employee actually submits for review (a review_handoff,
3264
3286
  // emitted by the submit phase). A turn that just finished (a question, a pause
@@ -3465,12 +3487,32 @@ function hasReviewHandoffSignal(text) {
3465
3487
  return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
3466
3488
  }
3467
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
+
3468
3508
  function reviewHandoffIssueForConversation(conv) {
3469
3509
  if (!conv) return '';
3470
3510
  if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
3471
3511
  return 'The review handoff contract is invalid.';
3472
3512
  }
3473
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');
3474
3516
  for (let i = messages.length - 1; i >= 0; i -= 1) {
3475
3517
  if (messages[i].role !== 'employee') continue;
3476
3518
  if (!hasReviewHandoffSignal(messages[i].text)) continue;
@@ -3478,6 +3520,9 @@ function reviewHandoffIssueForConversation(conv) {
3478
3520
  return 'The review handoff contract is malformed or missing required fields.';
3479
3521
  }
3480
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
+ }
3481
3526
  return '';
3482
3527
  }
3483
3528
 
@@ -3833,7 +3878,7 @@ function renderReviewExperience(conv) {
3833
3878
  if (handoff) {
3834
3879
  const hasArtifact = !!(fmt.actions && fmt.actions.length);
3835
3880
  handoff.textContent = fmt.key === 'contract_error'
3836
- ? '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.`
3837
3882
  : fmt.key === 'pull_request'
3838
3883
  ? 'Comment on the PR to leave inline notes, or approve / type changes below.'
3839
3884
  : isManagerTemplateJob(conv && conv.jobId)