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.
- package/dist/src/ai-hub/conversation-store.js +14 -6
- package/dist/src/ai-hub/desktop-main.js +14 -69
- package/dist/src/ai-hub/server.js +10 -0
- package/dist/src/ai-hub/window-open-decision.js +86 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +5 -7
- package/dist/src/services/email-service.js +7 -3
- package/package.json +2 -2
- package/public/ai-hub/script.js +22 -83
|
@@ -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 !== '
|
|
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 (
|
|
305
|
-
|
|
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
|
|
285
|
-
if (
|
|
286
|
-
void mainWindow?.loadURL(
|
|
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 (
|
|
290
|
-
|
|
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
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
|
3895
|
-
//
|
|
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)
|
|
4117
|
-
// pinned in the thread.
|
|
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 === '
|
|
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
|
-
|
|
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
|
|
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
|
|
4654
|
-
|
|
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
|
|