fraim-hub 2.0.210 → 2.0.211

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.
@@ -211,7 +211,7 @@ function normalizeConversation(projectPath, raw) {
211
211
  return null;
212
212
  if (typeof value.jobId !== 'string')
213
213
  return null;
214
- if (value.agentName !== 'codex' && value.agentName !== 'claude' && value.agentName !== 'gemini')
214
+ if (typeof value.agentName !== 'string' || value.agentName.trim().length === 0)
215
215
  return null;
216
216
  if (value.status !== 'running' && value.status !== 'completed' && value.status !== 'failed')
217
217
  return null;
@@ -301,11 +301,8 @@ class AiHubConversationStore {
301
301
  lockPath(bucketDir) {
302
302
  return path_1.default.join(bucketDir, '.lock');
303
303
  }
304
- // ---- migration (one-time split of the legacy monolith) ----
305
- ensureMigrated() {
306
- if (this.migrated)
307
- return;
308
- this.migrated = true;
304
+ // ---- migration (split of the legacy monolith) ----
305
+ importLegacyMonolithIfPresent() {
309
306
  let stat;
310
307
  try {
311
308
  stat = fs_1.default.statSync(this.stateFilePath);
@@ -338,6 +335,17 @@ class AiHubConversationStore {
338
335
  /* best-effort backup; another process may have already migrated */
339
336
  }
340
337
  }
338
+ ensureMigrated() {
339
+ if (!this.migrated) {
340
+ this.migrated = true;
341
+ this.importLegacyMonolithIfPresent();
342
+ return;
343
+ }
344
+ // Compatibility for tests and old processes that still recreate the legacy
345
+ // monolith after this store has already been opened. Shards remain the
346
+ // source of truth; a newly observed monolith is imported and moved aside.
347
+ this.importLegacyMonolithIfPresent();
348
+ }
341
349
  // ---- bucket read helpers (lock-free) ----
342
350
  readAllConversations(bucketDir, bucketKey) {
343
351
  let files;
@@ -14,6 +14,7 @@ const remote_hub_gateway_1 = require("./remote-hub-gateway");
14
14
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
15
15
  const version_utils_1 = require("../cli/utils/version-utils");
16
16
  const hub_runtime_file_1 = require("./hub-runtime-file");
17
+ const window_open_decision_1 = require("./window-open-decision");
17
18
  // ---------------------------------------------------------------------------
18
19
  // State
19
20
  // ---------------------------------------------------------------------------
@@ -54,66 +55,6 @@ function applyUserDataOverride() {
54
55
  fs_1.default.mkdirSync(userDataDir, { recursive: true });
55
56
  electron_1.app.setPath('userData', userDataDir);
56
57
  }
57
- function isTrustedInAppNavigation(targetUrl, hubUrl) {
58
- try {
59
- const parsed = new URL(targetUrl);
60
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
61
- return false;
62
- return parsed.origin === new URL(hubUrl).origin || parsed.origin === new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)()).origin;
63
- }
64
- catch {
65
- return false;
66
- }
67
- }
68
- function connectedSurfaceParam(value) {
69
- return value === 'account' || value === 'analytics' || value === 'brain' ? value : null;
70
- }
71
- function connectedSurfaceForHostedUrl(targetUrl, hubUrl) {
72
- try {
73
- const parsed = new URL(targetUrl);
74
- const hub = new URL(hubUrl);
75
- const remote = new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
76
- if (parsed.origin === hub.origin || parsed.origin !== remote.origin)
77
- return null;
78
- const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
79
- if (pathname === '/account')
80
- return 'account';
81
- if (pathname === '/analytics')
82
- return 'analytics';
83
- if (pathname === '/fraim-brain' || pathname === '/fraim-brain.html')
84
- return 'brain';
85
- if (pathname === '/ai-hub') {
86
- return connectedSurfaceParam(parsed.searchParams.get('connected') || parsed.searchParams.get('open'));
87
- }
88
- if (pathname === '/auth/sign-in.html' || pathname === '/auth/recovery.html' || pathname === '/auth/error.html') {
89
- return connectedSurfaceParam(parsed.searchParams.get('surface'));
90
- }
91
- return null;
92
- }
93
- catch {
94
- return null;
95
- }
96
- }
97
- function localHubConnectedUrl(hubUrl, surface, sourceUrl) {
98
- const target = new URL(hubUrl);
99
- target.search = '';
100
- target.hash = '';
101
- target.searchParams.set('connected', surface);
102
- if (sourceUrl) {
103
- try {
104
- const source = new URL(sourceUrl);
105
- const apiKey = source.searchParams.get('api-key') || source.searchParams.get('apiKey');
106
- if (apiKey)
107
- target.searchParams.set('api-key', apiKey);
108
- }
109
- catch { /* ignore malformed source URLs */ }
110
- }
111
- return target.toString();
112
- }
113
- function wrappedHostedNavigationUrl(targetUrl, hubUrl) {
114
- const surface = connectedSurfaceForHostedUrl(targetUrl, hubUrl);
115
- return surface ? localHubConnectedUrl(hubUrl, surface, targetUrl) : null;
116
- }
117
58
  // ---------------------------------------------------------------------------
118
59
  // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
119
60
  // so the app never crashes if assets aren't present.
@@ -281,18 +222,22 @@ async function createWindow(url) {
281
222
  mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
282
223
  }
283
224
  mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
284
- const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
285
- if (wrappedUrl) {
286
- void mainWindow?.loadURL(wrappedUrl);
287
- return { action: 'deny' };
225
+ const decision = (0, window_open_decision_1.resolveWindowOpenDecision)(targetUrl, url, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
226
+ if (decision.action === 'load-in-app') {
227
+ void mainWindow?.loadURL(decision.url);
288
228
  }
289
- if (isTrustedInAppNavigation(targetUrl, url)) {
290
- void mainWindow?.loadURL(targetUrl);
229
+ else if (decision.action === 'open-external') {
230
+ // e.g. the "Open PR on GitHub" review action — hand it to the OS default
231
+ // browser (issue #835). A bare target="_blank" anchor would otherwise be
232
+ // swallowed here.
233
+ void electron_1.shell.openExternal(decision.url);
291
234
  }
235
+ // A new BrowserWindow is never opened; we navigate the Hub window in-app or
236
+ // defer to the OS browser, so the popup request itself is always denied.
292
237
  return { action: 'deny' };
293
238
  });
294
239
  mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
295
- const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
240
+ const wrappedUrl = (0, window_open_decision_1.wrappedHostedNavigationUrl)(targetUrl, url, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
296
241
  if (!wrappedUrl)
297
242
  return;
298
243
  event.preventDefault();
@@ -303,7 +248,7 @@ async function createWindow(url) {
303
248
  mainWindow.webContents.on('will-redirect', (details) => {
304
249
  if (!details.isMainFrame)
305
250
  return;
306
- const wrappedUrl = wrappedHostedNavigationUrl(details.url, url);
251
+ const wrappedUrl = (0, window_open_decision_1.wrappedHostedNavigationUrl)(details.url, url, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
307
252
  if (!wrappedUrl)
308
253
  return;
309
254
  details.preventDefault();
@@ -312,7 +257,7 @@ async function createWindow(url) {
312
257
  }
313
258
  });
314
259
  mainWindow.webContents.on('did-navigate', (_event, targetUrl) => {
315
- const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
260
+ const wrappedUrl = (0, window_open_decision_1.wrappedHostedNavigationUrl)(targetUrl, url, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
316
261
  if (!wrappedUrl || mainWindow?.webContents.getURL() === wrappedUrl)
317
262
  return;
318
263
  void mainWindow?.loadURL(wrappedUrl);
@@ -98,6 +98,16 @@ function buildReviewApprovalSystemEventText(instructions) {
98
98
  return 'review_approved merge_pr_work_completion';
99
99
  if (/^Approved and clean up branch\.$/i.test(text))
100
100
  return 'review_approved cleanup_branch';
101
+ if (/^Approved and send\.$/i.test(text))
102
+ return 'review_approved send_delivery';
103
+ if (/^Approved and send, then push to\b/i.test(text))
104
+ return 'review_approved send_delivery push_default_branch';
105
+ if (/^Approved and send, then merge PR\.$/i.test(text))
106
+ return 'review_approved send_delivery merge_pr';
107
+ if (/^Approved and send, then merge PR and complete issue\.$/i.test(text))
108
+ return 'review_approved send_delivery merge_pr_work_completion';
109
+ if (/^Approved and send, then clean up branch\.$/i.test(text))
110
+ return 'review_approved send_delivery cleanup_branch';
101
111
  return null;
102
112
  }
103
113
  function loadManagerHiringModule() {
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.wrappedHostedNavigationUrl = wrappedHostedNavigationUrl;
4
+ exports.resolveWindowOpenDecision = resolveWindowOpenDecision;
5
+ const url_safety_1 = require("./url-safety");
6
+ function isTrustedInAppNavigation(targetUrl, hubUrl, remoteUrl) {
7
+ try {
8
+ const parsed = new URL(targetUrl);
9
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
10
+ return false;
11
+ return parsed.origin === new URL(hubUrl).origin || parsed.origin === new URL(remoteUrl).origin;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function connectedSurfaceParam(value) {
18
+ return value === 'account' || value === 'analytics' || value === 'brain' ? value : null;
19
+ }
20
+ function connectedSurfaceForHostedUrl(targetUrl, hubUrl, remoteUrl) {
21
+ try {
22
+ const parsed = new URL(targetUrl);
23
+ const hub = new URL(hubUrl);
24
+ const remote = new URL(remoteUrl);
25
+ if (parsed.origin === hub.origin || parsed.origin !== remote.origin)
26
+ return null;
27
+ const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
28
+ if (pathname === '/account')
29
+ return 'account';
30
+ if (pathname === '/analytics')
31
+ return 'analytics';
32
+ if (pathname === '/fraim-brain' || pathname === '/fraim-brain.html')
33
+ return 'brain';
34
+ if (pathname === '/ai-hub') {
35
+ return connectedSurfaceParam(parsed.searchParams.get('connected') || parsed.searchParams.get('open'));
36
+ }
37
+ if (pathname === '/auth/sign-in.html' || pathname === '/auth/recovery.html' || pathname === '/auth/error.html') {
38
+ return connectedSurfaceParam(parsed.searchParams.get('surface'));
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function localHubConnectedUrl(hubUrl, surface, sourceUrl) {
47
+ const target = new URL(hubUrl);
48
+ target.search = '';
49
+ target.hash = '';
50
+ target.searchParams.set('connected', surface);
51
+ if (sourceUrl) {
52
+ try {
53
+ const source = new URL(sourceUrl);
54
+ const apiKey = source.searchParams.get('api-key') || source.searchParams.get('apiKey');
55
+ if (apiKey)
56
+ target.searchParams.set('api-key', apiKey);
57
+ }
58
+ catch { /* ignore malformed source URLs */ }
59
+ }
60
+ return target.toString();
61
+ }
62
+ function wrappedHostedNavigationUrl(targetUrl, hubUrl, remoteUrl) {
63
+ const surface = connectedSurfaceForHostedUrl(targetUrl, hubUrl, remoteUrl);
64
+ return surface ? localHubConnectedUrl(hubUrl, surface, targetUrl) : null;
65
+ }
66
+ // Route a window-open / new-window request. Precedence:
67
+ // 1. A hosted FRAIM surface (account/analytics/brain) is wrapped back into the
68
+ // Hub shell and loaded in-app.
69
+ // 2. A same-origin (Hub or FRAIM remote) URL is loaded in-app.
70
+ // 3. Any other http/https URL (e.g. a GitHub/GitLab/ADO pull-request link from
71
+ // the review handoff) is opened in the OS default browser. Without this
72
+ // branch the desktop shell silently denies the click (issue #835).
73
+ // 4. Anything else (javascript:, file:, data:, malformed, ...) is denied —
74
+ // safeHttpUrl is the same http/https gate the client and server review
75
+ // handoff use, so nothing unsafe can reach shell.openExternal.
76
+ function resolveWindowOpenDecision(targetUrl, hubUrl, remoteUrl) {
77
+ const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, hubUrl, remoteUrl);
78
+ if (wrappedUrl)
79
+ return { action: 'load-in-app', url: wrappedUrl };
80
+ if (isTrustedInAppNavigation(targetUrl, hubUrl, remoteUrl))
81
+ return { action: 'load-in-app', url: targetUrl };
82
+ const external = (0, url_safety_1.safeHttpUrl)(targetUrl);
83
+ if (external)
84
+ return { action: 'open-external', url: external };
85
+ return { action: 'deny' };
86
+ }
@@ -385,13 +385,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
385
385
  coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
386
386
  validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
387
387
  };
388
- // Domain axis (issue #806, Option 1): `fraim_connect` (forJob=false) loads only
389
- // the interaction-preference files + the L0 nudge, since it runs with no job.
390
- // A job (forJob=true) loads every category globally, plus the current job's
391
- // domain files. Category order within a tier is preserved from prior behavior;
392
- // domain files are appended after the global files.
393
- const l2Cats = forJob ? ['mistake', 'pref', 'coach', 'validated'] : ['pref'];
394
- const l1Cats = forJob ? ['pref', 'coach', 'mistake', 'validated'] : ['pref'];
388
+ // Domain axis (issue #806): startup and job contexts both include the global
389
+ // learning set. Job contexts may additionally append the current job's domain
390
+ // files below the global files.
391
+ const l2Cats = ['mistake', 'pref', 'coach', 'validated'];
392
+ const l1Cats = ['pref', 'coach', 'mistake', 'validated'];
395
393
  const activeDomain = forJob && domain ? domain : null;
396
394
  const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
397
395
  // Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
@@ -4,7 +4,7 @@ exports.EmailService = void 0;
4
4
  const resend_1 = require("resend");
5
5
  class EmailService {
6
6
  constructor() {
7
- const apiKey = process.env.RESEND_API_KEY;
7
+ const apiKey = process.env.RESEND_API_KEY || (process.env.FRAIM_SUPPRESS_EMAILS === 'true' ? 'test-resend-key' : undefined);
8
8
  if (!apiKey) {
9
9
  throw new Error('RESEND_API_KEY environment variable is required');
10
10
  }
@@ -13,16 +13,20 @@ class EmailService {
13
13
  this.fromEmail = process.env.RESEND_FROM_EMAIL || 'FRAIM <onboarding@resend.dev>';
14
14
  this.baseUrl = (process.env.BASE_URL || 'https://fraimworks.ai').replace(/\/$/, '');
15
15
  }
16
+ static shouldSuppressEmail() {
17
+ return process.env.FRAIM_SUPPRESS_EMAILS === 'true'
18
+ || (process.env.NODE_ENV === 'test' && process.env.ALLOW_REAL_EMAILS_IN_TEST !== 'true');
19
+ }
16
20
  /** Wrapper around resend.emails.send — no-ops in test mode to avoid burning real API quota. */
17
21
  async sendEmail(payload) {
18
- if (process.env.NODE_ENV === 'test') {
22
+ if (EmailService.shouldSuppressEmail()) {
19
23
  console.log(`[TEST] Email suppressed — would send to ${Array.isArray(payload.to) ? payload.to.join(', ') : payload.to}: "${payload.subject}"`);
20
24
  return { data: { id: 'test-suppressed' }, error: null, headers: null };
21
25
  }
22
26
  return this.sendWithResend(payload);
23
27
  }
24
28
  async sendWithResend(payload) {
25
- if (process.env.NODE_ENV === 'test' && process.env.ALLOW_REAL_EMAILS_IN_TEST !== 'true') {
29
+ if (EmailService.shouldSuppressEmail()) {
26
30
  console.log(`[TEST] Email suppressed - would send to ${Array.isArray(payload.to) ? payload.to.join(', ') : payload.to}: "${payload.subject}"`);
27
31
  return { data: { id: 'test-suppressed' }, error: null, headers: null };
28
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.210",
3
+ "version": "2.0.211",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -90,7 +90,7 @@
90
90
  "dotenv": "^16.4.7",
91
91
  "electron": "^41.2.2",
92
92
  "express": "^5.2.1",
93
- "fraim": "2.0.210",
93
+ "fraim": "2.0.211",
94
94
  "mongodb": "^7.0.0",
95
95
  "node-cron": "4.2.1",
96
96
  "node-edge-tts": "^1.2.10",
@@ -3472,7 +3472,6 @@ function convAwaitingReview(conv) {
3472
3472
  if (conv.jobId === '__freeform__') return false;
3473
3473
  const handoff = reviewHandoffForConversation(conv);
3474
3474
  if (handoff) return handoff.reviewRequired === true;
3475
- if (reviewHandoffIssueForConversation(conv)) return true;
3476
3475
  // Delegation orchestration runs (fully-delegate with an active ledger) only enter review
3477
3476
  // via an explicit review_handoff — Mandy's delegation artifacts are orchestration working
3478
3477
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
@@ -3716,47 +3715,6 @@ function delegationLedgerForConversation(conv) {
3716
3715
  return null;
3717
3716
  }
3718
3717
 
3719
- function hasReviewHandoffSignal(text) {
3720
- return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
3721
- }
3722
-
3723
- function reviewSubmissionClaimText(text) {
3724
- return String(stripMarkdownForDisplay(text) || '')
3725
- .replace(/\s+/g, ' ')
3726
- .trim();
3727
- }
3728
-
3729
- function hasExplicitReviewSubmissionClaim(text) {
3730
- const cleaned = reviewSubmissionClaimText(text);
3731
- if (!cleaned) return false;
3732
- 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)) {
3733
- return false;
3734
- }
3735
- return /\bready\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
3736
- || /\bplease\s+review\b/i.test(cleaned)
3737
- || /\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)
3738
- || /\b(?:review|open)\s+(?:the\s+)?(?:pr|pull request|artifact|document|doc|file|files|deliverable|bundle)\b/i.test(cleaned);
3739
- }
3740
-
3741
- function reviewHandoffIssueForConversation(conv) {
3742
- if (!conv) return '';
3743
- if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
3744
- return 'The review handoff contract is invalid.';
3745
- }
3746
- const messages = conv.messages || [];
3747
- const hasRunArtifacts = Array.isArray(conv.artifacts) && conv.artifacts.length > 0;
3748
- const latestEmployee = [...messages].reverse().find((message) => message.role === 'employee');
3749
- // #770 R3: only the latest employee turn can raise a malformed-contract issue;
3750
- // a stale malformed tag from an earlier, superseded turn must not re-latch it.
3751
- if (latestEmployee && hasReviewHandoffSignal(latestEmployee.text) && !extractReviewHandoffFromText(latestEmployee.text)) {
3752
- return 'The review handoff contract is malformed or missing required fields.';
3753
- }
3754
- if (!conv.reviewHandoff && !hasRunArtifacts && latestEmployee && hasExplicitReviewSubmissionClaim(latestEmployee.text)) {
3755
- return 'This run says it is ready for review, but did not include a structured review handoff.';
3756
- }
3757
- return '';
3758
- }
3759
-
3760
3718
  function reviewHandoffForConversation(conv) {
3761
3719
  if (!conv) return null;
3762
3720
  // Authoritative source: the live server run snapshot, refreshed every poll by
@@ -3891,20 +3849,11 @@ function pullRequestActionLabel(url) {
3891
3849
  return 'Open pull request';
3892
3850
  }
3893
3851
 
3894
- // Derive the deliverable format from the job/deliverable type (R7.2). Returns a
3895
- // format key + a label + the action descriptors to render in the artifact strip.
3852
+ // Derive the deliverable format from the job/deliverable type (R7.2). Returns
3853
+ // metadata used by the review card and deliverables panel.
3896
3854
  function deriveDeliverableFormat(conv) {
3897
3855
  const structured = deriveFormatFromReviewHandoff(reviewHandoffForConversation(conv));
3898
3856
  if (structured) return structured;
3899
- const handoffIssue = reviewHandoffIssueForConversation(conv);
3900
- if (handoffIssue) {
3901
- return {
3902
- key: 'contract_error',
3903
- label: 'invalid review handoff contract',
3904
- actions: [{ id: 'resend-review-contract', label: 'Ask for corrected contract', primary: true, kind: 'resend-contract' }],
3905
- issue: handoffIssue,
3906
- };
3907
- }
3908
3857
 
3909
3858
  const hay = ((conv && (conv.jobId || '')) + ' ' + (conv && (conv.jobTitle || ''))).toLowerCase();
3910
3859
  // Prefer an explicit artifact path captured from the run when present.
@@ -4113,23 +4062,8 @@ function renderReviewExperience(conv) {
4113
4062
 
4114
4063
  // --- Format-derived artifact strip (R7.2) ---
4115
4064
  // #770 A1: real deliverables (artifact_set / pull_request / deck / report /
4116
- // markdown) now render in the Deliverables panel (renderDeliverablesPanel), not
4117
- // pinned in the thread. Only the contract_error correction prompt stays inline
4118
- // with the review card, since it is a decision-blocking ask, not a deliverable.
4119
- if (fmt.key === 'contract_error' && fmt.actions && fmt.actions.length) {
4120
- const strip = document.createElement('div');
4121
- strip.className = 'rc-artifact-strip';
4122
- for (const action of fmt.actions) {
4123
- const btn = document.createElement('button');
4124
- btn.type = 'button';
4125
- btn.className = 'rc-art-btn' + (action.primary ? '' : ' ghost');
4126
- btn.textContent = action.label;
4127
- btn.dataset.artAction = action.id;
4128
- btn.addEventListener('click', () => handleArtifactAction(conv, action));
4129
- strip.appendChild(btn);
4130
- }
4131
- host.appendChild(strip);
4132
- }
4065
+ // markdown) render in the Deliverables panel (renderDeliverablesPanel), not
4066
+ // pinned in the thread.
4133
4067
 
4134
4068
  // --- Unified action bar: review actions appear beside the always-on chips ---
4135
4069
  actions.hidden = false;
@@ -4137,9 +4071,7 @@ function renderReviewExperience(conv) {
4137
4071
  const handoff = els['review-handoff'];
4138
4072
  if (handoff) {
4139
4073
  const hasArtifact = !!(fmt.actions && fmt.actions.length);
4140
- handoff.textContent = fmt.key === 'contract_error'
4141
- ? `${fmt.issue || 'The review handoff contract is invalid.'} Ask the employee to resend it before review.`
4142
- : fmt.key === 'pull_request'
4074
+ handoff.textContent = fmt.key === 'pull_request'
4143
4075
  ? 'Comment on the PR to leave inline notes, or approve / type changes below.'
4144
4076
  : isManagerTemplateJob(conv && conv.jobId)
4145
4077
  ? 'Open the updated files, then approve or type changes below.'
@@ -4239,6 +4171,15 @@ function approvalMenuLabel(action) {
4239
4171
  return label.replace(/^Approve\s*\+\s*/i, 'Approve and ');
4240
4172
  }
4241
4173
 
4174
+ function approvalCommandFromLabel(action) {
4175
+ const label = approvalMenuLabel(action)
4176
+ .replace(/^Approve\s*,\s*/i, '')
4177
+ .replace(/^Approve\s+and\s+/i, '')
4178
+ .replace(/^Approve\s+/i, '')
4179
+ .trim();
4180
+ return label ? `Approved and ${label}.` : APPROVAL_MESSAGE;
4181
+ }
4182
+
4242
4183
  // #770 A1: single-run deliverables live in a stable collapsible panel in the
4243
4184
  // support stack, decoupled from the transient approve/reject decision UI. Fed by
4244
4185
  // the same format derivation as the old in-thread strip, so a genuine structured
@@ -4583,6 +4524,7 @@ function refreshStatusSurfaces() {
4583
4524
  }
4584
4525
 
4585
4526
  const APPROVAL_MESSAGE = 'Approved.';
4527
+ const DEFAULT_REQUEST_CHANGES_MESSAGE = 'Request changes. Please revise the submitted work and send it back for review.';
4586
4528
 
4587
4529
  function buildApprovalDeliveryPreparationMessage(reviewAction) {
4588
4530
  if (!reviewAction || reviewAction.kind !== 'approve_delivery' || !reviewAction.deliveryActionId) {
@@ -4601,8 +4543,7 @@ function buildApprovalDeliveryPreparationMessage(reviewAction) {
4601
4543
  if (reviewAction.deliveryActionId === 'cleanup_branch') {
4602
4544
  return 'Approved and clean up branch.';
4603
4545
  }
4604
- const label = approvalMenuLabel(reviewAction).replace(/^Approve\s+and\s+/i, '');
4605
- return label ? `Approved and ${label}.` : APPROVAL_MESSAGE;
4546
+ return approvalCommandFromLabel(reviewAction);
4606
4547
  }
4607
4548
 
4608
4549
  // Approve closes the review gate. From the dedicated review approval button,
@@ -4646,20 +4587,18 @@ async function approveReview(options) {
4646
4587
 
4647
4588
  // Request changes sends the manager's note as a coaching turn that RESUMES the
4648
4589
  // run via the existing continueRun path. The note follows the artifact: a typed
4649
- // comment becomes a coaching message (R7.7). Empty note focus the input.
4590
+ // comment becomes a coaching message (R7.7). Empty note sends a plain revision
4591
+ // request so the visible Request changes button always advances the review flow.
4650
4592
  async function requestChangesReview() {
4651
4593
  const conv = activeConversation();
4652
4594
  if (!conv) return;
4653
- const text = (els['coach-text'] && els['coach-text'].value.trim()) || '';
4654
- if (!text) {
4655
- showStatus('Type what to change below, then Request changes resumes the run with your note.', false);
4656
- if (els['coach-text']) els['coach-text'].focus();
4657
- return;
4658
- }
4595
+ const typedText = (els['coach-text'] && els['coach-text'].value.trim()) || '';
4596
+ const text = typedText || DEFAULT_REQUEST_CHANGES_MESSAGE;
4659
4597
  // Re-open the review gate: a change request means the run is no longer "done".
4660
4598
  conv.reviewApproved = false;
4661
- els['coach-text'].value = '';
4599
+ if (els['coach-text']) els['coach-text'].value = '';
4662
4600
  syncSendButton();
4601
+ showStatus(typedText ? 'Change request sent.' : 'Default change request sent.', false);
4663
4602
  await continueRun(text);
4664
4603
  }
4665
4604