diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md
index 63d3be44..1ed6d5b1 100644
--- a/CODEBASE_DOCUMENTATION.md
+++ b/CODEBASE_DOCUMENTATION.md
@@ -83,6 +83,7 @@ server/auditExportService.js - Redacted audit export across activity + sch
server/networkSecurityPolicy.js - Bind-host/auth safety policy helpers (loopback defaults + LAN auth guardrails)
server/processTelemetryBenchmarkService.js - Release benchmark metrics (onboarding/runtime/review), snapshot comparisons, release-note markdown generation
server/projectTypeService.js - Project taxonomy loader/validator for category→framework→template metadata (`config/project-types.json`)
+server/prReviewAutomationService.js - PR review automation pipeline (poll/webhook trigger, reviewer selection, worktree assignment, feedback routing, saved review snapshots)
```
### Multi-Workspace System (Core Feature)
@@ -287,6 +288,56 @@ user-settings.json - User preferences and workspace settings
user-settings.default.json - Default user settings template
```
+#### PR Review Automation Defaults
+
+`global.ui.tasks.automations.prReview` includes these keys in `user-settings.default.json`:
+
+- `enabled`: boolean
+- `pollEnabled`: boolean
+- `pollMs`: number
+- `webhookEnabled`: boolean
+- `reviewerAgent`: `claude` | `codex`
+- `reviewerMode`: `fresh` | `continue` | `resume`
+- `reviewerProvider`: provider string (Claude-only)
+- `reviewerClaudeModel`: optional Claude CLI `--model` value such as `opus`, `sonnet`, or a full model id
+- `reviewerSkipPermissions`: boolean (Claude-only)
+- `reviewerCodexModel`: optional Codex CLI `-m` model override (blank uses the local Codex default/config)
+- `reviewerCodexReasoning`: `low` | `medium` | `high` | `xhigh` (Codex-only; blank uses the local Codex default/config)
+- `reviewerCodexVerbosity`: `low` | `medium` | `high` (Codex-only; blank uses the local Codex default/config)
+- `reviewerCodexFlags`: array of Codex flags
+- `reviewerTier`: number
+- `autoSpawnReviewer`: boolean
+- `autoFeedbackToAuthor`: boolean
+- `autoSpawnFixer`: boolean
+- `notifyOnReviewerSpawn`: boolean
+- `notifyOnReviewCompleted`: boolean
+- `approvedDeliveryAction`: `notify` | `paste` | `paste_and_notify` | `none`
+- `commentedDeliveryAction`: `notify` | `paste` | `paste_and_notify` | `none`
+- `needsFixFeedbackAction`: `notify` | `paste` | `paste_and_notify` | `none`
+- `reviewerPostAction`: optional per-item record override (`feedback` or `auto_fix`)
+- `maxConcurrentReviewers`: number
+- `repos`: string array
+
+Saved task-record review fields used by Queue + feedback handoff include:
+- `reviewSourceSessionId` / `reviewSourceWorktreeId`: best-effort source agent to paste review updates back into
+- `reviewerSessionId` / `reviewerAgent`: which agent/worktree handled the background review
+- `latestReviewSummary` / `latestReviewBody`: locally cached review snapshot for Queue details + paste-back
+- `latestReviewOutcome` / `latestReviewUser` / `latestReviewUrl` / `latestReviewSubmittedAt`: latest GitHub review metadata
+- `latestReviewDeliveredAt`: when the saved review summary was pasted back into an agent session
+
+When a reviewer completes, the result is routed by outcome:
+- `approvedDeliveryAction` / `commentedDeliveryAction`: optional notify and/or paste-back to the source agent
+- `reviewerPostAction=feedback`: use `needsFixFeedbackAction` to notify and/or paste changes-requested feedback back to the source agent
+- `reviewerPostAction=auto_fix`: a fixer agent is spawned automatically and uses the stored review body as the fix request
+
+Queue detail actions now include saved-review affordances:
+- `Open latest review`: opens the saved review URL (or PR URL fallback)
+- `Paste to agent`: writes the saved review summary/body back into the source agent terminal via the existing terminal-input path
+- agent terminal headers also surface `⏳` while a review is running plus `📝` / `↩` buttons once a saved review snapshot exists for that session's linked PR
+
+Source-session linking is no longer Queue-only:
+- whenever any non-server terminal picks up an `existingPR` link through branch detection/session restore, the client now upserts `reviewSourceSessionId` / `reviewSourceWorktreeId` on the PR task record so background review completion can route back to that terminal automatically
+
### Workspace Templates & Scripts
```
templates/launch-settings/ - Workspace configuration templates
@@ -459,6 +510,10 @@ GET /api/project-types/categories - Project categories with resolved base paths
GET /api/project-types/frameworks?categoryId=... - Framework catalog (optionally scoped by category)
GET /api/project-types/templates?frameworkId=...&categoryId=... - Template catalog (optionally scoped)
POST /api/projects/create-workspace - Create project scaffold + matching workspace in one request
+GET /api/process/automations - Combined automation status (merge + review)
+GET /api/process/automations/pr-review/status - PR review automation status + timestamps
+POST /api/process/automations/pr-review/run - Trigger one PR review automation cycle
+PUT /api/process/automations/pr-review/config - Update PR review automation settings
GET /api/discord/status - Discord queue + services health/status (counts + signature status); endpoint can be gated by `DISCORD_API_TOKEN`
POST /api/discord/ensure-services - Ensure Services workspace/session bootstrap; accepts optional `dangerousModeOverride` (gated by `DISCORD_ALLOW_DANGEROUS_OVERRIDE`)
POST /api/discord/process-queue - Dispatch queue processing prompt with optional `Idempotency-Key`/`idempotencyKey`, queue signature verification, idempotent replay, audit logging, and per-endpoint rate limiting
diff --git a/client/app.js b/client/app.js
index 9a0cccdb..ecdd90e8 100644
--- a/client/app.js
+++ b/client/app.js
@@ -935,9 +935,24 @@ class ClaudeOrchestrator {
});
document.getElementById('workflow-review')?.addEventListener('click', () => {
this.setWorkflowMode('review');
- // Batch review defaults (Tier 3, unreviewed, console+diff ready).
- this.queuePanelPreset = { reviewTier: 3, unreviewedOnly: true, autoOpenDiff: true, autoConsole: true, autoAdvance: true, reviewActive: true };
- this.showQueuePanel();
+ const reviewContext = this.getCurrentReviewContext();
+ // Open a simple PR-first picker; power-user review lanes still live under Flows.
+ this.queuePanelPreset = {
+ mode: 'mine',
+ kindFilter: 'pr',
+ reviewTier: 'all',
+ tierSet: null,
+ unreviewedOnly: false,
+ blockedOnly: false,
+ autoOpenDiff: false,
+ autoConsole: false,
+ autoAdvance: false,
+ reviewActive: false,
+ prioritizeActive: true,
+ projectFilter: '',
+ repoScope: reviewContext.repoScope || ''
+ };
+ this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
});
document.getElementById('workflow-background')?.addEventListener('click', () => {
this.setWorkflowMode('background');
@@ -1151,6 +1166,10 @@ class ClaudeOrchestrator {
this.notificationManager.handleNotification(notification);
});
+ this.socket.on('pr-review-automation', (payload) => {
+ this.handlePrReviewAutomationEvent(payload);
+ });
+
this.socket.on('session-exited', ({ sessionId, exitCode }) => {
this.handleSessionExit(sessionId, exitCode);
});
@@ -1204,6 +1223,7 @@ class ClaudeOrchestrator {
const links = this.githubLinks.get(sessionId) || {};
links.pr = sessionState.existingPR;
this.githubLinks.set(sessionId, links);
+ this.maybeLinkPrTaskToSession(sessionId, sessionState.existingPR, { sessionOverride: this.sessions.get(sessionId) }).catch(() => {});
}
// Mark new sessions as active (active-only filter should treat background work as active too).
@@ -2088,6 +2108,58 @@ class ClaudeOrchestrator {
});
}
+ const prReviewEnabled = document.getElementById('pr-review-auto-enabled');
+ if (prReviewEnabled) {
+ prReviewEnabled.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.enabled', !!e.target.checked);
+ });
+ }
+ const prReviewReviewerAgent = document.getElementById('pr-review-reviewer-agent');
+ if (prReviewReviewerAgent) {
+ prReviewReviewerAgent.addEventListener('change', (e) => {
+ const next = String(e.target.value || '').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.reviewerAgent', next);
+ });
+ }
+ const prReviewReviewerMode = document.getElementById('pr-review-reviewer-mode');
+ if (prReviewReviewerMode) {
+ prReviewReviewerMode.addEventListener('change', (e) => {
+ const raw = String(e.target.value || '').trim().toLowerCase();
+ const next = raw === 'continue' ? 'continue' : 'fresh';
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.reviewerMode', next);
+ });
+ }
+ const prReviewNotifyStarted = document.getElementById('pr-review-notify-started');
+ if (prReviewNotifyStarted) {
+ prReviewNotifyStarted.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.notifyOnReviewerSpawn', !!e.target.checked);
+ });
+ }
+ const prReviewNotifyCompleted = document.getElementById('pr-review-notify-completed');
+ if (prReviewNotifyCompleted) {
+ prReviewNotifyCompleted.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.notifyOnReviewCompleted', !!e.target.checked);
+ });
+ }
+ const prReviewApprovedAction = document.getElementById('pr-review-approved-action');
+ if (prReviewApprovedAction) {
+ prReviewApprovedAction.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.approvedDeliveryAction', String(e.target.value || 'notify').trim());
+ });
+ }
+ const prReviewCommentedAction = document.getElementById('pr-review-commented-action');
+ if (prReviewCommentedAction) {
+ prReviewCommentedAction.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.commentedDeliveryAction', String(e.target.value || 'notify').trim());
+ });
+ }
+ const prReviewNeedsFixAction = document.getElementById('pr-review-needs-fix-action');
+ if (prReviewNeedsFixAction) {
+ prReviewNeedsFixAction.addEventListener('change', (e) => {
+ this.updateGlobalUserSetting('ui.tasks.automations.prReview.needsFixFeedbackAction', String(e.target.value || 'paste_and_notify').trim());
+ });
+ }
+
const identityClaimName = document.getElementById('identity-claim-name');
if (identityClaimName) {
identityClaimName.addEventListener('change', async (e) => {
@@ -3100,7 +3172,8 @@ class ClaudeOrchestrator {
async openReviewInbox({ quick = false, project = '' } = {}) {
this.setWorkflowMode('review');
const defaults = this.getReviewInboxDefaults(quick ? 'quickReview' : 'reviewInbox');
- const projectFilter = String(project || defaults.project || '').trim();
+ const reviewContext = this.getCurrentReviewContext({ project });
+ const projectFilter = String(reviewContext.projectFilter || defaults.project || '').trim();
this.queuePanelPreset = {
mode: defaults.mode,
reviewTier: defaults.reviewTier,
@@ -3115,10 +3188,11 @@ class ClaudeOrchestrator {
reviewRouteActive: false,
kindFilter: defaults.kind,
projectFilter: projectFilter || '',
+ repoScope: reviewContext.repoScope || '',
prioritizeActive: defaults.prioritizeActive,
quickReview: !!quick
};
- return this.showQueuePanel();
+ return this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
}
async openQuickReview({ project = '' } = {}) {
@@ -3522,6 +3596,283 @@ class ClaudeOrchestrator {
return `pr:${owner}/${repo}#${prNum}`;
}
+ extractGitHubRepoSlug(value) {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+
+ const directMatch = raw.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
+ if (directMatch) return `${directMatch[1]}/${directMatch[2]}`;
+
+ const normalized = raw.replace(/\.git(?=$|[/?#])/i, '');
+ const urlMatch = normalized.match(/github\.com[:/]([^/:\s]+)\/([^/#?\s]+?)(?:[/?#]|$)/i);
+ if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`;
+
+ return '';
+ }
+
+ getCurrentReviewContext({ project = '' } = {}) {
+ const explicitProject = String(project || '').trim();
+ const explicitRepo = this.extractGitHubRepoSlug(explicitProject);
+ const candidateSessionIds = [];
+ const repoSlugs = new Set();
+ const pushCandidate = (value) => {
+ const sid = String(value || '').trim();
+ if (!sid || candidateSessionIds.includes(sid)) return;
+ candidateSessionIds.push(sid);
+ };
+ const addRepoSlug = (value) => {
+ const slug = this.extractGitHubRepoSlug(value);
+ if (!slug) return '';
+ repoSlugs.add(slug);
+ return slug;
+ };
+ const normalizePath = (value) => String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
+ const workspaceTerminalIds = new Set(
+ (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : [])
+ .map((terminal) => String(terminal?.id || '').trim())
+ .filter(Boolean)
+ );
+ const workspaceRepoPaths = new Set(
+ (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : [])
+ .map((terminal) => normalizePath(terminal?.repository?.path))
+ .filter(Boolean)
+ );
+ const workspaceRepoPath = normalizePath(this.currentWorkspace?.repository?.path);
+ const addSessionRepoContext = (sessionId, session) => {
+ if (!sessionId || !session) return;
+ const links = this.githubLinks.get(sessionId) || {};
+ addRepoSlug(session?.repositorySlug);
+ addRepoSlug(links.pr);
+ addRepoSlug(links.commit);
+ addRepoSlug(session?.remoteUrl);
+ };
+ const deriveSessionRepoPath = (sessionId, session) => {
+ const directRoot = normalizePath(session?.repositoryRoot);
+ if (directRoot) return directRoot;
+ const cwd = normalizePath(this.resolveWorktreePathForSession(sessionId, session));
+ const worktreeId = String(session?.worktreeId || '').trim();
+ if (cwd && worktreeId && cwd.endsWith(`/${worktreeId}`)) {
+ return cwd.slice(0, -(`/${worktreeId}`).length);
+ }
+ return '';
+ };
+ const belongsToCurrentWorkspace = (sessionId, session) => {
+ const sid = String(sessionId || '').trim();
+ if (!sid || !session) return false;
+
+ const currentWorkspaceId = String(this.currentWorkspace?.id || '').trim();
+ const sessionWorkspaceId = String(session?.workspace || '').trim();
+ if (currentWorkspaceId && sessionWorkspaceId) return sessionWorkspaceId === currentWorkspaceId;
+
+ if (workspaceTerminalIds.size && workspaceTerminalIds.has(sid)) return true;
+
+ const sessionRepoPath = deriveSessionRepoPath(sid, session);
+ if (workspaceRepoPath && sessionRepoPath && sessionRepoPath === workspaceRepoPath) return true;
+ if (workspaceRepoPaths.size && sessionRepoPath && workspaceRepoPaths.has(sessionRepoPath)) return true;
+
+ return !currentWorkspaceId;
+ };
+
+ pushCandidate(this.focusedTerminalInfo?.sessionId);
+ pushCandidate(this.lastInteractedSessionId);
+
+ let prTaskId = '';
+ let repoSlug = addRepoSlug(explicitRepo);
+
+ for (const sessionId of candidateSessionIds) {
+ const session = this.sessions.get(sessionId);
+ if (!session) continue;
+ const links = this.githubLinks.get(sessionId) || {};
+
+ if (!prTaskId) prTaskId = this.getPRTaskIdFromUrl(links.pr) || '';
+ if (!repoSlug) repoSlug = addRepoSlug(session?.repositorySlug);
+ if (!repoSlug) {
+ repoSlug = addRepoSlug(links.pr)
+ || addRepoSlug(links.commit)
+ || addRepoSlug(session?.remoteUrl);
+ }
+ addSessionRepoContext(sessionId, session);
+ }
+
+ for (const [sid, session] of this.sessions) {
+ if (!belongsToCurrentWorkspace(sid, session)) continue;
+ addSessionRepoContext(sid, session);
+ }
+
+ addRepoSlug(this.currentWorkspace?.repository?.remote);
+ addRepoSlug(this.currentWorkspace?.remoteUrl);
+
+ if (!repoSlug) repoSlug = Array.from(repoSlugs)[0] || '';
+ if (!repoSlug && prTaskId) repoSlug = this.getRepositorySlugForPRTask({ id: prTaskId });
+ if (repoSlug) addRepoSlug(repoSlug);
+
+ return {
+ repoSlug: repoSlug || '',
+ repoScope: Array.from(repoSlugs).join(','),
+ projectFilter: explicitProject || '',
+ prTaskId: prTaskId || ''
+ };
+ }
+
+ getRepositorySlugForPRTask(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const direct = String(t.repository || '').trim();
+ if (direct) return direct;
+
+ const parse = (value) => {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+
+ const prTaskMatch = raw.match(/^pr:([^/]+\/[^#]+)#\d+$/i);
+ if (prTaskMatch) return prTaskMatch[1];
+
+ return this.extractGitHubRepoSlug(raw);
+ };
+
+ return parse(t.url) || parse(t.id) || '';
+ }
+
+ async maybeLinkPrTaskToSession(sessionId, prUrl, { sessionOverride = null } = {}) {
+ const sid = String(sessionId || '').trim();
+ const url = String(prUrl || '').trim();
+ if (!sid || !url) return null;
+ if (sid.endsWith('-server')) return null;
+
+ const prTaskId = this.getPRTaskIdFromUrl(url);
+ if (!prTaskId) return null;
+
+ const session = sessionOverride || this.sessions.get(sid) || null;
+ const worktreePath = this.resolveWorktreePathForSession(sid, session);
+ const worktreeId = String(session?.worktreeId || this.extractWorktreeLabel(worktreePath) || '').trim();
+
+ const existing = this.taskRecords.get(prTaskId) || {};
+ if (
+ String(existing?.reviewSourceSessionId || '').trim() === sid
+ && String(existing?.reviewSourceWorktreeId || '').trim() === worktreeId
+ ) {
+ return existing;
+ }
+
+ const patch = {
+ reviewSourceSessionId: sid,
+ reviewSourceWorktreeId: worktreeId || null
+ };
+
+ try {
+ const rec = await this.upsertTaskRecord(prTaskId, patch);
+ if (rec) {
+ const merged = { ...(existing && typeof existing === 'object' ? existing : {}), ...rec };
+ this.taskRecords.set(prTaskId, merged);
+ this.queuePanelApi?.handleAutomationEvent?.({ prId: prTaskId, recordPatch: rec });
+ this.updateTerminalControlsForPrTask(prTaskId);
+ return merged;
+ }
+ } catch (error) {
+ console.warn('Failed to link PR task to source session:', { sessionId: sid, prUrl: url, error: error?.message || error });
+ }
+
+ return null;
+ }
+
+ getPrReviewMetaForSession(sessionId) {
+ const sid = String(sessionId || '').trim();
+ if (!sid) return null;
+
+ const prUrl = String(this.githubLinks.get(sid)?.pr || '').trim();
+ const prTaskId = prUrl ? this.getPRTaskIdFromUrl(prUrl) : null;
+ if (!prTaskId) return null;
+
+ const record = this.taskRecords.get(prTaskId) || {};
+ const latestReviewSummary = String(record?.latestReviewSummary || '').trim();
+ const latestReviewBody = String(record?.latestReviewBody || '').trim();
+ const latestReviewOutcome = String(record?.latestReviewOutcome || '').trim().toLowerCase();
+ const reviewPending = !!record?.reviewerSpawnedAt && !record?.reviewedAt;
+
+ return {
+ prTaskId,
+ prUrl,
+ record,
+ latestReviewSummary,
+ latestReviewBody,
+ latestReviewOutcome,
+ hasLatestReview: !!(latestReviewSummary || latestReviewBody),
+ reviewPending
+ };
+ }
+
+ getLinkedSessionIdsForPrTask(prTaskId) {
+ const taskId = String(prTaskId || '').trim();
+ if (!taskId) return [];
+
+ const linked = new Set();
+ for (const [sessionId, links] of this.githubLinks.entries()) {
+ const prUrl = String(links?.pr || '').trim();
+ if (!prUrl) continue;
+ if (this.getPRTaskIdFromUrl(prUrl) === taskId) linked.add(sessionId);
+ }
+
+ const record = this.taskRecords.get(taskId) || {};
+ const sourceSessionId = String(record?.reviewSourceSessionId || '').trim();
+ if (sourceSessionId) linked.add(sourceSessionId);
+
+ return Array.from(linked);
+ }
+
+ updateTerminalControlsForPrTask(prTaskId) {
+ this.getLinkedSessionIdsForPrTask(prTaskId).forEach((sessionId) => {
+ this.updateTerminalControls(sessionId);
+ });
+ }
+
+ openLatestReviewForSession(sessionId) {
+ const meta = this.getPrReviewMetaForSession(sessionId);
+ if (!meta?.prTaskId) {
+ this.showToast?.('No linked PR review found for this terminal', 'warning');
+ return false;
+ }
+ return this.openLatestReviewForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record });
+ }
+
+ pasteLatestReviewToSessionFromHeader(sessionId) {
+ const sid = String(sessionId || '').trim();
+ const meta = this.getPrReviewMetaForSession(sid);
+ if (!sid || !meta?.prTaskId) {
+ this.showToast?.('No linked PR review found for this terminal', 'warning');
+ return false;
+ }
+
+ const payload = this.buildLatestReviewMessageForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record });
+ if (!payload) {
+ this.showToast?.('No saved review summary is available yet', 'warning');
+ return false;
+ }
+
+ this.sendTerminalInput(sid, `${payload}\n`);
+ this.showToast?.(`Pasted saved review into ${sid}`, 'success');
+ return true;
+ }
+
+ getPrReviewButtons(sessionId) {
+ const sid = String(sessionId || '').trim();
+ const meta = this.getPrReviewMetaForSession(sid);
+ if (!meta) return '';
+
+ const parts = [];
+ if (meta.reviewPending) {
+ parts.push(``);
+ }
+
+ if (meta.hasLatestReview) {
+ const outcome = meta.latestReviewOutcome || 'review';
+ const openTitle = `Open saved ${outcome} review`;
+ const pasteTitle = `Paste saved ${outcome} review back into this terminal`;
+ parts.push(``);
+ parts.push(``);
+ }
+
+ return parts.join('');
+ }
+
getTierForSession(sessionId) {
const session = this.sessions.get(sessionId);
const prUrl = this.githubLinks.get(sessionId)?.pr || null;
@@ -3613,6 +3964,7 @@ class ClaudeOrchestrator {
links.pr = state.existingPR;
this.githubLinks.set(sessionId, links);
console.log('Loaded existing PR for session:', sessionId, state.existingPR);
+ this.maybeLinkPrTaskToSession(sessionId, state.existingPR, { sessionOverride: sessionData }).catch(() => {});
}
// For mixed-repo workspaces, set terminals as active immediately so they show by default
@@ -6018,6 +6370,7 @@ class ClaudeOrchestrator {
this.githubLinks.set(sessionId, links);
console.log('Automatically detected existing PR:', existingPR);
this.maybeSchedulePrIntentRefresh(sessionId, existingPR);
+ this.maybeLinkPrTaskToSession(sessionId, existingPR, { sessionOverride: session }).catch(() => {});
}
}
@@ -6297,6 +6650,7 @@ class ClaudeOrchestrator {
}
}
+ buttons += this.getPrReviewButtons(sessionId);
return buttons;
}
@@ -6549,6 +6903,180 @@ class ClaudeOrchestrator {
}
}
+ resolvePreferredReviewSourceSession(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {};
+ const record = (t.record && typeof t.record === 'object')
+ ? { ...recordFromMap, ...t.record }
+ : recordFromMap;
+
+ const candidateIds = [
+ record.reviewSourceSessionId,
+ t.sessionId
+ ]
+ .map((value) => String(value || '').trim())
+ .filter(Boolean);
+
+ for (const sessionId of candidateIds) {
+ const session = this.sessions.get(sessionId);
+ if (session && session.status !== 'exited') return sessionId;
+ }
+
+ const candidateWorktreeId = String(
+ record.reviewSourceWorktreeId
+ || this.extractWorktreeLabel(String(t.worktreePath || '').trim())
+ || ''
+ ).trim().toLowerCase();
+
+ const candidateWorktreePath = String(
+ t.worktreePath
+ || (t.sessionId ? this.resolveWorktreePathForSession(t.sessionId) : '')
+ || ''
+ ).trim();
+
+ let fallbackSessionId = null;
+ for (const [sessionId, session] of this.sessions.entries()) {
+ if (!/-(claude|codex)$/.test(String(sessionId || ''))) continue;
+ if (session?.status === 'exited') continue;
+
+ const sessionWorktreePath = this.resolveWorktreePathForSession(sessionId, session);
+ if (candidateWorktreePath && sessionWorktreePath && sessionWorktreePath === candidateWorktreePath) {
+ return sessionId;
+ }
+
+ if (!fallbackSessionId && candidateWorktreeId) {
+ const inferred = String(session?.worktreeId || this.extractWorktreeLabel(sessionWorktreePath) || '').trim().toLowerCase();
+ if (inferred && inferred === candidateWorktreeId) {
+ fallbackSessionId = sessionId;
+ }
+ }
+ }
+
+ return fallbackSessionId;
+ }
+
+ buildLatestReviewMessageForTask(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {};
+ const record = (t.record && typeof t.record === 'object')
+ ? { ...recordFromMap, ...t.record }
+ : recordFromMap;
+
+ const summary = String(record.latestReviewSummary || '').trim();
+ const body = String(record.latestReviewBody || '').trim();
+ const outcome = String(record.latestReviewOutcome || '').trim().toLowerCase();
+ const outcomeLabel = outcome === 'approved'
+ ? 'APPROVED'
+ : outcome === 'commented'
+ ? 'COMMENTED'
+ : outcome === 'needs_fix'
+ ? 'CHANGES REQUESTED'
+ : 'REVIEW UPDATE';
+ const reviewUser = String(record.latestReviewUser || '').trim() || 'AI reviewer';
+ const reviewUrl = String(record.latestReviewUrl || t.url || '').trim();
+ const reviewAgent = String(record.latestReviewAgent || record.reviewerAgent || '').trim();
+ const prNumber = String(t.prNumber || '').trim() || (String(t.id || '').match(/#(\d+)$/)?.[1] || '?');
+ const text = summary || body;
+ if (!text) return '';
+
+ return [
+ '',
+ '--- Saved PR Review ---',
+ `PR #${prNumber} reviewed by ${reviewUser}.`,
+ `Outcome: ${outcomeLabel}`,
+ reviewAgent ? `Reviewer agent: ${reviewAgent}` : '',
+ reviewUrl ? `GitHub: ${reviewUrl}` : '',
+ '',
+ text,
+ '',
+ '--- End Saved PR Review ---',
+ ''
+ ].filter(Boolean).join('\n');
+ }
+
+ openLatestReviewForTask(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {};
+ const record = (t.record && typeof t.record === 'object')
+ ? { ...recordFromMap, ...t.record }
+ : recordFromMap;
+ const url = String(record.latestReviewUrl || t.url || '').trim();
+ if (!url) {
+ this.showToast?.('No saved review URL for this task', 'warning');
+ return false;
+ }
+
+ try {
+ window.open(url, '_blank', 'noopener');
+ return true;
+ } catch (error) {
+ this.showToast?.(String(error?.message || error), 'error');
+ return false;
+ }
+ }
+
+ pasteLatestReviewToTaskSession(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const sessionId = this.resolvePreferredReviewSourceSession(t);
+ if (!sessionId) {
+ this.showToast?.('No active agent session found for this review', 'warning');
+ return false;
+ }
+
+ const payload = this.buildLatestReviewMessageForTask(t);
+ if (!payload) {
+ this.showToast?.('No saved review summary is available yet', 'warning');
+ return false;
+ }
+
+ this.sendTerminalInput(sessionId, `${payload}\n`);
+ this.showToast?.(`Pasted saved review into ${sessionId}`, 'success');
+ return true;
+ }
+
+ handlePrReviewAutomationEvent(payload = {}) {
+ const event = String(payload?.event || '').trim().toLowerCase();
+ const prId = String(payload?.prId || '').trim();
+ const recordPatch = (payload?.recordPatch && typeof payload.recordPatch === 'object') ? payload.recordPatch : null;
+ if (prId && recordPatch) {
+ const existing = this.taskRecords.get(prId) || {};
+ this.taskRecords.set(prId, { ...(existing && typeof existing === 'object' ? existing : {}), ...recordPatch });
+ }
+
+ this.queuePanelApi?.handleAutomationEvent?.(payload);
+ if (prId) this.updateTerminalControlsForPrTask(prId);
+
+ const cfg = this.userSettings?.global?.ui?.tasks?.automations?.prReview || {};
+ const label = prId || 'PR review';
+
+ if (event === 'reviewer-spawned') {
+ if (cfg.notifyOnReviewerSpawn === false) return;
+ const agentLabel = String(payload?.agentId || '').trim() || 'agent';
+ const message = `Reviewer started for ${label} (${agentLabel})`;
+ this.showToast?.(message, 'info');
+ this.notificationManager?.handleNotification?.({ type: 'completed', message, sessionId: payload?.sessionId || null });
+ return;
+ }
+
+ if (event === 'review-completed') {
+ if (cfg.notifyOnReviewCompleted === false) return;
+ const outcome = String(payload?.outcome || '').trim().toLowerCase();
+ const summary = String(payload?.reviewSummary || '').trim();
+ const message = summary
+ ? `${label}: ${outcome || 'completed'} — ${summary}`
+ : `${label}: ${outcome || 'completed'}`;
+ const toastType = outcome === 'needs_fix' ? 'warning' : (outcome === 'approved' ? 'success' : 'info');
+ const notificationType = outcome === 'needs_fix' ? 'waiting' : 'completed';
+ this.showToast?.(message, toastType, { durationMs: outcome === 'needs_fix' ? 8000 : 5000 });
+ this.notificationManager?.handleNotification?.({
+ type: notificationType,
+ message,
+ sessionId: payload?.pastedToSessionId || null,
+ metadata: { prId, outcome, reviewUrl: payload?.reviewUrl || null }
+ });
+ }
+ }
+
resizeTerminal(sessionId, cols, rows) {
if (this.socket && this.socket.connected) {
this.socket.emit('terminal-resize', { sessionId, cols, rows });
@@ -7157,6 +7685,7 @@ class ClaudeOrchestrator {
.catch?.((err) => console.error('Failed to refresh conflicts:', err));
break;
+ case 'queue-review-pr':
case 'queue-spawn-reviewer':
this.showQueuePanel?.()
.then(() => setTimeout(() => this.queuePanelApi?.spawnReviewerSelected?.(), 50))
@@ -8738,18 +9267,19 @@ class ClaudeOrchestrator {
No available reviewers
`;
- } else {
+ } else {
availableReviewers.forEach(({ sessionId, session, worktreeNumber, status }) => {
const statusClass = status === 'waiting' ? 'ready' : status === 'busy' ? 'busy' : 'inactive';
+ const reviewerAgent = String(sessionId || '').includes('-codex') ? 'Codex' : 'Claude';
html += `
- 🤖 Claude ${worktreeNumber}
+ 🤖 ${reviewerAgent} ${worktreeNumber}
(${session.branch || 'unknown'})
`;
});
- }
+ }
return html;
}
@@ -8758,21 +9288,24 @@ class ClaudeOrchestrator {
const reviewers = [];
for (const [sessionId, session] of this.sessions) {
- // Only include Claude sessions that are not the requesting session
- if (sessionId.includes('-claude') && sessionId !== requestingSessionId) {
- const worktreeNumber = sessionId.replace('-claude', '').replace('work', '');
- const isActive = this.sessionActivity.get(sessionId) === 'active';
-
- // Prefer active sessions, but include inactive ones as backup
- if (isActive || session.status === 'waiting') {
- reviewers.push({
- sessionId,
- session,
- worktreeNumber,
- status: session.status,
- isActive
- });
- }
+ // Only include agent sessions that are not the requesting session
+ const isAgentSession = /-(claude|codex)$/.test(String(sessionId || ''));
+ if (!isAgentSession || String(sessionId || '') === String(requestingSessionId || '')) {
+ continue;
+ }
+
+ const worktreeNumber = String(sessionId || '').replace(/-(claude|codex)$/, '').replace('work', '');
+ const isActive = this.sessionActivity.get(sessionId) === 'active';
+
+ // Prefer active sessions, but include inactive ones as backup
+ if (isActive || session.status === 'waiting') {
+ reviewers.push({
+ sessionId,
+ session,
+ worktreeNumber,
+ status: session.status,
+ isActive
+ });
}
}
@@ -8797,14 +9330,16 @@ class ClaudeOrchestrator {
const codeInfo = await this.extractCodeForReview(requestingSessionId);
if (!codeInfo.hasContent) {
- this.showToast(`No code changes detected in Claude ${requestingSessionId.replace('work', '').replace('-claude', '')}`, 'warning');
+ const requestWorktree = String(requestingSessionId || '').replace(/-(claude|codex)$/, '').replace('work', '');
+ const requestAgent = String(requestingSessionId || '').includes('-codex') ? 'Codex' : 'Claude';
+ this.showToast(`No code changes detected in ${requestAgent} ${requestWorktree}`, 'warning');
return;
}
// Format review request
const reviewRequest = this.formatReviewRequest(codeInfo, requestingSessionId);
- // Send to reviewer Claude
+ // Send to reviewer session
this.sendTerminalInput(reviewerSessionId, reviewRequest);
// Mark both sessions as active
@@ -8812,9 +9347,11 @@ class ClaudeOrchestrator {
this.buildSidebar();
// Show success message
- const requestingWorktree = requestingSessionId.replace('work', '').replace('-claude', '');
- const reviewerWorktree = reviewerSessionId.replace('work', '').replace('-claude', '');
- this.showToast(`Code review assigned: Claude ${requestingWorktree} → Claude ${reviewerWorktree}`, 'success');
+ const requestingWorktree = String(requestingSessionId || '').replace(/-(claude|codex)$/, '').replace('work', '');
+ const reviewerWorktree = String(reviewerSessionId || '').replace(/-(claude|codex)$/, '').replace('work', '');
+ const requestingAgent = String(requestingSessionId || '').includes('-codex') ? 'Codex' : 'Claude';
+ const reviewerAgent = String(reviewerSessionId || '').includes('-codex') ? 'Codex' : 'Claude';
+ this.showToast(`Code review assigned: ${requestingAgent} ${requestingWorktree} → ${reviewerAgent} ${reviewerWorktree}`, 'success');
} catch (error) {
console.error('Error assigning code review:', error);
@@ -16712,6 +17249,43 @@ class ClaudeOrchestrator {
prMergeComment.checked = cfg.comment !== false;
}
+ const prReviewCfg = this.userSettings.global?.ui?.tasks?.automations?.prReview || {};
+ const prReviewEnabled = document.getElementById('pr-review-auto-enabled');
+ if (prReviewEnabled) {
+ prReviewEnabled.checked = prReviewCfg.enabled === true;
+ }
+ const prReviewReviewerAgent = document.getElementById('pr-review-reviewer-agent');
+ if (prReviewReviewerAgent) {
+ prReviewReviewerAgent.value = String(prReviewCfg.reviewerAgent || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
+ }
+ const prReviewReviewerMode = document.getElementById('pr-review-reviewer-mode');
+ if (prReviewReviewerMode) {
+ prReviewReviewerMode.value = String(prReviewCfg.reviewerMode || 'fresh').trim().toLowerCase() === 'continue' ? 'continue' : 'fresh';
+ }
+ const prReviewNotifyStarted = document.getElementById('pr-review-notify-started');
+ if (prReviewNotifyStarted) {
+ prReviewNotifyStarted.checked = prReviewCfg.notifyOnReviewerSpawn !== false;
+ }
+ const prReviewNotifyCompleted = document.getElementById('pr-review-notify-completed');
+ if (prReviewNotifyCompleted) {
+ prReviewNotifyCompleted.checked = prReviewCfg.notifyOnReviewCompleted !== false;
+ }
+ const prReviewApprovedAction = document.getElementById('pr-review-approved-action');
+ if (prReviewApprovedAction) {
+ const value = String(prReviewCfg.approvedDeliveryAction || 'notify').trim().toLowerCase();
+ prReviewApprovedAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'notify';
+ }
+ const prReviewCommentedAction = document.getElementById('pr-review-commented-action');
+ if (prReviewCommentedAction) {
+ const value = String(prReviewCfg.commentedDeliveryAction || 'notify').trim().toLowerCase();
+ prReviewCommentedAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'notify';
+ }
+ const prReviewNeedsFixAction = document.getElementById('pr-review-needs-fix-action');
+ if (prReviewNeedsFixAction) {
+ const value = String(prReviewCfg.needsFixFeedbackAction || 'paste_and_notify').trim().toLowerCase();
+ prReviewNeedsFixAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'paste_and_notify';
+ }
+
const identityClaimName = document.getElementById('identity-claim-name');
if (identityClaimName) {
const v = this.getIdentityClaimName();
@@ -24636,6 +25210,7 @@ class ClaudeOrchestrator {
mode: 'mine', // mine | all
kindFilter: 'all', // all | pr | worktree | session
projectFilter: '',
+ repoScope: '',
prioritizeActive: localStorage.getItem('queue-prioritize-active') === 'true',
query: '',
tasks: [],
@@ -24674,6 +25249,11 @@ class ClaudeOrchestrator {
reprompted: []
};
+ const resolveQueueAgentId = () => {
+ const raw = String(localStorage.getItem('tasks-launch-agent') || 'claude').trim().toLowerCase();
+ return raw === 'codex' ? 'codex' : 'claude';
+ };
+
const loadSnoozes = () => {
try {
const raw = localStorage.getItem('queue-snoozes');
@@ -24736,6 +25316,9 @@ class ClaudeOrchestrator {
if (preset.projectFilter !== undefined) {
state.projectFilter = String(preset.projectFilter || '').trim();
}
+ if (preset.repoScope !== undefined) {
+ state.repoScope = String(preset.repoScope || '').trim();
+ }
if (preset.prioritizeActive !== undefined) {
state.prioritizeActive = !!preset.prioritizeActive;
try { localStorage.setItem('queue-prioritize-active', state.prioritizeActive ? 'true' : 'false'); } catch {}
@@ -24785,14 +25368,14 @@ class ClaudeOrchestrator {
-
-
+
+
-
+
@@ -24813,7 +25396,7 @@ class ClaudeOrchestrator {
-
+
@@ -24908,6 +25491,12 @@ class ClaudeOrchestrator {
}
if (projectFilterEl) {
projectFilterEl.value = String(state.projectFilter || '');
+ const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean);
+ projectFilterEl.title = scopedRepos.length > 1
+ ? 'Filter within the current workspace repos'
+ : scopedRepos.length === 1
+ ? `Project filter within ${scopedRepos[0]}`
+ : 'Filter the current PR list by repo or project';
}
const showPairingModal = async () => {
@@ -25015,6 +25604,20 @@ class ClaudeOrchestrator {
const setMode = (mode) => {
state.mode = mode === 'all' ? 'all' : 'mine';
+ const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean);
+ const repoCount = scopedRepos.length;
+ mineBtn.textContent = 'My PRs';
+ mineBtn.title = repoCount > 1
+ ? 'Only my PRs in the current workspace repos'
+ : repoCount === 1
+ ? `Only my PRs in ${scopedRepos[0]}`
+ : 'Only PRs authored by me';
+ allBtn.textContent = repoCount > 1 ? 'Workspace PRs' : repoCount === 1 ? 'This Repo' : 'Any Author';
+ allBtn.title = repoCount > 1
+ ? 'PRs from any author in the current workspace repos'
+ : repoCount === 1
+ ? `PRs from any author in ${scopedRepos[0]}`
+ : 'PRs from any author';
mineBtn.classList.toggle('active', state.mode === 'mine');
allBtn.classList.toggle('active', state.mode === 'all');
};
@@ -25140,11 +25743,19 @@ class ClaudeOrchestrator {
syncReviewControlsUI();
});
- autoReviewerBtn?.addEventListener('click', () => {
+ autoReviewerBtn?.addEventListener('click', async () => {
state.autoReviewer = !state.autoReviewer;
localStorage.setItem('queue-auto-reviewer', state.autoReviewer ? 'true' : 'false');
syncReviewControlsUI();
if (state.selectedId) renderDetail(getTaskById(state.selectedId));
+ if (!state.autoReviewer) {
+ this.showToast?.('Auto Reviewer disabled', 'info');
+ return;
+ }
+ const result = await triggerAutoReviewerForQueue({ notify: true, preferSelected: true });
+ if (!result?.started && result?.reason === 'no_candidate') {
+ this.showToast?.('Auto Reviewer armed: it will start on the selected or next visible unreviewed PR', 'info');
+ }
});
autoFixerBtn?.addEventListener('click', () => {
@@ -25323,6 +25934,7 @@ class ClaudeOrchestrator {
const parts = raw.replace(/\\/g, '/').split('/').filter(Boolean);
return parts[parts.length - 1] || raw;
};
+ const extractRepoSlug = (value) => this.extractGitHubRepoSlug(value);
const updateProjectFilterOptions = () => {
if (!projectFilterEl) return;
@@ -25335,9 +25947,12 @@ class ClaudeOrchestrator {
if (!label) continue;
options.set(normalizeProjectKey(label), label);
}
- const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b)));
const current = normalizeProjectKey(state.projectFilter);
- projectFilterEl.innerHTML = `
` + sorted
+ if (current && state.projectFilter && !options.has(current)) {
+ options.set(current, state.projectFilter);
+ }
+ const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b)));
+ projectFilterEl.innerHTML = `
` + sorted
.map((label) => `
`)
.join('');
if (current && options.has(current)) {
@@ -25462,54 +26077,89 @@ class ClaudeOrchestrator {
const buildActiveIndex = () => {
const activeSessionIds = new Set();
+ const openSessionIds = new Set();
const activeWorktreeIds = new Set();
+ const openWorktreeIds = new Set();
const activeWorktreePaths = new Set();
+ const openWorktreePaths = new Set();
const activeRepoNames = new Set();
+ const openRepoNames = new Set();
const activeRepoSlugs = new Set();
+ const openRepoSlugs = new Set();
for (const [sid, session] of this.sessions) {
if (!this.isAgentSession(sid)) continue;
const status = String(session?.status || '').trim().toLowerCase();
if (status === 'exited') continue;
+ openSessionIds.add(String(sid));
+
+ const worktreeId = String(session?.worktreeId || '').trim();
+ if (worktreeId) openWorktreeIds.add(worktreeId);
+
+ const worktreePath = String(session?.config?.cwd || '').trim();
+ if (worktreePath) openWorktreePaths.add(worktreePath);
+
+ const repoName = String(session?.repositoryName || '').trim();
+ if (repoName) openRepoNames.add(normalizeProjectKey(repoName));
+
+ const repoRoot = String(session?.repositoryRoot || '').trim();
+ const repoRootName = extractRepoName(repoRoot);
+ if (repoRootName) openRepoNames.add(normalizeProjectKey(repoRootName));
+
+ const repoSlug = String(session?.repositorySlug || '').trim();
+ if (repoSlug) openRepoSlugs.add(normalizeProjectKey(repoSlug));
+
const hasActivity = this.sessionActivity.get(sid) === 'active' || status === 'busy' || status === 'waiting';
if (!hasActivity) continue;
activeSessionIds.add(String(sid));
- const worktreeId = String(session?.worktreeId || '').trim();
if (worktreeId) activeWorktreeIds.add(worktreeId);
- const worktreePath = String(session?.config?.cwd || '').trim();
if (worktreePath) activeWorktreePaths.add(worktreePath);
- const repoName = String(session?.repositoryName || '').trim();
if (repoName) activeRepoNames.add(normalizeProjectKey(repoName));
- const repoRoot = String(session?.repositoryRoot || '').trim();
- const repoRootName = extractRepoName(repoRoot);
if (repoRootName) activeRepoNames.add(normalizeProjectKey(repoRootName));
- const repoSlug = String(session?.repositorySlug || '').trim();
if (repoSlug) activeRepoSlugs.add(normalizeProjectKey(repoSlug));
}
- return { activeSessionIds, activeWorktreeIds, activeWorktreePaths, activeRepoNames, activeRepoSlugs };
+ return {
+ activeSessionIds,
+ openSessionIds,
+ activeWorktreeIds,
+ openWorktreeIds,
+ activeWorktreePaths,
+ openWorktreePaths,
+ activeRepoNames,
+ openRepoNames,
+ activeRepoSlugs,
+ openRepoSlugs
+ };
};
const getActiveScoreForTask = (t, index) => {
if (!index) return 0;
if (t?.kind === 'session') {
const sid = String(t?.sessionId || '').trim();
- return (sid && index.activeSessionIds.has(sid)) ? 3 : 0;
+ if (sid && index.activeSessionIds.has(sid)) return 5;
+ return (sid && index.openSessionIds.has(sid)) ? 4 : 0;
}
if (t?.kind === 'worktree') {
const path = String(t?.worktreePath || '').trim();
- if (path && index.activeWorktreePaths.has(path)) return 2;
+ if (path && index.activeWorktreePaths.has(path)) return 4;
+ if (path && index.openWorktreePaths.has(path)) return 3;
const worktreeId = String(t?.worktreeId || '').trim();
- if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 2;
+ if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 4;
+ if (worktreeId && index.openWorktreeIds.has(worktreeId)) return 3;
return 0;
}
if (t?.kind === 'pr') {
+ const linkedSessionIds = this.getLinkedSessionIdsForPrTask(t?.id);
+ if (linkedSessionIds.some((sid) => index.activeSessionIds.has(String(sid || '').trim()))) return 6;
+ if (linkedSessionIds.some((sid) => index.openSessionIds.has(String(sid || '').trim()))) return 5;
+
const rec = (t?.record && typeof t.record === 'object') ? t.record : {};
const worktreeIds = [
rec.reviewerWorktreeId,
@@ -25517,14 +26167,17 @@ class ClaudeOrchestrator {
rec.recheckWorktreeId,
rec.overnightWorktreeId
].map((v) => String(v || '').trim()).filter(Boolean);
- if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 3;
+ if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 4;
+ if (worktreeIds.some((id) => index.openWorktreeIds.has(id))) return 3;
const repoSlug = normalizeProjectKey(t?.repository || '');
- if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 2;
+ if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 3;
+ if (repoSlug && index.openRepoSlugs.has(repoSlug)) return 2;
const repoName = normalizeProjectKey(extractRepoName(t?.repository || ''));
const projectName = normalizeProjectKey(t?.project || '');
- if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 2;
+ if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 3;
+ if ((repoName && index.openRepoNames.has(repoName)) || (projectName && index.openRepoNames.has(projectName))) return 2;
}
return 0;
};
@@ -25893,6 +26546,8 @@ class ClaudeOrchestrator {
url.searchParams.set('mode', state.mode);
url.searchParams.set('state', 'open');
url.searchParams.set('include', 'dependencySummary');
+ const repoSlug = String(state.repoScope || '').trim() || extractRepoSlug(state.projectFilter);
+ if (repoSlug) url.searchParams.set('repo', repoSlug);
const res = await fetch(url.toString());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || 'Failed to load queue');
@@ -25905,6 +26560,9 @@ class ClaudeOrchestrator {
state.selectedId = ordered[0]?.id || null;
}
renderList();
+ if (state.autoReviewer) {
+ Promise.resolve().then(() => triggerAutoReviewerForQueue({ notify: false, preferSelected: true })).catch(() => {});
+ }
refreshConflicts().catch(() => {});
};
@@ -25934,6 +26592,24 @@ class ClaudeOrchestrator {
else this.taskRecords.delete(id);
};
+ const buildReviewSourcePatch = (task) => {
+ const t = task && typeof task === 'object' ? task : {};
+ const patch = {};
+ const sourceSessionId = this.resolvePreferredReviewSourceSession(t);
+ if (sourceSessionId) patch.reviewSourceSessionId = sourceSessionId;
+
+ const session = sourceSessionId ? this.sessions.get(sourceSessionId) : null;
+ const worktreeId = String(
+ session?.worktreeId
+ || t?.record?.reviewSourceWorktreeId
+ || this.extractWorktreeLabel(String(t?.worktreePath || this.resolveWorktreePathForSession(sourceSessionId, session) || '').trim())
+ || ''
+ ).trim();
+ if (worktreeId) patch.reviewSourceWorktreeId = worktreeId;
+
+ return patch;
+ };
+
const stopReviewTimer = async ({ endedAtIso, reason = 'manual', nudge = false } = {}) => {
const activeId = state.reviewTimer?.taskId;
if (!activeId) return;
@@ -26863,6 +27539,16 @@ class ClaudeOrchestrator {
const reviewTestsCommand = String(reviewChecklist?.tests?.command || '');
const reviewManualDone = !!reviewChecklist?.manual?.done;
const reviewManualSteps = String(reviewChecklist?.manual?.steps || '');
+ const reviewerPostAction = String(record.reviewerPostAction || 'feedback').trim().toLowerCase() || 'feedback';
+ const latestReviewSummary = String(record.latestReviewSummary || '').trim();
+ const latestReviewBody = String(record.latestReviewBody || '').trim();
+ const latestReviewOutcome = String(record.latestReviewOutcome || '').trim().toLowerCase();
+ const latestReviewUser = String(record.latestReviewUser || '').trim();
+ const latestReviewUrl = String(record.latestReviewUrl || '').trim();
+ const latestReviewSubmittedAt = String(record.latestReviewSubmittedAt || '').trim();
+ const latestReviewAgent = String(record.latestReviewAgent || record.reviewerAgent || '').trim();
+ const latestReviewDeliveredAt = String(record.latestReviewDeliveredAt || '').trim();
+ const latestReviewText = latestReviewSummary || latestReviewBody;
const normalizePrUrl = (value) => {
const raw = String(value || '').trim();
@@ -27047,19 +27733,41 @@ class ClaudeOrchestrator {
-
-
Outcome
-
-
-
-
+
+
Outcome
+
+
+
+
+
+
Needs-fix action
+
+
+
+
+
+
+
+
+
Latest Review
+
+ ${latestReviewOutcome ? `${escapeHtml(latestReviewOutcome)}${latestReviewUser ? ` • ${escapeHtml(latestReviewUser)}` : ''}${latestReviewSubmittedAt ? ` • ${escapeHtml(latestReviewSubmittedAt)}` : ''}${latestReviewAgent ? ` • ${escapeHtml(latestReviewAgent)}` : ''}` : 'No saved review yet.'}
+ ${latestReviewDeliveredAt ? `
delivered: ${escapeHtml(latestReviewDeliveredAt)}
` : ''}
+
+
+
+
+
${escapeHtml(latestReviewText || 'No saved review summary yet.')}
@@ -27259,17 +27967,20 @@ class ClaudeOrchestrator {
const openInspectorBtn = detailEl.querySelector('#queue-open-inspector');
const openConsoleBtn = detailEl.querySelector('#queue-open-console');
const spawnOvernightBtn = detailEl.querySelector('#queue-spawn-overnight');
- const spawnReviewerBtn = detailEl.querySelector('#queue-spawn-reviewer');
+ const spawnReviewerBtn = detailEl.querySelector('#queue-review-pr') || detailEl.querySelector('#queue-spawn-reviewer');
const spawnFixerBtn = detailEl.querySelector('#queue-spawn-fixer');
const repromptBtn = detailEl.querySelector('#queue-reprompt');
- const spawnRecheckBtn = detailEl.querySelector('#queue-spawn-recheck');
- const timerStartBtn = detailEl.querySelector('#queue-review-timer-start');
+ const spawnRecheckBtn = detailEl.querySelector('#queue-spawn-recheck');
+ const openLatestReviewBtn = detailEl.querySelector('#queue-open-latest-review');
+ const pasteLatestReviewBtn = detailEl.querySelector('#queue-paste-latest-review');
+ const timerStartBtn = detailEl.querySelector('#queue-review-timer-start');
const timerStopBtn = detailEl.querySelector('#queue-review-timer-stop');
- const notesEl = detailEl.querySelector('#queue-notes');
+ const notesEl = detailEl.querySelector('#queue-notes');
const reviewTestsDoneEl = detailEl.querySelector('#queue-review-tests-done');
const reviewTestsCommandEl = detailEl.querySelector('#queue-review-tests-command');
const reviewManualDoneEl = detailEl.querySelector('#queue-review-manual-done');
const reviewManualStepsEl = detailEl.querySelector('#queue-review-manual-steps');
+ const reviewPostActionEl = detailEl.querySelector('#queue-review-post-action');
const snoozeAutoBtn = detailEl.querySelector('#queue-snooze-auto');
const snooze15Btn = detailEl.querySelector('#queue-snooze-15m');
const snooze1hBtn = detailEl.querySelector('#queue-snooze-1h');
@@ -28235,6 +28946,30 @@ class ClaudeOrchestrator {
}
});
+ openLatestReviewBtn?.addEventListener('click', () => {
+ this.openLatestReviewForTask(getTaskById(t.id) || t);
+ });
+
+ pasteLatestReviewBtn?.addEventListener('click', () => {
+ this.pasteLatestReviewToTaskSession(getTaskById(t.id) || t);
+ });
+
+ reviewPostActionEl?.addEventListener('change', async () => {
+ try {
+ const value = String(reviewPostActionEl.value || '').trim().toLowerCase();
+ reviewPostActionEl.disabled = true;
+ const patch = { reviewerPostAction: value || 'feedback' };
+ const rec = await upsertRecord(t.id, patch);
+ updateTaskRecordInState(t.id, rec);
+ await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
+ renderDetail(getTaskById(t.id));
+ } catch (e) {
+ this.showToast(String(e?.message || e), 'error');
+ } finally {
+ if (reviewPostActionEl) reviewPostActionEl.disabled = false;
+ }
+ });
+
const runGitHubReview = async ({ action, body } = {}) => {
const res = await fetch('/api/prs/review', {
method: 'POST',
@@ -28302,10 +29037,11 @@ class ClaudeOrchestrator {
nudged = true;
}
- const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null };
- const rec = await upsertRecord(t.id, patch);
- updateTaskRecordInState(t.id, rec);
- await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: body }).catch(() => {});
+ const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null };
+ const rec = await upsertRecord(t.id, patch);
+ updateTaskRecordInState(t.id, rec);
+ await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
+ await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: body }).catch(() => {});
await fetchTasks().catch(() => {});
renderDetail(getTaskById(t.id));
@@ -28348,7 +29084,7 @@ class ClaudeOrchestrator {
spawnOvernightBtn?.addEventListener('click', async () => {
try {
spawnOvernightBtn.disabled = true;
- const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
const rec = await upsertRecord(t.id, {
overnightSpawnedAt: new Date().toISOString(),
@@ -28368,9 +29104,9 @@ class ClaudeOrchestrator {
spawnReviewerBtn?.addEventListener('click', async () => {
try {
spawnReviewerBtn.disabled = true;
- const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
- const patch = { reviewerSpawnedAt: new Date().toISOString() };
+ const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(t) };
if (info.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
const rec = await upsertRecord(t.id, patch);
updateTaskRecordInState(t.id, rec);
@@ -28387,7 +29123,7 @@ class ClaudeOrchestrator {
spawnFixerBtn?.addEventListener('click', async () => {
try {
spawnFixerBtn.disabled = true;
- const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes: String(notesEl?.value || '') });
+ const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes: String(notesEl?.value || '') });
if (info) {
const rec = await upsertRecord(t.id, {
fixerSpawnedAt: new Date().toISOString(),
@@ -28413,7 +29149,7 @@ class ClaudeOrchestrator {
const existingFixerId = String(t?.record?.fixerWorktreeId || '').trim();
const info = await this.spawnFixAgentForPRTask(t, {
tier: 2,
- agentId: 'claude',
+ agentId: resolveQueueAgentId(),
mode: 'fresh',
yolo: true,
notes,
@@ -28441,7 +29177,7 @@ class ClaudeOrchestrator {
spawnRecheckBtn?.addEventListener('click', async () => {
try {
spawnRecheckBtn.disabled = true;
- const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
const rec = await upsertRecord(t.id, {
recheckSpawnedAt: new Date().toISOString(),
@@ -28517,6 +29253,7 @@ class ClaudeOrchestrator {
updateTaskRecordInState(t.id, rec);
if (value === 'needs_fix') {
await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: value, notes: String(notesEl?.value || '') });
+ await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
}
await fetchTasks();
renderDetail(getTaskById(t.id));
@@ -28570,76 +29307,101 @@ class ClaudeOrchestrator {
}
};
- const maybeAutoSpawnReviewer = async (t) => {
- if (!state.autoReviewer) return;
- const task = t || {};
- if (task.kind !== 'pr') return;
+ const getAutoReviewerEligibility = (task) => {
+ const t = task || {};
+ if (!state.autoReviewer) return { ok: false, reason: 'disabled' };
+ if (t.kind !== 'pr') return { ok: false, reason: 'not_pr' };
+ if (t?.record?.reviewedAt) return { ok: false, reason: 'reviewed' };
+ if (t?.record?.reviewerSpawnedAt) return { ok: false, reason: 'already_started' };
+ if (state.reviewerSpawning?.has?.(t.id)) return { ok: false, reason: 'starting' };
+ return { ok: true, reason: '' };
+ };
+
+ const getAutoReviewerCandidate = ({ preferSelected = true } = {}) => {
+ if (preferSelected && state.selectedId) {
+ const selected = getTaskById(state.selectedId);
+ if (getAutoReviewerEligibility(selected).ok) return selected;
+ }
+ const ordered = getOrderedTasks(getFilteredTasks());
+ return ordered.find((task) => getAutoReviewerEligibility(task).ok) || null;
+ };
+
+ const maybeAutoSpawnReviewer = async (task, { silent = true } = {}) => {
+ const eligibility = getAutoReviewerEligibility(task);
+ if (!eligibility.ok) return false;
+ const nextTask = task || {};
+
+ state.reviewerSpawning.add(nextTask.id);
+
+ try {
+ const info = await this.spawnReviewAgentForPRTask(nextTask, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
+ if (!info) return false;
+ const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(nextTask) };
+ if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
+ const rec = await upsertRecord(nextTask.id, patch);
+ updateTaskRecordInState(nextTask.id, rec);
+ renderList();
+ renderDetail(getTaskById(nextTask.id));
+ return true;
+ } catch (e) {
+ if (!silent) this.showToast?.(String(e?.message || e), 'error');
+ else console.warn('Auto reviewer spawn failed:', e);
+ return false;
+ } finally {
+ state.reviewerSpawning.delete(nextTask.id);
+ }
+ };
+
+ const triggerAutoReviewerForQueue = async ({ notify = false, preferSelected = true } = {}) => {
+ if (!state.autoReviewer) return { started: false, reason: 'disabled' };
+ const candidate = getAutoReviewerCandidate({ preferSelected });
+ if (!candidate) return { started: false, reason: 'no_candidate' };
+ const started = await maybeAutoSpawnReviewer(candidate, { silent: !notify });
+ return { started, taskId: candidate?.id || '', reason: started ? '' : 'failed' };
+ };
+
+ const parseIsoMaybe = (v) => {
+ const ms = Date.parse(String(v || ''));
+ return Number.isFinite(ms) ? ms : 0;
+ };
+
+ const maybeAutoSpawnFixer = async (t) => {
+ const task = t || {};
+ if (task.kind !== 'pr') return;
+ const rec = (task?.record && typeof task.record === 'object') ? task.record : {};
+ const taskAction = String(rec?.reviewerPostAction || '').trim().toLowerCase();
+ if (!state.autoFixer && taskAction !== 'auto_fix') return;
+ const shouldAutoFix = taskAction ? taskAction === 'auto_fix' : !!state.autoFixer;
+ if (!shouldAutoFix) return;
const tier = Number(task?.record?.tier);
if (tier !== 3) return;
- if (task?.record?.reviewedAt) return;
- if (task?.record?.reviewerSpawnedAt) return;
+ if (rec?.fixerSpawnedAt) return;
+ const outcome = String(rec?.reviewOutcome || '').trim().toLowerCase();
+ if (outcome !== 'needs_fix') return;
+ const notes = String(rec?.notes || '').trim();
+ if (!notes) return;
- if (state.reviewerSpawning?.has?.(task.id)) return;
- state.reviewerSpawning.add(task.id);
+ if (state.fixerSpawning?.has?.(task.id)) return;
+ state.fixerSpawning.add(task.id);
try {
- const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnFixAgentForPRTask(task, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes });
if (!info) return;
- const patch = { reviewerSpawnedAt: new Date().toISOString() };
- if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
- const rec = await upsertRecord(task.id, patch);
- updateTaskRecordInState(task.id, rec);
+ const patch = { fixerSpawnedAt: new Date().toISOString() };
+ if (info?.worktreeId) patch.fixerWorktreeId = info.worktreeId;
+ const next = await upsertRecord(task.id, patch);
+ updateTaskRecordInState(task.id, next);
renderList();
renderDetail(getTaskById(task.id));
} catch (e) {
- // best-effort; keep it silent unless it was user-initiated
- console.warn('Auto reviewer spawn failed:', e);
+ console.warn('Auto fixer spawn failed:', e);
} finally {
- state.reviewerSpawning.delete(task.id);
+ state.fixerSpawning.delete(task.id);
}
};
- const parseIsoMaybe = (v) => {
- const ms = Date.parse(String(v || ''));
- return Number.isFinite(ms) ? ms : 0;
- };
-
- const maybeAutoSpawnFixer = async (t) => {
- if (!state.autoFixer) return;
- const task = t || {};
- if (task.kind !== 'pr') return;
-
- const tier = Number(task?.record?.tier);
- if (tier !== 3) return;
-
- const rec = (task?.record && typeof task.record === 'object') ? task.record : {};
- if (rec?.fixerSpawnedAt) return;
- const outcome = String(rec?.reviewOutcome || '').trim().toLowerCase();
- if (outcome !== 'needs_fix') return;
- const notes = String(rec?.notes || '').trim();
- if (!notes) return;
-
- if (state.fixerSpawning?.has?.(task.id)) return;
- state.fixerSpawning.add(task.id);
-
- try {
- const info = await this.spawnFixAgentForPRTask(task, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes });
- if (!info) return;
- const patch = { fixerSpawnedAt: new Date().toISOString() };
- if (info?.worktreeId) patch.fixerWorktreeId = info.worktreeId;
- const next = await upsertRecord(task.id, patch);
- updateTaskRecordInState(task.id, next);
- renderList();
- renderDetail(getTaskById(task.id));
- } catch (e) {
- console.warn('Auto fixer spawn failed:', e);
- } finally {
- state.fixerSpawning.delete(task.id);
- }
- };
-
const maybeAutoSpawnRecheck = async (t) => {
if (!state.autoRecheck) return;
const task = t || {};
@@ -28662,7 +29424,7 @@ class ClaudeOrchestrator {
state.recheckSpawning.add(task.id);
try {
- const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (!info) return;
const patch = { recheckSpawnedAt: new Date().toISOString() };
if (info?.worktreeId) patch.recheckWorktreeId = info.worktreeId;
@@ -28721,9 +29483,14 @@ class ClaudeOrchestrator {
if (state.selectedId) renderDetail(getTaskById(state.selectedId));
});
- projectFilterEl?.addEventListener('change', () => {
+ projectFilterEl?.addEventListener('change', async () => {
state.projectFilter = String(projectFilterEl.value || '');
- applyFiltersAndMaybeClampSelection();
+ try {
+ await fetchTasks();
+ if (state.selectedId) renderDetail(getTaskById(state.selectedId));
+ } catch (e) {
+ this.showToast(String(e?.message || e), 'error');
+ }
});
refreshBtn.addEventListener('click', async () => {
@@ -29137,9 +29904,9 @@ class ClaudeOrchestrator {
return false;
}
try {
- const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
- const patch = { reviewerSpawnedAt: new Date().toISOString() };
+ const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(t) };
if (info.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
const rec = await upsertRecord(t.id, patch);
updateTaskRecordInState(t.id, rec);
@@ -29167,7 +29934,7 @@ class ClaudeOrchestrator {
return false;
}
try {
- const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes: String(getTaskById(t.id)?.record?.notes || '') });
+ const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes: String(getTaskById(t.id)?.record?.notes || '') });
if (info) {
const rec = await upsertRecord(t.id, {
fixerSpawnedAt: new Date().toISOString(),
@@ -29198,7 +29965,7 @@ class ClaudeOrchestrator {
return false;
}
try {
- const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
const rec = await upsertRecord(t.id, {
recheckSpawnedAt: new Date().toISOString(),
@@ -29229,7 +29996,7 @@ class ClaudeOrchestrator {
return false;
}
try {
- const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: 'claude', mode: 'fresh', yolo: true });
+ const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
if (info) {
const rec = await upsertRecord(t.id, {
overnightSpawnedAt: new Date().toISOString(),
@@ -29592,8 +30359,10 @@ class ClaudeOrchestrator {
const rec = await upsertRecord(t.id, patch);
updateTaskRecordInState(t.id, rec);
+ const nextTask = { ...(t || {}), record: rec };
if (value === 'needs_fix') {
await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: value, notes: String(getTaskById(t.id)?.record?.notes || '') });
+ await maybeAutoSpawnFixer(nextTask);
}
await fetchTasks().catch(() => {});
@@ -29821,6 +30590,7 @@ class ClaudeOrchestrator {
const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null };
const rec = await upsertRecord(t.id, patch);
updateTaskRecordInState(t.id, rec);
+ await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: reviewBody }).catch(() => {});
await fetchTasks().catch(() => {});
@@ -29874,11 +30644,22 @@ class ClaudeOrchestrator {
await fetchTasks().catch(() => {});
renderDetail(getTaskById(t.id));
return true;
- } catch (err) {
- this.showToast?.(String(err?.message || err), 'error');
- return false;
- }
- },
+ } catch (err) {
+ this.showToast?.(String(err?.message || err), 'error');
+ return false;
+ }
+ },
+ handleAutomationEvent: (payload = {}) => {
+ const prId = String(payload?.prId || '').trim();
+ const recordPatch = (payload?.recordPatch && typeof payload.recordPatch === 'object') ? payload.recordPatch : null;
+ if (!prId || !recordPatch) return false;
+ const existing = getTaskById(prId);
+ if (!existing) return false;
+ updateTaskRecordInState(prId, recordPatch);
+ renderList();
+ if (state.selectedId === prId) renderDetail(getTaskById(prId));
+ return true;
+ },
next: () => navigate(1),
prev: () => navigate(-1)
};
@@ -32402,12 +33183,12 @@ class ClaudeOrchestrator {
return { agentId: 'claude', mode: m, flags };
}
- async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) {
+ async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) {
const t = prTask || {};
if (t.kind !== 'pr') return;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return;
@@ -32515,7 +33296,7 @@ class ClaudeOrchestrator {
if (t.kind !== 'pr') return null;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return null;
@@ -32629,7 +33410,7 @@ class ClaudeOrchestrator {
if (t.kind !== 'pr') return null;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return null;
diff --git a/client/index.html b/client/index.html
index e3c58b56..fa483e8d 100644
--- a/client/index.html
+++ b/client/index.html
@@ -821,6 +821,81 @@
PR Merge Automation
+
+
PR Review Automation
+
+
+
Allows webhook/poll review tracking and background reviewer launches when configured.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Per-task “Needs-fix action” in Queue still overrides this when set to auto-fix.
+
+
+
Tasks Launch
diff --git a/client/styles.css b/client/styles.css
index 62b1bf22..210b7e81 100644
--- a/client/styles.css
+++ b/client/styles.css
@@ -6037,6 +6037,25 @@ body.dependency-onboarding-active #dependency-setup-modal {
border-color: var(--accent-primary-hover);
}
+.control-btn.pr-review-status-btn.pending {
+ background: color-mix(in srgb, var(--accent-warning) 18%, var(--bg-tertiary));
+ border-color: color-mix(in srgb, var(--accent-warning) 50%, var(--border-primary));
+ color: var(--accent-warning);
+}
+
+.control-btn.pr-review-status-btn.ready,
+.control-btn.pr-review-paste-btn {
+ background: color-mix(in srgb, var(--accent-primary) 14%, var(--bg-tertiary));
+ border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-primary));
+ color: var(--accent-primary);
+}
+
+.control-btn.pr-review-status-btn.ready:hover,
+.control-btn.pr-review-paste-btn:hover {
+ background: color-mix(in srgb, var(--accent-primary) 22%, var(--bg-tertiary));
+ border-color: var(--accent-primary);
+}
+
.terminal-controls {
position: relative;
}
diff --git a/server/commandRegistry.js b/server/commandRegistry.js
index 0d36c62c..7ed5ba60 100644
--- a/server/commandRegistry.js
+++ b/server/commandRegistry.js
@@ -725,6 +725,17 @@ class CommandRegistry {
}
});
+ this.register('queue-review-pr', {
+ category: 'process',
+ description: 'Start a PR review agent for the selected Queue PR (Tier 3 reviewer)',
+ params: [],
+ examples: [],
+ handler: (params, { io }) => {
+ io.emit('commander-action', { action: 'queue-review-pr' });
+ return { message: 'Queue: review PR' };
+ }
+ });
+
this.register('queue-spawn-reviewer', {
category: 'process',
description: 'Spawn a reviewer agent for the selected Queue PR (Tier 3 reviewer)',
diff --git a/server/index.js b/server/index.js
index b444dd5c..8d8ddea7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -3645,7 +3645,8 @@ app.post('/api/webhooks/github', async (req, res) => {
reviewState: review.state || '',
reviewBody: review.body || '',
reviewUser: review.user?.login || '',
- url: pr.html_url || pr.url || ''
+ url: pr.html_url || pr.url || '',
+ reviewUrl: review.html_url || review.url || pr.html_url || pr.url || ''
});
return res.json({ ok: true, event, verified: sig.verified, action, result });
}
diff --git a/server/prReviewAutomationService.js b/server/prReviewAutomationService.js
index 82b4e2e4..faeb7101 100644
--- a/server/prReviewAutomationService.js
+++ b/server/prReviewAutomationService.js
@@ -2,6 +2,7 @@
const path = require('path');
const winston = require('winston');
+const { collectDiagnostics } = require('./diagnosticsService');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
@@ -12,6 +13,61 @@ const logger = winston.createLogger({
});
const CONFIG_PATH = 'global.ui.tasks.automations.prReview';
+const AGENT_CLI_CACHE_TTL_MS = 30_000;
+
+const normalizeOptionalCliValue = (value) => {
+ const raw = String(value || '').trim();
+ if (!raw) return undefined;
+ const normalized = raw.toLowerCase();
+ if (normalized === 'default' || normalized === 'latest') return undefined;
+ return raw;
+};
+
+const normalizeReviewerMode = (value) => {
+ const raw = String(value || '').trim().toLowerCase();
+ if (raw === 'resume') {
+ // Automated reviewer/fixer spawns do not have a resume id, so picker-style
+ // resume would block the workflow instead of launching directly.
+ return 'continue';
+ }
+ return raw === 'continue' || raw === 'fresh' ? raw : 'fresh';
+};
+
+const normalizeReviewerPostAction = (v) => {
+ const s = String(v || '').trim().toLowerCase();
+ return s === 'auto_fix' || s === 'auto-fix' ? 'auto_fix' : 'feedback';
+};
+
+const normalizeDeliveryAction = (value, fallback = 'notify') => {
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) return fallback;
+ if (raw === 'paste_and_notify' || raw === 'paste-and-notify') return 'paste_and_notify';
+ if (raw === 'paste' || raw === 'notify' || raw === 'none') return raw;
+ return fallback;
+};
+
+const normalizeAgentId = (value) => {
+ const raw = String(value || '').trim().toLowerCase();
+ return raw === 'codex' ? 'codex' : 'claude';
+};
+
+const summarizeReviewBody = (value) => {
+ const lines = String(value || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ if (!lines.length) return '';
+
+ const picked = [];
+ for (const line of lines) {
+ if (picked.join('\n').length >= 1200) break;
+ picked.push(line);
+ if (picked.length >= 8) break;
+ }
+
+ return picked.join('\n').slice(0, 1200);
+};
const DEFAULT_CONFIG = {
enabled: false,
@@ -19,10 +75,23 @@ const DEFAULT_CONFIG = {
pollMs: 60_000,
webhookEnabled: true,
reviewerAgent: 'claude',
+ reviewerMode: 'fresh',
+ reviewerProvider: 'anthropic',
+ reviewerClaudeModel: '',
+ reviewerSkipPermissions: true,
+ reviewerCodexModel: '',
+ reviewerCodexReasoning: '',
+ reviewerCodexVerbosity: '',
+ reviewerCodexFlags: ['yolo'],
reviewerTier: 3,
autoSpawnReviewer: true,
autoFeedbackToAuthor: true,
autoSpawnFixer: false,
+ notifyOnReviewerSpawn: true,
+ notifyOnReviewCompleted: true,
+ approvedDeliveryAction: 'notify',
+ commentedDeliveryAction: 'notify',
+ needsFixFeedbackAction: 'paste_and_notify',
maxConcurrentReviewers: 3,
repos: []
};
@@ -36,11 +105,17 @@ class PrReviewAutomationService {
this.workspaceManager = deps.workspaceManager || null;
this.ensureWorkspaceMixedWorktree = deps.ensureWorkspaceMixedWorktree || null;
this.io = deps.io || null;
+ this.collectDiagnostics = deps.collectDiagnostics || collectDiagnostics;
this.lastPollAt = null;
this.activeReviewers = new Map();
this.processedPrKeys = new Set();
+ this.activeFixers = new Map();
this.pollTimer = null;
+ this.agentCliAvailabilityCache = {
+ at: 0,
+ value: null
+ };
}
static getInstance(deps = {}) {
@@ -80,6 +155,226 @@ class PrReviewAutomationService {
return next;
}
+ _resolveReviewerConfig(cfg = {}) {
+ const rawAgent = String(cfg.reviewerAgent || 'claude').trim().toLowerCase();
+ const agentId = rawAgent === 'codex' ? 'codex' : 'claude';
+ const mode = normalizeReviewerMode(cfg.reviewerMode || 'fresh');
+
+ if (agentId === 'codex') {
+ const model = normalizeOptionalCliValue(cfg.reviewerCodexModel);
+
+ const reasoningRaw = String(cfg.reviewerCodexReasoning || '').trim().toLowerCase();
+ const reasoning = (reasoningRaw === 'low' || reasoningRaw === 'medium' || reasoningRaw === 'high' || reasoningRaw === 'xhigh')
+ ? reasoningRaw
+ : undefined;
+
+ const verbosityRaw = String(cfg.reviewerCodexVerbosity || '').trim().toLowerCase();
+ const verbosity = (verbosityRaw === 'low' || verbosityRaw === 'medium' || verbosityRaw === 'high')
+ ? verbosityRaw
+ : undefined;
+
+ const providedFlags = Array.isArray(cfg.reviewerCodexFlags) ? cfg.reviewerCodexFlags : [];
+ const validFlags = new Set(['yolo', 'workspaceWrite', 'readOnly', 'neverAsk', 'askOnRequest']);
+ const flags = providedFlags
+ .map((f) => String(f || '').trim())
+ .filter((f) => validFlags.has(f));
+
+ return {
+ agentId,
+ mode,
+ model,
+ reasoning,
+ verbosity,
+ flags: flags.length ? [...new Set(flags)] : ['yolo']
+ };
+ }
+
+ const provider = String(cfg.reviewerProvider || 'anthropic').trim() || 'anthropic';
+ const model = normalizeOptionalCliValue(cfg.reviewerClaudeModel);
+ const reviewerSkipPermissions = cfg.reviewerSkipPermissions === undefined ? true : !!cfg.reviewerSkipPermissions;
+
+ return {
+ agentId,
+ mode,
+ provider,
+ model,
+ flags: reviewerSkipPermissions ? ['skipPermissions'] : []
+ };
+ }
+
+ async _getInstalledAgentCliAvailability() {
+ const now = Date.now();
+ if (this.agentCliAvailabilityCache.value && (now - this.agentCliAvailabilityCache.at) < AGENT_CLI_CACHE_TTL_MS) {
+ return this.agentCliAvailabilityCache.value;
+ }
+
+ const diagnostics = await this.collectDiagnostics();
+ const tools = Array.isArray(diagnostics?.tools) ? diagnostics.tools : [];
+ const availability = {
+ claude: tools.some((tool) => tool?.id === 'claude' && !!tool?.ok),
+ codex: tools.some((tool) => tool?.id === 'codex' && !!tool?.ok)
+ };
+
+ this.agentCliAvailabilityCache = {
+ at: now,
+ value: availability
+ };
+ return availability;
+ }
+
+ async _resolveRunnableReviewerConfig(cfg = {}) {
+ const requested = this._resolveReviewerConfig(cfg);
+ try {
+ const availability = await this._getInstalledAgentCliAvailability();
+ if (availability[requested.agentId]) {
+ return requested;
+ }
+
+ const fallbackAgentId = requested.agentId === 'codex' ? 'claude' : 'codex';
+ if (availability[fallbackAgentId]) {
+ logger.warn('Requested reviewer agent CLI unavailable; falling back to installed alternative', {
+ requestedAgent: requested.agentId,
+ fallbackAgent: fallbackAgentId
+ });
+ return this._resolveReviewerConfig({ ...cfg, reviewerAgent: fallbackAgentId });
+ }
+
+ logger.warn('No supported reviewer agent CLI is installed', {
+ requestedAgent: requested.agentId,
+ availability
+ });
+ return null;
+ } catch (error) {
+ logger.warn('Failed to detect reviewer agent CLI availability; using requested agent config', {
+ requestedAgent: requested.agentId,
+ error: error.message
+ });
+ return requested;
+ }
+ }
+
+ _resolvePostReviewAction(prId, cfg = {}) {
+ const record = this.taskRecordService?.get?.(prId) || null;
+ if (record?.reviewerPostAction) {
+ return normalizeReviewerPostAction(record.reviewerPostAction);
+ }
+
+ if (cfg.autoFeedbackToAuthor) return 'feedback';
+ if (cfg.autoSpawnFixer) return 'auto_fix';
+
+ return 'feedback';
+ }
+
+ _resolveOutcomeDeliveryAction(outcome, cfg = {}) {
+ const key = String(outcome || '').trim().toLowerCase();
+ if (key === 'approved') {
+ return normalizeDeliveryAction(cfg.approvedDeliveryAction, 'notify');
+ }
+ if (key === 'commented') {
+ return normalizeDeliveryAction(cfg.commentedDeliveryAction, 'notify');
+ }
+ return normalizeDeliveryAction(cfg.needsFixFeedbackAction, 'paste_and_notify');
+ }
+
+ _inferReviewerAgent(prId, reviewInfo = {}, cfg = {}) {
+ const active = this.activeReviewers.get(prId) || null;
+ const fromSession = String(active?.sessionId || '').trim().toLowerCase();
+ if (fromSession.endsWith('-codex')) return 'codex';
+ if (fromSession.endsWith('-claude')) return 'claude';
+
+ const record = this.taskRecordService?.get?.(prId) || null;
+ const stored = String(record?.reviewerAgent || '').trim().toLowerCase();
+ if (stored === 'codex' || stored === 'claude') return stored;
+
+ const latest = String(reviewInfo?.latestReviewAgent || '').trim().toLowerCase();
+ if (latest === 'codex' || latest === 'claude') return latest;
+
+ return normalizeAgentId(cfg.reviewerAgent || 'claude');
+ }
+
+ _buildReviewSnapshot(prId, reviewInfo = {}, outcome, cfg = {}) {
+ const reviewBody = String(reviewInfo?.reviewBody || '').trim();
+ const reviewSummary = summarizeReviewBody(reviewBody) || '(No detailed comments)';
+ return {
+ latestReviewBody: reviewBody || null,
+ latestReviewSummary: reviewSummary,
+ latestReviewOutcome: outcome || null,
+ latestReviewUser: String(reviewInfo?.reviewUser || '').trim() || null,
+ latestReviewUrl: String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim() || null,
+ latestReviewSubmittedAt: reviewInfo?.reviewSubmittedAt || new Date().toISOString(),
+ latestReviewAgent: this._inferReviewerAgent(prId, reviewInfo, cfg)
+ };
+ }
+
+ _buildReviewFeedbackMessage(prId, reviewInfo = {}, outcome) {
+ const { number } = this._getPrIdentityFromId(prId);
+ const normalizedOutcome = String(outcome || '').trim().toLowerCase();
+ const outcomeLabel = normalizedOutcome === 'approved'
+ ? 'APPROVED'
+ : normalizedOutcome === 'commented'
+ ? 'COMMENTED'
+ : 'CHANGES REQUESTED';
+ const reviewUrl = String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim();
+ const summary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim();
+
+ return [
+ '',
+ '--- PR Review Update ---',
+ `PR #${reviewInfo.number || number || '?'} reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`,
+ `Outcome: ${outcomeLabel}`,
+ reviewInfo.reviewAgent ? `Reviewer agent: ${String(reviewInfo.reviewAgent).trim()}` : '',
+ reviewUrl ? `GitHub: ${reviewUrl}` : '',
+ '',
+ 'Summary:',
+ summary,
+ '',
+ normalizedOutcome === 'needs_fix'
+ ? 'Please address the feedback and push updated commits.'
+ : normalizedOutcome === 'approved'
+ ? 'The PR review approved the current changes.'
+ : 'The reviewer left comments but did not block the PR.',
+ '--- End PR Review Update ---',
+ ''
+ ].filter(Boolean).join('\n');
+ }
+
+ _getPrIdentityFromId(prId) {
+ const raw = String(prId || '').trim();
+ const match = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/);
+ if (!match) return {};
+ return {
+ owner: String(match[1] || '').trim(),
+ repo: String(match[2] || '').trim(),
+ number: Number(match[3]) || 0
+ };
+ }
+
+ _buildFixPrompt(pr, reviewInfo, cfg) {
+ const reviewText = String(reviewInfo?.reviewBody || reviewInfo?.notes || '').trim() || '(no review body provided)';
+ return [
+ `You are a fixer agent for PR ${pr.number} in ${pr.owner}/${pr.repo}.`,
+ pr.title ? `PR title: ${pr.title}` : '',
+ pr.author ? `PR author: ${pr.author}` : '',
+ pr.url ? `PR URL: ${pr.url}` : '',
+ '',
+ 'You should implement only the changes requested in the latest review.',
+ '',
+ 'Reviewer feedback:',
+ reviewText,
+ '',
+ `Use \`gh pr checkout ${pr.number}\` to check out the branch,`,
+ `then use \`gh pr diff ${pr.number}\` to review required changes and apply fixes.`,
+ 'Keep changes scoped to this PR.',
+ 'Run relevant tests/lint before finishing and summarize what was changed.',
+ '',
+ 'Output format:',
+ '1) What you changed',
+ '2) Tests run (commands + result)',
+ '3) Remaining risks / follow-up actions',
+ `Review body from reviewer: ${String(reviewInfo?.reviewUser || '').trim() ? `by ${String(reviewInfo.reviewUser).trim()}` : 'unknown'}.`
+ ].filter(Boolean).join('\n');
+ }
+
// ---------------------------------------------------------------------------
// Polling lifecycle
// ---------------------------------------------------------------------------
@@ -190,7 +485,7 @@ class PrReviewAutomationService {
return { ok: true, prId, spawned: false };
}
- async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url }) {
+ async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url, reviewUrl }) {
const cfg = this.getConfig();
if (!cfg.enabled || !cfg.webhookEnabled) {
return { ignored: true, reason: 'disabled' };
@@ -204,21 +499,60 @@ class PrReviewAutomationService {
: state === 'changes_requested' ? 'needs_fix'
: 'commented';
+ const snapshot = this._buildReviewSnapshot(prId, {
+ owner,
+ repo,
+ number,
+ url,
+ reviewBody,
+ reviewUser,
+ reviewSubmittedAt: new Date().toISOString(),
+ reviewUrl: reviewUrl || url
+ }, outcome, cfg);
+
this.taskRecordService?.upsert?.(prId, {
reviewed: true,
reviewedAt: new Date().toISOString(),
reviewOutcome: outcome,
- reviewEndedAt: new Date().toISOString()
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot
});
// Clean up active reviewer tracking
this.activeReviewers.delete(prId);
- if (outcome === 'needs_fix' && cfg.autoFeedbackToAuthor) {
- await this._sendFeedbackToAuthor(prId, { owner, repo, number, reviewBody, reviewUser, outcome }, cfg);
- }
+ const reviewInfo = {
+ owner,
+ repo,
+ number,
+ url,
+ reviewBody,
+ reviewUser,
+ outcome,
+ reviewUrl: reviewUrl || url,
+ reviewSubmittedAt: snapshot.latestReviewSubmittedAt,
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewAgent: snapshot.latestReviewAgent
+ };
- this._emitUpdate('review-completed', { prId, outcome, reviewUser });
+ const followUp = await this._handleReviewFollowUp(prId, reviewInfo, cfg, outcome);
+ this._emitUpdate('review-completed', {
+ prId,
+ outcome,
+ reviewUser,
+ recordPatch: {
+ reviewed: true,
+ reviewedAt: new Date().toISOString(),
+ reviewOutcome: outcome,
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot,
+ ...(followUp?.recordPatch || {})
+ },
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewUrl: snapshot.latestReviewUrl,
+ pastedToSessionId: followUp?.sessionId || null,
+ deliveryAction: followUp?.deliveryAction || null
+ });
return { ok: true, prId, outcome };
}
@@ -307,7 +641,7 @@ class PrReviewAutomationService {
try {
const prData = await this.pullRequestService.getPullRequest({
owner, repo, number,
- fields: ['reviews']
+ fields: ['reviews', 'html_url', 'url', 'title']
});
const reviews = prData?.reviews || [];
const latestReview = reviews
@@ -323,6 +657,10 @@ class PrReviewAutomationService {
owner,
repo,
number,
+ title: prData?.title || '',
+ url: prData?.html_url || prData?.url || '',
+ reviewUrl: latestReview.html_url || prData?.html_url || prData?.url || '',
+ reviewSubmittedAt: submittedAt,
reviewState: latestReview.state,
reviewBody: latestReview.body || '',
reviewUser: latestReview.author?.login || ''
@@ -365,21 +703,69 @@ class PrReviewAutomationService {
: outcome === 'changes_requested' ? 'needs_fix'
: 'commented';
+ const snapshot = this._buildReviewSnapshot(item.prId, {
+ ...item,
+ reviewSubmittedAt: item.reviewSubmittedAt || new Date().toISOString(),
+ reviewUrl: item.reviewUrl || item.url || ''
+ }, mappedOutcome, cfg);
+
this.taskRecordService?.upsert?.(item.prId, {
reviewed: true,
reviewedAt: new Date().toISOString(),
reviewOutcome: mappedOutcome,
- reviewEndedAt: new Date().toISOString()
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot
});
this.activeReviewers.delete(item.prId);
logger.info('Review completed', { prId: item.prId, outcome: mappedOutcome });
- if (mappedOutcome === 'needs_fix' && cfg.autoFeedbackToAuthor) {
- await this._sendFeedbackToAuthor(item.prId, item, cfg);
+ const followUp = await this._handleReviewFollowUp(item.prId, {
+ ...item,
+ reviewSubmittedAt: snapshot.latestReviewSubmittedAt,
+ reviewUrl: snapshot.latestReviewUrl,
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewAgent: snapshot.latestReviewAgent
+ }, cfg, mappedOutcome);
+
+ this._emitUpdate('review-completed', {
+ prId: item.prId,
+ outcome: mappedOutcome,
+ reviewUser: item.reviewUser,
+ recordPatch: {
+ reviewed: true,
+ reviewedAt: new Date().toISOString(),
+ reviewOutcome: mappedOutcome,
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot,
+ ...(followUp?.recordPatch || {})
+ },
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewUrl: snapshot.latestReviewUrl,
+ pastedToSessionId: followUp?.sessionId || null,
+ deliveryAction: followUp?.deliveryAction || null
+ });
+ }
+
+ async _handleReviewFollowUp(prId, reviewInfo, cfg, outcome) {
+ if (outcome === 'needs_fix') {
+ return this._routeNeedsFixReview(prId, reviewInfo, cfg);
+ }
+
+ const deliveryAction = this._resolveOutcomeDeliveryAction(outcome, cfg);
+ if (deliveryAction === 'none') {
+ return { deliveryAction };
}
- this._emitUpdate('review-completed', { prId: item.prId, outcome: mappedOutcome });
+ return this._sendFeedbackToAuthor(prId, {
+ ...reviewInfo,
+ reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome,
+ deliveryAction,
+ allowNotesFallback: false
+ });
}
// ---------------------------------------------------------------------------
@@ -440,22 +826,32 @@ class PrReviewAutomationService {
}
// Find an available worktree in the active workspace
- const worktreeId = await this._findAvailableWorktree(pr, cfg);
+ const reviewerConfig = await this._resolveRunnableReviewerConfig(cfg);
+ if (!reviewerConfig) {
+ logger.warn('Cannot spawn reviewer - no supported agent CLI available', { prId: pr.prId });
+ return false;
+ }
+
+ const assignment = await this._findAvailableWorktree(pr, reviewerConfig);
+
+ const worktreeId = assignment?.worktreeId;
if (!worktreeId) {
logger.warn('No available worktree for reviewer', { prId: pr.prId });
return false;
}
- const sessionId = `${pr.repo || 'review'}-${worktreeId}-claude`;
+ const sessionId = assignment?.sessionId || `${pr.repo || 'review'}-${worktreeId}-${reviewerConfig.agentId}`;
const prompt = this._buildReviewPrompt(pr, cfg);
try {
- // Start the agent
- const agent = cfg.reviewerAgent || 'claude';
const started = this.sessionManager.startAgentWithConfig(sessionId, {
- provider: agent,
- skipPermissions: true,
- mode: 'fresh'
+ agentId: reviewerConfig.agentId,
+ provider: reviewerConfig.provider,
+ mode: reviewerConfig.mode,
+ flags: reviewerConfig.flags,
+ model: reviewerConfig.model,
+ reasoning: reviewerConfig.reasoning,
+ verbosity: reviewerConfig.verbosity
});
if (!started) {
@@ -464,7 +860,7 @@ class PrReviewAutomationService {
}
// Wait for agent init, then send prompt
- const initDelay = agent === 'codex' ? 15_000 : 8_000;
+ const initDelay = reviewerConfig.agentId === 'codex' ? 15_000 : 8_000;
setTimeout(() => {
this.sessionManager.writeToSession(sessionId, prompt + '\n');
}, initDelay);
@@ -480,11 +876,32 @@ class PrReviewAutomationService {
this.taskRecordService?.upsert?.(pr.prId, {
reviewerSpawnedAt: new Date().toISOString(),
reviewerWorktreeId: worktreeId,
+ reviewerSessionId: sessionId,
+ reviewerAgent: reviewerConfig.agentId,
reviewStartedAt: new Date().toISOString()
});
- logger.info('Reviewer agent spawned', { prId: pr.prId, sessionId, worktreeId, agent });
- this._emitUpdate('reviewer-spawned', { prId: pr.prId, sessionId, worktreeId });
+ logger.info('Reviewer agent spawned', {
+ prId: pr.prId,
+ sessionId,
+ worktreeId,
+ agent: reviewerConfig.agentId,
+ mode: reviewerConfig.mode,
+ provider: reviewerConfig.provider
+ });
+ this._emitUpdate('reviewer-spawned', {
+ prId: pr.prId,
+ sessionId,
+ worktreeId,
+ agentId: reviewerConfig.agentId,
+ recordPatch: {
+ reviewerSpawnedAt: new Date().toISOString(),
+ reviewerWorktreeId: worktreeId,
+ reviewerSessionId: sessionId,
+ reviewerAgent: reviewerConfig.agentId,
+ reviewStartedAt: new Date().toISOString()
+ }
+ });
return true;
} catch (e) {
logger.error('Error spawning reviewer', { prId: pr.prId, error: e.message, stack: e.stack });
@@ -492,6 +909,154 @@ class PrReviewAutomationService {
}
}
+ async _spawnFixerForPr(pr, cfg, reviewInfo = {}) {
+ if (!this.sessionManager || !this.workspaceManager) {
+ logger.warn('Cannot spawn fixer - sessionManager or workspaceManager not available');
+ return false;
+ }
+
+ if (this.activeFixers.has(pr.prId)) {
+ logger.info('Fixer already active for PR', { prId: pr.prId });
+ return false;
+ }
+
+ const fixerConfig = await this._resolveRunnableReviewerConfig(cfg);
+ if (!fixerConfig) {
+ logger.warn('Cannot spawn fixer - no supported agent CLI available', { prId: pr.prId });
+ return false;
+ }
+
+ const assignment = await this._findAvailableWorktree(pr, fixerConfig);
+
+ const worktreeId = assignment?.worktreeId;
+ if (!worktreeId) {
+ logger.warn('No available worktree for fixer', { prId: pr.prId });
+ return false;
+ }
+
+ const sessionId = assignment?.sessionId || `${pr.repo || 'fix'}-${worktreeId}-${fixerConfig.agentId}`;
+ const prompt = this._buildFixPrompt(pr, reviewInfo, cfg);
+
+ try {
+ const started = this.sessionManager.startAgentWithConfig(sessionId, {
+ agentId: fixerConfig.agentId,
+ provider: fixerConfig.provider,
+ mode: fixerConfig.mode,
+ flags: fixerConfig.flags,
+ model: fixerConfig.model,
+ reasoning: fixerConfig.reasoning,
+ verbosity: fixerConfig.verbosity
+ });
+
+ if (!started) {
+ logger.warn('Failed to start fixer agent', { sessionId, prId: pr.prId });
+ return false;
+ }
+
+ const initDelay = fixerConfig.agentId === 'codex' ? 15_000 : 8_000;
+ setTimeout(() => {
+ this.sessionManager.writeToSession(sessionId, prompt + '\n');
+ }, initDelay);
+
+ this.activeFixers.set(pr.prId, {
+ worktreeId,
+ sessionId,
+ spawnedAt: new Date().toISOString()
+ });
+
+ this.taskRecordService?.upsert?.(pr.prId, {
+ fixerSpawnedAt: new Date().toISOString(),
+ fixerWorktreeId: worktreeId
+ });
+
+ logger.info('Fixer agent spawned', {
+ prId: pr.prId,
+ sessionId,
+ worktreeId,
+ agent: fixerConfig.agentId,
+ mode: fixerConfig.mode
+ });
+ this._emitUpdate('fixer-spawned', { prId: pr.prId, sessionId, worktreeId });
+ return { worktreeId, sessionId };
+ } catch (e) {
+ logger.error('Error spawning fixer', { prId: pr.prId, error: e.message, stack: e.stack });
+ return false;
+ }
+ }
+
+ async _routeNeedsFixReview(prId, reviewInfo, cfg) {
+ const action = this._resolvePostReviewAction(prId, cfg);
+ const parsed = this._getPrIdentityFromId(prId);
+ const deliveryAction = this._resolveOutcomeDeliveryAction('needs_fix', cfg);
+
+ if (action === 'auto_fix') {
+ const record = this.taskRecordService?.get?.(prId) || {};
+ const resolvedOwner = String(reviewInfo?.owner || '').trim()
+ || String(record.owner || '').trim()
+ || parsed.owner;
+ const resolvedRepo = String(reviewInfo?.repo || '').trim()
+ || String(record.repo || '').trim()
+ || parsed.repo;
+ const resolvedNumber = Number(reviewInfo?.number || parsed.number || 0);
+
+ const resolved = {
+ ...reviewInfo,
+ title: String(reviewInfo?.title || record.title || '').trim(),
+ owner: resolvedOwner,
+ repo: resolvedRepo,
+ number: resolvedNumber,
+ url: String(reviewInfo?.url || record.url || '').trim(),
+ reviewBody: reviewInfo?.reviewBody || record.notes || ''
+ };
+ const spawnOk = await this._spawnFixerForPr(
+ {
+ prId,
+ number: Number(resolved.number),
+ title: String(resolved.title || '').trim(),
+ owner: String(resolved.owner || '').trim(),
+ repo: String(resolved.repo || '').trim(),
+ author: String(resolved.reviewUser || '').trim(),
+ url: String(resolved.url || '').trim()
+ },
+ cfg,
+ { ...reviewInfo, reviewBody: resolved.reviewBody }
+ );
+
+ if (!spawnOk) {
+ return this._sendFeedbackToAuthor(prId, {
+ ...resolved,
+ reviewSummary: summarizeReviewBody(resolved.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome: 'needs_fix',
+ deliveryAction,
+ allowNotesFallback: true
+ });
+ }
+
+ return {
+ deliveryAction: 'auto_fix',
+ recordPatch: {
+ fixerSpawnedAt: new Date().toISOString(),
+ fixerWorktreeId: spawnOk?.worktreeId || null
+ },
+ sessionId: spawnOk?.sessionId || null
+ };
+ }
+
+ return this._sendFeedbackToAuthor(prId, {
+ ...reviewInfo,
+ owner: reviewInfo?.owner || '',
+ repo: reviewInfo?.repo || '',
+ reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome: 'needs_fix',
+ deliveryAction,
+ allowNotesFallback: true
+ });
+ }
+
// ---------------------------------------------------------------------------
// Internal: find an available worktree for reviewer
// ---------------------------------------------------------------------------
@@ -509,6 +1074,17 @@ class PrReviewAutomationService {
const workspace = this.workspaceManager.getWorkspaceById?.(wsId);
if (!workspace) return null;
+ const reviewerConfig = cfg?.agentId ? cfg : this._resolveReviewerConfig(cfg);
+ const fallbackRepo = String(pr?.repo || '').trim() || 'review';
+
+ const preferredSessionIdsForWorktree = (repoPrefix, worktreeId) => {
+ const ids = [
+ `${repoPrefix}-${worktreeId}-${reviewerConfig.agentId}`,
+ `${repoPrefix}-${worktreeId}-claude`,
+ `${repoPrefix}-${worktreeId}-codex`
+ ];
+ return [...new Set(ids)];
+ };
// Find worktrees that aren't currently used by active reviewers
const usedWorktrees = new Set(
@@ -523,10 +1099,15 @@ class PrReviewAutomationService {
// Check if the session in this worktree is idle/exited
const repoName = terminal.repository?.name || terminal.repositoryName || '';
- const claudeSessionId = `${repoName}-${wId}-claude`;
- const session = this.sessionManager?.getSessionById?.(claudeSessionId);
- if (!session || session.status === 'exited' || session.status === 'idle') {
- return wId;
+ const repoPrefix = String(repoName || fallbackRepo).trim();
+ for (const candidateSessionId of preferredSessionIdsForWorktree(repoPrefix, wId)) {
+ const session = this.sessionManager?.getSessionById?.(candidateSessionId);
+ if (session && (session.status === 'exited' || session.status === 'idle')) {
+ return {
+ worktreeId: wId,
+ sessionId: candidateSessionId
+ };
+ }
}
}
@@ -568,57 +1149,100 @@ class PrReviewAutomationService {
// Internal: send feedback to original author session
// ---------------------------------------------------------------------------
- async _sendFeedbackToAuthor(prId, reviewInfo, cfg) {
+ async _sendFeedbackToAuthor(prId, reviewInfo, cfg, options = {}) {
const record = this.taskRecordService?.get?.(prId);
if (!record) return;
- // Try to find the original author's session from the task record
- // Session IDs follow pattern: repoName-worktreeId-claude
+ const outcome = String(options?.outcome || reviewInfo?.outcome || 'needs_fix').trim().toLowerCase();
+ const deliveryAction = normalizeDeliveryAction(options?.deliveryAction, outcome === 'needs_fix' ? 'paste_and_notify' : 'notify');
+ const allowNotesFallback = options?.allowNotesFallback !== false;
+
const match = prId.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/);
if (!match) return;
const [, , repo] = match;
- // Look through all sessions for one working on this repo
- const sessions = this.sessionManager?.getAllSessions?.() || [];
+ const preferredAgentId = normalizeAgentId(reviewInfo?.reviewAgent || record?.reviewerAgent || cfg?.reviewerAgent || 'claude');
+ const preferredSuffix = `-${preferredAgentId}`;
+ const repoNeedle = String(repo || '').trim().toLowerCase();
+ const targetWorktree = String(record.reviewSourceWorktreeId || '').trim().toLowerCase();
+ const configuredSessionId = String(record.reviewSourceSessionId || '').trim();
+
+ const entries = this.sessionManager?.getAllSessionEntries?.() || [];
let targetSession = null;
- for (const [sid, session] of sessions) {
- if (sid.includes(repo) && sid.endsWith('-claude') && session.status !== 'exited') {
- // Check if this session's worktree matches the PR branch
- targetSession = sid;
+ if (configuredSessionId) {
+ for (const [sid, session] of entries) {
+ if (String(sid || '').trim() !== configuredSessionId) continue;
+ if (session?.status === 'exited') continue;
+ targetSession = configuredSessionId;
break;
}
}
- const feedbackMsg = [
- `\n--- PR Review Feedback ---`,
- `PR #${reviewInfo.number} has been reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`,
- `Outcome: CHANGES REQUESTED`,
- '',
- reviewInfo.reviewBody || '(No detailed comments)',
- '',
- 'Please address the feedback and push updated commits.',
- `--- End Review Feedback ---\n`
- ].join('\n');
+ const isAgentSession = (sid, session) => {
+ const lower = String(sid || '').toLowerCase();
+ if (session?.status === 'exited') return false;
+ return lower.endsWith('-claude') || lower.endsWith('-codex');
+ };
+
+ if (!targetSession) {
+ for (const [sid, session] of entries) {
+ const sidLower = String(sid || '').toLowerCase();
+ if (!isAgentSession(sid, session)) continue;
+ if (targetWorktree && sidLower.includes(`-${targetWorktree}-`) && sidLower.endsWith(preferredSuffix)) {
+ targetSession = sid;
+ break;
+ }
+ }
+ }
+
+ if (!targetSession) {
+ for (const [sid, session] of entries) {
+ const sidLower = String(sid || '').toLowerCase();
+ if (!isAgentSession(sid, session)) continue;
+ if (!repoNeedle || sidLower.includes(repoNeedle)) {
+ if (sidLower.endsWith(preferredSuffix)) {
+ targetSession = sid;
+ break;
+ }
+ }
+ }
+ }
+
+ if (!targetSession) {
+ for (const [sid, session] of entries) {
+ const sidLower = String(sid || '').toLowerCase();
+ if (!isAgentSession(sid, session)) continue;
+ if (!repoNeedle || sidLower.includes(repoNeedle)) {
+ targetSession = sid;
+ break;
+ }
+ }
+ }
- if (targetSession) {
+ const feedbackMsg = this._buildReviewFeedbackMessage(prId, reviewInfo, outcome);
+ const notesSummary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim();
+
+ if (targetSession && (deliveryAction === 'paste' || deliveryAction === 'paste_and_notify')) {
logger.info('Sending review feedback to session', { prId, sessionId: targetSession });
this.sessionManager.writeToSession(targetSession, feedbackMsg);
- return;
+ const deliveredAt = new Date().toISOString();
+ this.taskRecordService?.upsert?.(prId, { latestReviewDeliveredAt: deliveredAt });
+ return {
+ deliveryAction,
+ sessionId: targetSession,
+ recordPatch: { latestReviewDeliveredAt: deliveredAt }
+ };
}
- // If no active session found and autoSpawnFixer is on, spawn a fixer
- if (cfg.autoSpawnFixer) {
- logger.info('Original session not found, would spawn fixer', { prId });
+ if (allowNotesFallback) {
+ logger.info('No active session found for review delivery, storing in task record', { prId, deliveryAction });
this.taskRecordService?.upsert?.(prId, {
- notes: `Review feedback pending - original session not found. Fixer needed.`
- });
- } else {
- logger.info('No active session found for feedback, storing in task record', { prId });
- this.taskRecordService?.upsert?.(prId, {
- notes: `Review: changes requested by ${reviewInfo.reviewUser || 'AI'}. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 500)}`
+ notes: `Review (${outcome}) by ${reviewInfo.reviewUser || 'AI'}: ${notesSummary}`
});
}
+
+ return { deliveryAction, sessionId: null };
}
// ---------------------------------------------------------------------------
diff --git a/server/processProjectDashboardService.js b/server/processProjectDashboardService.js
index 38a08708..7e045de5 100644
--- a/server/processProjectDashboardService.js
+++ b/server/processProjectDashboardService.js
@@ -28,6 +28,24 @@ const riskRank = (risk) => {
return 0;
};
+const extractRepoSlugFromPullRequest = (pr) => {
+ const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim();
+ if (nameWithOwner) return nameWithOwner;
+
+ const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
+ const name = pr?.repository?.name || null;
+ if (owner && name) return `${owner}/${name}`;
+
+ const repoSlug = String(pr?.repository || '').trim();
+ if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug;
+
+ const url = String(pr?.url || '').trim();
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
+ if (match) return `${match[1]}/${match[2]}`;
+
+ return null;
+};
+
class ProcessProjectDashboardService {
constructor({ pullRequestService, taskRecordService } = {}) {
this.pullRequestService = pullRequestService;
@@ -64,9 +82,7 @@ class ProcessProjectDashboardService {
const recordsById = new Map();
if (this.taskRecordService?.get) {
for (const pr of prs) {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
const id = repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : null;
if (!id) continue;
// eslint-disable-next-line no-await-in-loop
@@ -78,9 +94,7 @@ class ProcessProjectDashboardService {
const byRepo = new Map();
for (const pr of prs) {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
if (!repoSlug) continue;
const prId = pr?.number ? `pr:${repoSlug}#${pr.number}` : null;
@@ -225,4 +239,3 @@ class ProcessProjectDashboardService {
}
module.exports = { ProcessProjectDashboardService };
-
diff --git a/server/processTaskService.js b/server/processTaskService.js
index 88c3f9e7..82f16e45 100644
--- a/server/processTaskService.js
+++ b/server/processTaskService.js
@@ -12,6 +12,24 @@ const logger = winston.createLogger({
]
});
+const extractRepoSlugFromPullRequest = (pr) => {
+ const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim();
+ if (nameWithOwner) return nameWithOwner;
+
+ const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
+ const name = pr?.repository?.name || null;
+ if (owner && name) return `${owner}/${name}`;
+
+ const repoSlug = String(pr?.repository || '').trim();
+ if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug;
+
+ const url = String(pr?.url || '').trim();
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
+ if (match) return `${match[1]}/${match[2]}`;
+
+ return null;
+};
+
class ProcessTaskService {
constructor({ sessionManager, worktreeTagService, pullRequestService } = {}) {
this.sessionManager = sessionManager;
@@ -84,9 +102,7 @@ class ProcessTaskService {
});
return (result.prs || []).map(pr => {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
return {
id: repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : `pr:${pr?.url || pr?.number || Math.random()}`,
@@ -128,4 +144,3 @@ class ProcessTaskService {
}
module.exports = { ProcessTaskService };
-
diff --git a/server/sessionManager.js b/server/sessionManager.js
index f9efc646..23b66a83 100644
--- a/server/sessionManager.js
+++ b/server/sessionManager.js
@@ -2756,7 +2756,7 @@ class SessionManager extends EventEmitter {
return getShellKind();
}
- buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions }) {
+ buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions, model }) {
let cmd = 'claude';
if (mode === 'continue') {
@@ -2767,6 +2767,10 @@ class SessionManager extends EventEmitter {
: 'claude --resume';
}
+ if (model) {
+ cmd += ` --model ${quoteForShell(model, shellKind)}`;
+ }
+
if (skipPermissions) {
cmd += ' --dangerously-skip-permissions';
}
@@ -2853,6 +2857,7 @@ class SessionManager extends EventEmitter {
shellKind,
mode: finalOptions.mode,
resumeId: finalOptions.resumeId,
+ model: finalOptions.model,
skipPermissions: !!finalOptions.skipPermissions
});
@@ -2932,6 +2937,7 @@ class SessionManager extends EventEmitter {
shellKind,
mode: finalConfig.mode,
resumeId: finalConfig.resumeId,
+ model: finalConfig.model,
skipPermissions
});
const resolvedCommand = this.resolveClaudeCommand(claudeCmd, provider);
diff --git a/server/taskRecordService.js b/server/taskRecordService.js
index 17095355..af92276b 100644
--- a/server/taskRecordService.js
+++ b/server/taskRecordService.js
@@ -135,6 +135,17 @@ const normalizeReviewOutcome = (v) => {
return allowed.has(s) ? s : null;
};
+const normalizeReviewerPostAction = (v) => {
+ const s = String(v || '').trim().toLowerCase();
+ return s === 'auto_fix' ? 'auto_fix' : 'feedback';
+};
+
+const normalizeAgentId = (v) => {
+ const s = String(v || '').trim().toLowerCase();
+ if (!s) return null;
+ return s === 'codex' ? 'codex' : (s === 'claude' ? 'claude' : null);
+};
+
const normalizeDateTime = (v) => {
if (v === null || v === '') return null;
const dt = new Date(v);
@@ -198,6 +209,44 @@ const normalizeReviewChecklist = (raw) => {
return Object.keys(out).length ? out : null;
};
+const canonicalizePrTaskRecordId = (id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return '';
+
+ const legacyMatch = raw.match(/^pr:https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i);
+ if (legacyMatch) {
+ return `pr:${legacyMatch[1]}/${legacyMatch[2]}#${legacyMatch[3]}`;
+ }
+
+ return raw;
+};
+
+const toLegacyPrTaskRecordId = (id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return null;
+
+ const canonicalMatch = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/i);
+ if (!canonicalMatch) return null;
+
+ return `pr:https://github.com/${canonicalMatch[1]}/${canonicalMatch[2]}/pull/${canonicalMatch[3]}`;
+};
+
+const resolveStoredTaskRecordId = (records, id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return '';
+
+ const all = records && typeof records === 'object' ? records : {};
+ if (all[raw]) return raw;
+
+ const canonical = canonicalizePrTaskRecordId(raw);
+ if (canonical && canonical !== raw && all[canonical]) return canonical;
+
+ const legacy = toLegacyPrTaskRecordId(raw);
+ if (legacy && all[legacy]) return legacy;
+
+ return raw;
+};
+
class TaskRecordService {
constructor({ filePath } = {}) {
this.filePath = filePath || DEFAULT_PATH;
@@ -237,24 +286,37 @@ class TaskRecordService {
list() {
const records = this.data?.records || {};
- return Object.entries(records).map(([id]) => ({ id, ...(this.get(id) || {}) }));
+ const normalized = new Map();
+
+ for (const [storedId] of Object.entries(records)) {
+ const id = canonicalizePrTaskRecordId(storedId) || storedId;
+ const record = this.get(id) || this.get(storedId);
+ if (!record) continue;
+
+ if (!normalized.has(id) || storedId === id) {
+ normalized.set(id, { id, ...record });
+ }
+ }
+
+ return Array.from(normalized.values());
}
get(id) {
if (!id) return null;
- const local = this.data?.records?.[id] || null;
+ const storageId = resolveStoredTaskRecordId(this.data?.records, id);
+ const local = this.data?.records?.[storageId] || null;
const visibility = String(local?.recordVisibility || '').trim().toLowerCase();
if (visibility !== 'shared' && visibility !== 'encrypted') return local;
const repoRoot = String(local?.recordRepoRoot || '').trim();
- const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[visibility];
+ const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[visibility];
if (!repoRoot || !relPath) return local;
try {
const passphrase = visibility === 'encrypted' ? getTaskRecordPassphrase() : '';
if (visibility === 'encrypted' && !passphrase) return local;
- const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility, passphrase });
+ const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility, passphrase });
if (!fromRepo) return local;
return {
...stripRecordPointers(fromRepo),
@@ -397,6 +459,14 @@ class TaskRecordService {
}
}
+ if (p.reviewerPostAction !== undefined) {
+ if (p.reviewerPostAction === null || p.reviewerPostAction === '') {
+ clear.add('reviewerPostAction');
+ } else {
+ next.reviewerPostAction = normalizeReviewerPostAction(p.reviewerPostAction);
+ }
+ }
+
if (p.reviewStartedAt !== undefined) {
const dt = normalizeDateTime(p.reviewStartedAt);
if (dt) next.reviewStartedAt = dt;
@@ -438,6 +508,39 @@ class TaskRecordService {
}
}
+ if (p.reviewerSessionId !== undefined) {
+ if (p.reviewerSessionId === null || p.reviewerSessionId === '') {
+ clear.add('reviewerSessionId');
+ } else {
+ next.reviewerSessionId = String(p.reviewerSessionId || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.reviewerAgent !== undefined) {
+ if (p.reviewerAgent === null || p.reviewerAgent === '') {
+ clear.add('reviewerAgent');
+ } else {
+ const agentId = normalizeAgentId(p.reviewerAgent);
+ if (agentId !== null) next.reviewerAgent = agentId;
+ }
+ }
+
+ if (p.reviewSourceSessionId !== undefined) {
+ if (p.reviewSourceSessionId === null || p.reviewSourceSessionId === '') {
+ clear.add('reviewSourceSessionId');
+ } else {
+ next.reviewSourceSessionId = String(p.reviewSourceSessionId || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.reviewSourceWorktreeId !== undefined) {
+ if (p.reviewSourceWorktreeId === null || p.reviewSourceWorktreeId === '') {
+ clear.add('reviewSourceWorktreeId');
+ } else {
+ next.reviewSourceWorktreeId = String(p.reviewSourceWorktreeId || '').trim().slice(0, 120);
+ }
+ }
+
if (p.fixerSpawnedAt !== undefined) {
const dt = normalizeDateTime(p.fixerSpawnedAt);
if (dt) next.fixerSpawnedAt = dt;
@@ -480,6 +583,68 @@ class TaskRecordService {
}
}
+ if (p.latestReviewBody !== undefined) {
+ if (p.latestReviewBody === null || p.latestReviewBody === '') {
+ clear.add('latestReviewBody');
+ } else {
+ next.latestReviewBody = String(p.latestReviewBody || '').trim().slice(0, 20_000);
+ }
+ }
+
+ if (p.latestReviewSummary !== undefined) {
+ if (p.latestReviewSummary === null || p.latestReviewSummary === '') {
+ clear.add('latestReviewSummary');
+ } else {
+ next.latestReviewSummary = String(p.latestReviewSummary || '').trim().slice(0, 4_000);
+ }
+ }
+
+ if (p.latestReviewOutcome !== undefined) {
+ if (p.latestReviewOutcome === null || p.latestReviewOutcome === '') {
+ clear.add('latestReviewOutcome');
+ } else {
+ const outcome = normalizeReviewOutcome(p.latestReviewOutcome);
+ if (outcome !== null) next.latestReviewOutcome = outcome;
+ }
+ }
+
+ if (p.latestReviewUser !== undefined) {
+ if (p.latestReviewUser === null || p.latestReviewUser === '') {
+ clear.add('latestReviewUser');
+ } else {
+ next.latestReviewUser = String(p.latestReviewUser || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.latestReviewUrl !== undefined) {
+ if (p.latestReviewUrl === null || p.latestReviewUrl === '') {
+ clear.add('latestReviewUrl');
+ } else {
+ next.latestReviewUrl = String(p.latestReviewUrl || '').trim().slice(0, 600);
+ }
+ }
+
+ if (p.latestReviewSubmittedAt !== undefined) {
+ const dt = normalizeDateTime(p.latestReviewSubmittedAt);
+ if (dt) next.latestReviewSubmittedAt = dt;
+ else clear.add('latestReviewSubmittedAt');
+ }
+
+ if (p.latestReviewAgent !== undefined) {
+ if (p.latestReviewAgent === null || p.latestReviewAgent === '') {
+ clear.add('latestReviewAgent');
+ } else {
+ const agentId = normalizeAgentId(p.latestReviewAgent);
+ if (agentId !== null) next.latestReviewAgent = agentId;
+ }
+ }
+
+ if (p.latestReviewDeliveredAt !== undefined) {
+ const dt = normalizeDateTime(p.latestReviewDeliveredAt);
+ if (dt) next.latestReviewDeliveredAt = dt;
+ else clear.add('latestReviewDeliveredAt');
+ }
+
// Optional external ticket/task link (v1: Trello)
if (p.ticketProvider !== undefined) {
if (p.ticketProvider === null || p.ticketProvider === '') {
@@ -619,10 +784,11 @@ class TaskRecordService {
if (!id) throw new Error('id is required');
if (!this.data.records) this.data.records = {};
+ const storageId = resolveStoredTaskRecordId(this.data.records, id);
const nowIso = new Date().toISOString();
- const existed = !!this.data.records[id];
- const existingLocal = this.data.records[id] || {};
+ const existed = !!this.data.records[storageId];
+ const existingLocal = this.data.records[storageId] || {};
const { next, clear } = this.normalizePatch(patch);
const pointerVisibility = String(next.recordVisibility || existingLocal.recordVisibility || 'private').trim().toLowerCase();
@@ -632,14 +798,14 @@ class TaskRecordService {
if (!toSharedOrEncrypted && (existingLocal.recordVisibility === 'shared' || existingLocal.recordVisibility === 'encrypted')) {
const oldVisibility = String(existingLocal.recordVisibility).trim().toLowerCase();
const repoRoot = String(existingLocal.recordRepoRoot || '').trim();
- const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[oldVisibility];
+ const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[oldVisibility];
if (repoRoot && relPath) {
try {
const passphrase = oldVisibility === 'encrypted' ? getTaskRecordPassphrase() : '';
if (oldVisibility !== 'encrypted' || passphrase) {
- const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: oldVisibility, passphrase });
+ const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: oldVisibility, passphrase });
if (fromRepo) {
- this.data.records[id] = {
+ this.data.records[storageId] = {
...stripRecordPointers(fromRepo),
createdAt: fromRepo.createdAt || existingLocal.createdAt || nowIso,
updatedAt: fromRepo.updatedAt || nowIso
@@ -652,7 +818,7 @@ class TaskRecordService {
}
}
- const baseLocal = this.data.records[id] || {};
+ const baseLocal = this.data.records[storageId] || {};
const nextNonPointers = {};
for (const [k, v] of Object.entries(next)) {
if (RECORD_POINTER_KEYS.has(k)) continue;
@@ -668,14 +834,14 @@ class TaskRecordService {
// private store (local JSON)
const merged = { ...mergedCandidate };
for (const k of Array.from(RECORD_POINTER_KEYS)) delete merged[k];
- this.data.records[id] = merged;
+ this.data.records[storageId] = merged;
await this.save();
return merged;
}
const repoRoot = String(next.recordRepoRoot || existingLocal.recordRepoRoot || '').trim();
if (!repoRoot) throw new Error('recordRepoRoot is required for shared/encrypted records');
- const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[pointerVisibility];
+ const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[pointerVisibility];
if (!relPath) throw new Error('recordPath is required for shared/encrypted records');
if (pointerVisibility === 'encrypted') {
@@ -689,7 +855,7 @@ class TaskRecordService {
const passphrase = pointerVisibility === 'encrypted' ? getTaskRecordPassphrase() : '';
const existingRepo = (() => {
try {
- return readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null;
+ return readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null;
} catch {
return null;
}
@@ -700,7 +866,7 @@ class TaskRecordService {
for (const k of clearNonPointers) delete mergedRepo[k];
await writeTaskRecordToRepo({
- id,
+ id: storageId,
repoRoot,
relPath,
visibility: pointerVisibility,
@@ -714,7 +880,7 @@ class TaskRecordService {
recordRepoRoot: repoRoot,
recordPath: relPath
};
- this.data.records[id] = cached;
+ this.data.records[storageId] = cached;
await this.save();
return cached;
}
@@ -722,8 +888,9 @@ class TaskRecordService {
async remove(id) {
if (!id) throw new Error('id is required');
if (!this.data.records) this.data.records = {};
- const existed = !!this.data.records[id];
- delete this.data.records[id];
+ const storageId = resolveStoredTaskRecordId(this.data.records, id);
+ const existed = !!this.data.records[storageId];
+ delete this.data.records[storageId];
await this.save();
return existed;
}
@@ -740,7 +907,8 @@ class TaskRecordService {
const root = String(repoRoot || '').trim();
if (!root) throw new Error('repoRoot is required');
- const defaults = this.defaultRepoTaskRecordPaths(taskId);
+ const storageId = resolveStoredTaskRecordId(this.data?.records, taskId);
+ const defaults = this.defaultRepoTaskRecordPaths(storageId);
const rp = String(relPath || defaults[store] || '').trim();
if (!rp) throw new Error('relPath is required');
@@ -749,11 +917,11 @@ class TaskRecordService {
throw new Error('Encrypted task records require ORCHESTRATOR_TASK_RECORDS_ENCRYPTION_KEY (or ORCHESTRATOR_TASK_RECORDS_PASSPHRASE) to be set');
}
- const existing = this.data?.records?.[taskId] || null;
+ const existing = this.data?.records?.[storageId] || null;
if (!existing) return null;
const record = stripRecordPointers(existing);
- await writeTaskRecordToRepo({ id: taskId, repoRoot: root, relPath: rp, visibility: store, record, passphrase });
+ await writeTaskRecordToRepo({ id: storageId, repoRoot: root, relPath: rp, visibility: store, record, passphrase });
const cached = {
...stripRecordPointers(record),
@@ -763,7 +931,7 @@ class TaskRecordService {
updatedAt: new Date().toISOString()
};
if (!cached.createdAt) cached.createdAt = existing?.createdAt || cached.updatedAt;
- this.data.records[taskId] = cached;
+ this.data.records[storageId] = cached;
await this.save();
return cached;
}
diff --git a/server/userSettingsService.js b/server/userSettingsService.js
index 1bf9c88f..b40bfc2a 100644
--- a/server/userSettingsService.js
+++ b/server/userSettingsService.js
@@ -374,6 +374,32 @@ class UserSettingsService {
closeIfNoDoneList: false,
pollMs: 60_000
}
+ },
+ prReview: {
+ enabled: false,
+ pollEnabled: true,
+ pollMs: 60_000,
+ webhookEnabled: true,
+ reviewerAgent: 'claude',
+ reviewerMode: 'fresh',
+ reviewerProvider: 'anthropic',
+ reviewerClaudeModel: '',
+ reviewerSkipPermissions: true,
+ reviewerCodexModel: '',
+ reviewerCodexReasoning: '',
+ reviewerCodexVerbosity: '',
+ reviewerCodexFlags: ['yolo'],
+ reviewerTier: 3,
+ autoSpawnReviewer: true,
+ autoFeedbackToAuthor: true,
+ autoSpawnFixer: false,
+ notifyOnReviewerSpawn: true,
+ notifyOnReviewCompleted: true,
+ approvedDeliveryAction: 'notify',
+ commentedDeliveryAction: 'notify',
+ needsFixFeedbackAction: 'paste_and_notify',
+ maxConcurrentReviewers: 3,
+ repos: []
}
},
kanban: {
@@ -812,6 +838,10 @@ class UserSettingsService {
...((defaultsAutomations.trello || {}).onPrMerged || {}),
...(((next.trello || {}).onPrMerged) || {})
}
+ },
+ prReview: {
+ ...(defaultsAutomations.prReview || {}),
+ ...(next.prReview || {})
}
};
}
diff --git a/server/voiceCommandService.js b/server/voiceCommandService.js
index 81c870f8..9f9edc01 100644
--- a/server/voiceCommandService.js
+++ b/server/voiceCommandService.js
@@ -276,6 +276,15 @@ class VoiceCommandService {
command: 'queue-spawn-reviewer',
extractParams: () => ({})
},
+ {
+ patterns: [
+ /^(?:review|start|run)\s+pr$/i,
+ /^(?:review|start|run)\s+pull\s+request$/i,
+ /^(?:review|start|run)\s+review\s+pr$/i,
+ ],
+ command: 'queue-review-pr',
+ extractParams: () => ({})
+ },
{
patterns: [
/^(?:spawn|start|run)\s+fixer$/i,
diff --git a/tests/unit/prReviewAutomationService.test.js b/tests/unit/prReviewAutomationService.test.js
new file mode 100644
index 00000000..f3b4c67a
--- /dev/null
+++ b/tests/unit/prReviewAutomationService.test.js
@@ -0,0 +1,270 @@
+jest.mock('winston', () => {
+ const logger = {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn()
+ };
+
+ return {
+ createLogger: jest.fn(() => logger),
+ format: {
+ combine: jest.fn(() => ({})),
+ timestamp: jest.fn(() => ({})),
+ errors: jest.fn(() => ({})),
+ json: jest.fn(() => ({})),
+ simple: jest.fn(() => ({})),
+ colorize: jest.fn(() => ({})),
+ printf: jest.fn(() => ({}))
+ },
+ transports: {
+ File: jest.fn(),
+ Console: jest.fn()
+ }
+ };
+}, { virtual: true });
+
+const { PrReviewAutomationService } = require('../../server/prReviewAutomationService');
+const { SessionManager } = require('../../server/sessionManager');
+
+const makeAutomationService = ({
+ diagnosticsTools = [
+ { id: 'claude', ok: true },
+ { id: 'codex', ok: true }
+ ]
+} = {}) => {
+ const workspace = {
+ id: 'ws-review',
+ terminals: [
+ {
+ id: 'demo-work1-claude',
+ worktreeId: 'work1',
+ repository: { name: 'demo' }
+ }
+ ]
+ };
+
+ const sessionManager = {
+ startAgentWithConfig: jest.fn().mockReturnValue(true),
+ writeToSession: jest.fn(),
+ getSessionById: jest.fn(),
+ getAllSessionEntries: jest.fn(() => [])
+ };
+
+ const taskRecordService = {
+ get: jest.fn(() => null),
+ upsert: jest.fn()
+ };
+
+ const workspaceManager = {
+ getActiveWorkspace: jest.fn(() => ({ id: workspace.id })),
+ getWorkspaceById: jest.fn(() => workspace)
+ };
+
+ const service = new PrReviewAutomationService({
+ sessionManager,
+ taskRecordService,
+ workspaceManager,
+ collectDiagnostics: jest.fn(async () => ({ tools: diagnosticsTools }))
+ });
+
+ return {
+ service,
+ sessionManager,
+ taskRecordService,
+ workspaceManager
+ };
+};
+
+describe('PrReviewAutomationService reviewer launch config', () => {
+ test('normalizes automation-only reviewer config for Codex and Claude', () => {
+ const service = new PrReviewAutomationService();
+
+ const codex = service._resolveReviewerConfig({
+ reviewerAgent: 'codex',
+ reviewerMode: 'resume',
+ reviewerCodexModel: 'latest',
+ reviewerCodexReasoning: 'xhigh',
+ reviewerCodexVerbosity: 'high',
+ reviewerCodexFlags: ['yolo']
+ });
+ expect(codex.agentId).toBe('codex');
+ expect(codex.mode).toBe('continue');
+ expect(codex.model).toBeUndefined();
+ expect(codex.reasoning).toBe('xhigh');
+ expect(codex.verbosity).toBe('high');
+
+ const claude = service._resolveReviewerConfig({
+ reviewerAgent: 'claude',
+ reviewerMode: 'resume',
+ reviewerClaudeModel: 'opus',
+ reviewerSkipPermissions: true
+ });
+ expect(claude.agentId).toBe('claude');
+ expect(claude.mode).toBe('continue');
+ expect(claude.model).toBe('opus');
+ expect(claude.flags).toEqual(['skipPermissions']);
+ });
+});
+
+describe('PrReviewAutomationService reviewer spawning', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ test('launches Codex in the existing agent shell when no -codex session exists', async () => {
+ const { service, sessionManager } = makeAutomationService();
+ sessionManager.getSessionById.mockImplementation((sessionId) => {
+ if (sessionId === 'demo-work1-claude') {
+ return { status: 'idle' };
+ }
+ return null;
+ });
+
+ const ok = await service._spawnReviewerForPr(
+ {
+ prId: 'pr:acme/demo#12',
+ owner: 'acme',
+ repo: 'demo',
+ number: 12,
+ title: 'Review me'
+ },
+ {
+ reviewerAgent: 'codex',
+ reviewerMode: 'fresh',
+ reviewerCodexFlags: ['yolo']
+ }
+ );
+
+ expect(ok).toBe(true);
+ expect(sessionManager.startAgentWithConfig).toHaveBeenCalledWith(
+ 'demo-work1-claude',
+ expect.objectContaining({
+ agentId: 'codex',
+ mode: 'fresh',
+ flags: ['yolo']
+ })
+ );
+
+ jest.runOnlyPendingTimers();
+ expect(sessionManager.writeToSession).toHaveBeenCalledWith(
+ 'demo-work1-claude',
+ expect.stringContaining('gh pr diff 12')
+ );
+ });
+
+ test('falls back to Claude when Codex is not installed', async () => {
+ const { service, sessionManager } = makeAutomationService({
+ diagnosticsTools: [
+ { id: 'claude', ok: true },
+ { id: 'codex', ok: false }
+ ]
+ });
+ sessionManager.getSessionById.mockImplementation((sessionId) => {
+ if (sessionId === 'demo-work1-claude') {
+ return { status: 'idle' };
+ }
+ return null;
+ });
+
+ const ok = await service._spawnReviewerForPr(
+ {
+ prId: 'pr:acme/demo#42',
+ owner: 'acme',
+ repo: 'demo',
+ number: 42,
+ title: 'Fallback me'
+ },
+ {
+ reviewerAgent: 'codex',
+ reviewerMode: 'fresh',
+ reviewerSkipPermissions: true
+ }
+ );
+
+ expect(ok).toBe(true);
+ expect(sessionManager.startAgentWithConfig).toHaveBeenCalledWith(
+ 'demo-work1-claude',
+ expect.objectContaining({
+ agentId: 'claude',
+ mode: 'fresh',
+ flags: ['skipPermissions']
+ })
+ );
+ });
+
+ test('stores review snapshot and pastes needs-fix feedback back to the source session', async () => {
+ const { service, sessionManager, taskRecordService } = makeAutomationService();
+ service.userSettingsService = {
+ getAllSettings: () => ({
+ global: {
+ ui: {
+ tasks: {
+ automations: {
+ prReview: {
+ enabled: true,
+ webhookEnabled: true,
+ needsFixFeedbackAction: 'paste_and_notify'
+ }
+ }
+ }
+ }
+ }
+ })
+ };
+ taskRecordService.get.mockReturnValue({
+ reviewSourceSessionId: 'demo-work2-claude',
+ reviewSourceWorktreeId: 'work2',
+ reviewerPostAction: 'feedback'
+ });
+ sessionManager.getAllSessionEntries.mockReturnValue([
+ ['demo-work2-claude', { status: 'waiting' }]
+ ]);
+
+ const result = await service.onReviewSubmitted({
+ owner: 'acme',
+ repo: 'demo',
+ number: 77,
+ reviewState: 'changes_requested',
+ reviewBody: 'Fix the failing edge case and add a regression test.',
+ reviewUser: 'review-bot',
+ url: 'https://github.com/acme/demo/pull/77',
+ reviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1'
+ });
+
+ expect(result).toEqual(expect.objectContaining({ ok: true, outcome: 'needs_fix' }));
+ expect(taskRecordService.upsert).toHaveBeenCalledWith(
+ 'pr:acme/demo#77',
+ expect.objectContaining({
+ latestReviewOutcome: 'needs_fix',
+ latestReviewUser: 'review-bot',
+ latestReviewSummary: expect.stringContaining('Fix the failing edge case'),
+ latestReviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1'
+ })
+ );
+ expect(sessionManager.writeToSession).toHaveBeenCalledWith(
+ 'demo-work2-claude',
+ expect.stringContaining('CHANGES REQUESTED')
+ );
+ });
+});
+
+describe('SessionManager.buildClaudeCommand', () => {
+ test('includes explicit model aliases in Claude launches', () => {
+ const sessionManager = new SessionManager({ emit: jest.fn() }, { getAllAgents: () => [] });
+
+ const command = sessionManager.buildClaudeCommand({
+ shellKind: 'bash',
+ mode: 'fresh',
+ model: 'opus',
+ skipPermissions: true
+ });
+
+ expect(command).toBe("claude --model 'opus' --dangerously-skip-permissions");
+ });
+});
diff --git a/tests/unit/processTaskService.test.js b/tests/unit/processTaskService.test.js
index 9a9545d0..5e828f5a 100644
--- a/tests/unit/processTaskService.test.js
+++ b/tests/unit/processTaskService.test.js
@@ -10,7 +10,7 @@ describe('ProcessTaskService', () => {
title: 'PR title',
state: 'OPEN',
url: 'https://example.com/pr/5',
- repository: { name: 'repo', owner: { login: 'me' } },
+ repository: { name: 'repo', nameWithOwner: 'me/repo' },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
author: { login: 'me' }
@@ -42,8 +42,13 @@ describe('ProcessTaskService', () => {
const service = new ProcessTaskService({ sessionManager, worktreeTagService, pullRequestService });
const tasks = await service.listTasks();
+ const prTask = tasks.find(t => t.kind === 'pr' && t.prNumber === 5);
- expect(tasks.some(t => t.kind === 'pr' && t.prNumber === 5)).toBe(true);
+ expect(prTask).toMatchObject({
+ id: 'pr:me/repo#5',
+ repository: 'me/repo',
+ prNumber: 5
+ });
expect(tasks.some(t => t.kind === 'worktree' && t.worktreePath === '/tmp/work1')).toBe(true);
expect(tasks.some(t => t.kind === 'session' && t.sessionId === 's1')).toBe(true);
@@ -51,4 +56,3 @@ describe('ProcessTaskService', () => {
expect(tasks[0].kind).toBe('session');
});
});
-
diff --git a/tests/unit/taskRecordService.test.js b/tests/unit/taskRecordService.test.js
index faa45071..77f23a00 100644
--- a/tests/unit/taskRecordService.test.js
+++ b/tests/unit/taskRecordService.test.js
@@ -57,10 +57,22 @@ describe('TaskRecordService', () => {
promptChars: 123,
reviewerSpawnedAt: '2026-01-25T00:00:11Z',
reviewerWorktreeId: 'work9',
+ reviewerSessionId: 'demo-work9-claude',
+ reviewerAgent: 'CLAUDE',
+ reviewSourceSessionId: 'demo-work1-claude',
+ reviewSourceWorktreeId: 'work1',
fixerSpawnedAt: '2026-01-25T00:00:12Z',
fixerWorktreeId: 'work10',
recheckSpawnedAt: '2026-01-25T00:00:13Z',
- recheckWorktreeId: 'work11'
+ recheckWorktreeId: 'work11',
+ latestReviewSummary: 'Fix the edge case.',
+ latestReviewBody: 'Fix the edge case. Add a regression test.',
+ latestReviewOutcome: 'NEEDS_FIX',
+ latestReviewUser: 'review-bot',
+ latestReviewUrl: 'https://github.com/acme/demo/pull/1#pullrequestreview-1',
+ latestReviewSubmittedAt: '2026-01-25T00:00:14Z',
+ latestReviewAgent: 'CODEX',
+ latestReviewDeliveredAt: '2026-01-25T00:00:15Z'
});
expect(rec.reviewStartedAt).toBe('2026-01-25T00:00:00.000Z');
@@ -69,10 +81,22 @@ describe('TaskRecordService', () => {
expect(rec.promptChars).toBe(123);
expect(rec.reviewerSpawnedAt).toBe('2026-01-25T00:00:11.000Z');
expect(rec.reviewerWorktreeId).toBe('work9');
+ expect(rec.reviewerSessionId).toBe('demo-work9-claude');
+ expect(rec.reviewerAgent).toBe('claude');
+ expect(rec.reviewSourceSessionId).toBe('demo-work1-claude');
+ expect(rec.reviewSourceWorktreeId).toBe('work1');
expect(rec.fixerSpawnedAt).toBe('2026-01-25T00:00:12.000Z');
expect(rec.fixerWorktreeId).toBe('work10');
expect(rec.recheckSpawnedAt).toBe('2026-01-25T00:00:13.000Z');
expect(rec.recheckWorktreeId).toBe('work11');
+ expect(rec.latestReviewSummary).toBe('Fix the edge case.');
+ expect(rec.latestReviewBody).toBe('Fix the edge case. Add a regression test.');
+ expect(rec.latestReviewOutcome).toBe('needs_fix');
+ expect(rec.latestReviewUser).toBe('review-bot');
+ expect(rec.latestReviewUrl).toBe('https://github.com/acme/demo/pull/1#pullrequestreview-1');
+ expect(rec.latestReviewSubmittedAt).toBe('2026-01-25T00:00:14.000Z');
+ expect(rec.latestReviewAgent).toBe('codex');
+ expect(rec.latestReviewDeliveredAt).toBe('2026-01-25T00:00:15.000Z');
});
test('upsert supports prompt repo location fields', async () => {
@@ -127,6 +151,24 @@ describe('TaskRecordService', () => {
expect(rec2.reviewedAt).toBeUndefined();
});
+ test('legacy PR URL ids resolve through canonical ids', async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-task-records-'));
+ const filePath = path.join(tmp, 'task-records.json');
+ const svc = new TaskRecordService({ filePath });
+
+ const legacyId = 'pr:https://github.com/me/repo/pull/42';
+ await svc.upsert(legacyId, { tier: 3, claimedBy: 'me' });
+
+ expect(svc.get('pr:me/repo#42')).toMatchObject({ tier: 3, claimedBy: 'me' });
+ expect(svc.list().map((r) => r.id)).toContain('pr:me/repo#42');
+
+ await svc.upsert('pr:me/repo#42', { reviewOutcome: 'needs_fix' });
+
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ expect(raw.records[legacyId].reviewOutcome).toBe('needs_fix');
+ expect(raw.records['pr:me/repo#42']).toBeUndefined();
+ });
+
test('upsert supports overnight runner fields', async () => {
const svc = TaskRecordService.getInstance();
svc.data = { version: 1, records: {} };
diff --git a/tests/unit/userSettingsDefaults.test.js b/tests/unit/userSettingsDefaults.test.js
index e2b29f2b..a550b891 100644
--- a/tests/unit/userSettingsDefaults.test.js
+++ b/tests/unit/userSettingsDefaults.test.js
@@ -114,6 +114,18 @@ describe('UserSettingsService defaults', () => {
expect(typeof pager.doneCheck.enabled).toBe('boolean');
});
+ test('includes PR review automation defaults', () => {
+ const defaults = UserSettingsService.prototype.getDefaultSettings.call({});
+ const prReview = defaults?.global?.ui?.tasks?.automations?.prReview;
+ expect(prReview).toBeTruthy();
+ expect(prReview.reviewerAgent).toBe('claude');
+ expect(prReview.notifyOnReviewerSpawn).toBe(true);
+ expect(prReview.notifyOnReviewCompleted).toBe(true);
+ expect(prReview.approvedDeliveryAction).toBe('notify');
+ expect(prReview.commentedDeliveryAction).toBe('notify');
+ expect(prReview.needsFixFeedbackAction).toBe('paste_and_notify');
+ });
+
test('mergeSettings deep-merges ui.tasks without dropping defaults', () => {
const defaults = UserSettingsService.prototype.getDefaultSettings.call({});
const merged = UserSettingsService.prototype.mergeSettings.call({}, defaults, {
diff --git a/user-settings.default.json b/user-settings.default.json
index f0f98b00..2e5977af 100644
--- a/user-settings.default.json
+++ b/user-settings.default.json
@@ -177,9 +177,9 @@
},
"reviewInbox": {
"mode": "mine",
- "tiers": "t3t4",
+ "tiers": "all",
"kind": "pr",
- "unreviewedOnly": true,
+ "unreviewedOnly": false,
"autoConsole": false,
"autoAdvance": false,
"prioritizeActive": true,
@@ -258,6 +258,34 @@
"closeIfNoDoneList": false,
"pollMs": 60000
}
+ },
+ "prReview": {
+ "enabled": false,
+ "pollEnabled": true,
+ "pollMs": 60000,
+ "webhookEnabled": true,
+ "reviewerAgent": "claude",
+ "reviewerMode": "fresh",
+ "reviewerProvider": "anthropic",
+ "reviewerClaudeModel": "",
+ "reviewerSkipPermissions": true,
+ "reviewerCodexModel": "",
+ "reviewerCodexReasoning": "",
+ "reviewerCodexVerbosity": "",
+ "reviewerCodexFlags": [
+ "yolo"
+ ],
+ "reviewerTier": 3,
+ "autoSpawnReviewer": true,
+ "autoFeedbackToAuthor": true,
+ "autoSpawnFixer": false,
+ "notifyOnReviewerSpawn": true,
+ "notifyOnReviewCompleted": true,
+ "approvedDeliveryAction": "notify",
+ "commentedDeliveryAction": "notify",
+ "needsFixFeedbackAction": "paste_and_notify",
+ "maxConcurrentReviewers": 3,
+ "repos": []
}
},
"kanban": {