diff --git a/README.md b/README.md
index 412cec2..6ce8fdb 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
- **Themes** — Dark (Tokyo Night), Light, Soft Light, and Ultra Dark — terminal theme syncs automatically
- **Custom buttons** — Configurable action buttons that run shell commands or Claude prompts with placement, scope, and shortcut options
- **Session startup prompts** — Pre-configure per-project prompts (e.g. `/notion-ticket {{input}}`) that auto-run in a new session's agent terminal
+- **Review loop** — One-click review → triage → fix cycle on a branch with stop conditions (clean rounds, iteration cap, cost cap) and a sticky PR comment for skipped findings
- **Claude Web sessions** — Surface your own `claude/*` branches from Claude Code on the web in the sidebar; click to open them locally as worktrees
- **Keyboard navigable** — Full keyboard support: arrow keys, focus trapping, roving tabindex, accessible by default
@@ -362,6 +363,33 @@ Create configurable action buttons that run shell commands or Claude prompts fro
+
+Review loop
+
+Automate the review → triage → fix cycle on a branch. Each round runs three phases against the branch's diff vs. its base:
+
+1. **Review** — A fresh Claude reviews the diff and writes structured findings to `.crucible/review-loop/round-N-issues.json`.
+2. **Triage** — Another Claude reads those findings and fans out a sub-agent per issue. Each sub-agent decides `fix` / `skip` / `defer` / `noop` and writes a short justification.
+3. **Fix** — Claude applies the fixes, commits, and pushes.
+
+The loop stops on the first of: N consecutive clean rounds (default 2), iteration cap (default 5), cost cap (default $5), or manual cancel. Workspace defaults and per-project overrides for all four live in **Settings → Review Loop**, including a per-project toggle that hides the toolbar button and prevents the loop from running for that scope.
+
+Skipped or deferred items get summarised in a single sticky comment on the open PR (using a hidden marker so subsequent rounds update the same comment instead of re-posting). That gives reviewers a record of what was knowingly left undone and why.
+
+The Review Loop tab in the session workspace shows live progress: status pill, current phase, cumulative cost, per-round triage decisions, and a per-round log.
+
+
+
+
+
+
+
+
+
+
+
+
+
Permissions sync
diff --git a/docs/screenshots/button-settings.png b/docs/screenshots/button-settings.png
index 9c9728b..be2adbe 100644
Binary files a/docs/screenshots/button-settings.png and b/docs/screenshots/button-settings.png differ
diff --git a/docs/screenshots/code-attention.png b/docs/screenshots/code-attention.png
index 67b51e1..f4d2f1d 100644
Binary files a/docs/screenshots/code-attention.png and b/docs/screenshots/code-attention.png differ
diff --git a/docs/screenshots/custom-buttons.png b/docs/screenshots/custom-buttons.png
index b6218e0..aaefbcf 100644
Binary files a/docs/screenshots/custom-buttons.png and b/docs/screenshots/custom-buttons.png differ
diff --git a/docs/screenshots/editor-branch-picker.png b/docs/screenshots/editor-branch-picker.png
index bc10837..ed676d0 100644
Binary files a/docs/screenshots/editor-branch-picker.png and b/docs/screenshots/editor-branch-picker.png differ
diff --git a/docs/screenshots/editor-worktree.png b/docs/screenshots/editor-worktree.png
index 0fb5e04..9338f98 100644
Binary files a/docs/screenshots/editor-worktree.png and b/docs/screenshots/editor-worktree.png differ
diff --git a/docs/screenshots/editor.png b/docs/screenshots/editor.png
index e3c3962..5568cf7 100644
Binary files a/docs/screenshots/editor.png and b/docs/screenshots/editor.png differ
diff --git a/docs/screenshots/git-diff.png b/docs/screenshots/git-diff.png
index d39ca15..091c71f 100644
Binary files a/docs/screenshots/git-diff.png and b/docs/screenshots/git-diff.png differ
diff --git a/docs/screenshots/hero.png b/docs/screenshots/hero.png
index d39ca15..4c24260 100644
Binary files a/docs/screenshots/hero.png and b/docs/screenshots/hero.png differ
diff --git a/docs/screenshots/new-session-dialog-with-input.png b/docs/screenshots/new-session-dialog-with-input.png
index 8a32884..c16c8d0 100644
Binary files a/docs/screenshots/new-session-dialog-with-input.png and b/docs/screenshots/new-session-dialog-with-input.png differ
diff --git a/docs/screenshots/new-session-dialog.png b/docs/screenshots/new-session-dialog.png
index 4e1554a..e8da8a6 100644
Binary files a/docs/screenshots/new-session-dialog.png and b/docs/screenshots/new-session-dialog.png differ
diff --git a/docs/screenshots/opened-as-main-branch.png b/docs/screenshots/opened-as-main-branch.png
index f79d507..4da1cfa 100644
Binary files a/docs/screenshots/opened-as-main-branch.png and b/docs/screenshots/opened-as-main-branch.png differ
diff --git a/docs/screenshots/pr-attention.png b/docs/screenshots/pr-attention.png
index a83e597..1a52c35 100644
Binary files a/docs/screenshots/pr-attention.png and b/docs/screenshots/pr-attention.png differ
diff --git a/docs/screenshots/pr-review-contextmenu-changedfiles.png b/docs/screenshots/pr-review-contextmenu-changedfiles.png
index ea4ce3a..9a0d18d 100644
Binary files a/docs/screenshots/pr-review-contextmenu-changedfiles.png and b/docs/screenshots/pr-review-contextmenu-changedfiles.png differ
diff --git a/docs/screenshots/pr-review-contextmenu-fileexplorer.png b/docs/screenshots/pr-review-contextmenu-fileexplorer.png
index d2c6721..0e67955 100644
Binary files a/docs/screenshots/pr-review-contextmenu-fileexplorer.png and b/docs/screenshots/pr-review-contextmenu-fileexplorer.png differ
diff --git a/docs/screenshots/pr-review-inlinethread-open.png b/docs/screenshots/pr-review-inlinethread-open.png
index 2af3312..6a9c7c1 100644
Binary files a/docs/screenshots/pr-review-inlinethread-open.png and b/docs/screenshots/pr-review-inlinethread-open.png differ
diff --git a/docs/screenshots/pr-review-inlinethread-resolved.png b/docs/screenshots/pr-review-inlinethread-resolved.png
index 3f821a1..5edf356 100644
Binary files a/docs/screenshots/pr-review-inlinethread-resolved.png and b/docs/screenshots/pr-review-inlinethread-resolved.png differ
diff --git a/docs/screenshots/pr-review-inlinethread-suggestion.png b/docs/screenshots/pr-review-inlinethread-suggestion.png
index 90fdeaf..410d6ee 100644
Binary files a/docs/screenshots/pr-review-inlinethread-suggestion.png and b/docs/screenshots/pr-review-inlinethread-suggestion.png differ
diff --git a/docs/screenshots/pr-review-reviewers-empty.png b/docs/screenshots/pr-review-reviewers-empty.png
index a2c4ad9..cf7a33d 100644
Binary files a/docs/screenshots/pr-review-reviewers-empty.png and b/docs/screenshots/pr-review-reviewers-empty.png differ
diff --git a/docs/screenshots/pr-review-reviewers-mixed.png b/docs/screenshots/pr-review-reviewers-mixed.png
index aa1f596..8ea9699 100644
Binary files a/docs/screenshots/pr-review-reviewers-mixed.png and b/docs/screenshots/pr-review-reviewers-mixed.png differ
diff --git a/docs/screenshots/pr-review-reviewers-pending-only.png b/docs/screenshots/pr-review-reviewers-pending-only.png
index 85b0485..69c0d6d 100644
Binary files a/docs/screenshots/pr-review-reviewers-pending-only.png and b/docs/screenshots/pr-review-reviewers-pending-only.png differ
diff --git a/docs/screenshots/pr-review-suggestion-disabled.png b/docs/screenshots/pr-review-suggestion-disabled.png
index 4f95081..30f2ccc 100644
Binary files a/docs/screenshots/pr-review-suggestion-disabled.png and b/docs/screenshots/pr-review-suggestion-disabled.png differ
diff --git a/docs/screenshots/pr-review-suggestion-multiline.png b/docs/screenshots/pr-review-suggestion-multiline.png
index 136e825..abbc385 100644
Binary files a/docs/screenshots/pr-review-suggestion-multiline.png and b/docs/screenshots/pr-review-suggestion-multiline.png differ
diff --git a/docs/screenshots/pr-review-suggestion-singleline.png b/docs/screenshots/pr-review-suggestion-singleline.png
index 73e68e5..5598360 100644
Binary files a/docs/screenshots/pr-review-suggestion-singleline.png and b/docs/screenshots/pr-review-suggestion-singleline.png differ
diff --git a/docs/screenshots/pr-review.png b/docs/screenshots/pr-review.png
index dd0429c..fd5aa82 100644
Binary files a/docs/screenshots/pr-review.png and b/docs/screenshots/pr-review.png differ
diff --git a/docs/screenshots/pr-sort-filter-menu-active.png b/docs/screenshots/pr-sort-filter-menu-active.png
index 81beef4..c2090a5 100644
Binary files a/docs/screenshots/pr-sort-filter-menu-active.png and b/docs/screenshots/pr-sort-filter-menu-active.png differ
diff --git a/docs/screenshots/pr-sort-filter-menu-people.png b/docs/screenshots/pr-sort-filter-menu-people.png
index 46475f0..ae62c98 100644
Binary files a/docs/screenshots/pr-sort-filter-menu-people.png and b/docs/screenshots/pr-sort-filter-menu-people.png differ
diff --git a/docs/screenshots/pr-sort-filter-menu.png b/docs/screenshots/pr-sort-filter-menu.png
index df71acb..af0e874 100644
Binary files a/docs/screenshots/pr-sort-filter-menu.png and b/docs/screenshots/pr-sort-filter-menu.png differ
diff --git a/docs/screenshots/review-loop-completed.png b/docs/screenshots/review-loop-completed.png
new file mode 100644
index 0000000..c990632
Binary files /dev/null and b/docs/screenshots/review-loop-completed.png differ
diff --git a/docs/screenshots/review-loop-running.png b/docs/screenshots/review-loop-running.png
new file mode 100644
index 0000000..506d0b3
Binary files /dev/null and b/docs/screenshots/review-loop-running.png differ
diff --git a/docs/screenshots/review-loop-settings.png b/docs/screenshots/review-loop-settings.png
new file mode 100644
index 0000000..7c54957
Binary files /dev/null and b/docs/screenshots/review-loop-settings.png differ
diff --git a/docs/screenshots/sessions.png b/docs/screenshots/sessions.png
index 0fc4505..58cf02c 100644
Binary files a/docs/screenshots/sessions.png and b/docs/screenshots/sessions.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index 6c52fc4..986817a 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/startup-prompt-editor.png b/docs/screenshots/startup-prompt-editor.png
index d1ede28..5a717c8 100644
Binary files a/docs/screenshots/startup-prompt-editor.png and b/docs/screenshots/startup-prompt-editor.png differ
diff --git a/docs/screenshots/startup-prompt-settings.png b/docs/screenshots/startup-prompt-settings.png
index f9c0ae6..df12a1d 100644
Binary files a/docs/screenshots/startup-prompt-settings.png and b/docs/screenshots/startup-prompt-settings.png differ
diff --git a/docs/screenshots/tab-attention.png b/docs/screenshots/tab-attention.png
index 1fd0f13..2a13252 100644
Binary files a/docs/screenshots/tab-attention.png and b/docs/screenshots/tab-attention.png differ
diff --git a/docs/screenshots/theme-dark.png b/docs/screenshots/theme-dark.png
index e734bb5..ada8cfa 100644
Binary files a/docs/screenshots/theme-dark.png and b/docs/screenshots/theme-dark.png differ
diff --git a/docs/screenshots/theme-light.png b/docs/screenshots/theme-light.png
index 0e3118c..2530cac 100644
Binary files a/docs/screenshots/theme-light.png and b/docs/screenshots/theme-light.png differ
diff --git a/docs/screenshots/theme-soft-light.png b/docs/screenshots/theme-soft-light.png
index 9874d4a..1aaa7b2 100644
Binary files a/docs/screenshots/theme-soft-light.png and b/docs/screenshots/theme-soft-light.png differ
diff --git a/docs/screenshots/theme-ultra-dark.png b/docs/screenshots/theme-ultra-dark.png
index 5081c38..8eadb8a 100644
Binary files a/docs/screenshots/theme-ultra-dark.png and b/docs/screenshots/theme-ultra-dark.png differ
diff --git a/mock/mockApi.ts b/mock/mockApi.ts
index 4b1ad7a..d3b7a9e 100644
--- a/mock/mockApi.ts
+++ b/mock/mockApi.ts
@@ -27,6 +27,8 @@ import {
mockButtons,
mockButtonGroups,
mockStartupPrompts,
+ mockReviewLoopSettings,
+ mockReviewLoopRunning,
} from './mockData'
// Collect terminal.onData callbacks so we can push fake output
@@ -214,6 +216,23 @@ export const mockApi = {
save: async () => {},
},
+ reviewLoop: {
+ getSettings: async () => mockReviewLoopSettings,
+ setSettings: async () => {},
+ start: async () => {},
+ cancel: async () => {},
+ /**
+ * Stories can override the returned state by setting
+ * `(window as any).__mockReviewLoopState = state` before mount.
+ * Falls back to the running snapshot.
+ */
+ getState: async () => {
+ const override = (globalThis as any).__mockReviewLoopState
+ return override ?? mockReviewLoopRunning
+ },
+ onStateUpdate: () => noop(),
+ },
+
claudeWeb: {
listSessions: async () => [
{
diff --git a/mock/mockData.ts b/mock/mockData.ts
index 9d68fbb..2c637c5 100644
--- a/mock/mockData.ts
+++ b/mock/mockData.ts
@@ -19,6 +19,8 @@ import type {
CustomButton,
CustomButtonGroup,
StartupPrompt,
+ ReviewLoopState,
+ ReviewLoopSettings,
} from '../src/shared/types'
// --- Accounts ---
@@ -639,3 +641,205 @@ export const mockStartupPrompts: Record = {
],
'proj-2': [],
}
+
+// --- Review loop ---
+
+export const mockReviewLoopSettings: ReviewLoopSettings = {
+ workspace: {
+ enabled: true,
+ maxIterations: 5,
+ consecutiveCleanRounds: 2,
+ costCapUsd: 5,
+ },
+ projectOverrides: {
+ 'proj-2': { enabled: false },
+ },
+}
+
+const baseTime = Date.parse('2026-04-30T13:00:00Z')
+
+/**
+ * A representative "running" review loop snapshot — round 1 has finished
+ * (with 2 fixes applied + 1 deferred), round 2 is mid-triage. Designed to
+ * exercise every UI element in the panel: status pill, cost, decisions,
+ * skipped/deferred summary, log.
+ */
+export const mockReviewLoopRunning: ReviewLoopState = {
+ sessionId: 'sess-1',
+ branch: 'session/add-pr-review',
+ baseBranch: 'main',
+ worktreePath: '/Users/dev/.codecrucible-worktrees/CodeCrucible/add-pr-review',
+ status: 'running',
+ currentPhase: 'triage',
+ iteration: 2,
+ cumulativeCostUsd: 1.42,
+ startedAt: new Date(baseTime).toISOString(),
+ skippedIssues: [
+ {
+ id: 'r1-3',
+ title: 'Duplicate retry logic in pr.service vs git.service',
+ description: 'Both files re-implement exponential backoff. Could be extracted but is pre-existing code.',
+ file: 'src/main/services/pr.service.ts',
+ line: 88,
+ category: 'style',
+ introducedInPR: false,
+ decision: 'defer',
+ justification: 'Pre-existing duplication on main; out of scope for this PR. File a follow-up to extract a shared backoff helper.',
+ },
+ ],
+ rounds: [
+ {
+ index: 1,
+ startedAt: new Date(baseTime + 1_000).toISOString(),
+ endedAt: new Date(baseTime + 92_000).toISOString(),
+ phase: 'idle',
+ costUsd: 0.84,
+ log: [
+ '[2026-04-30T13:00:01Z] Reviewing diff between session/add-pr-review and main…',
+ '[2026-04-30T13:00:34Z] Review found 3 candidate issues.',
+ '[2026-04-30T13:00:34Z] Triaging 3 issues (one sub-agent each)…',
+ '[2026-04-30T13:01:18Z] Triage complete: 2 to fix, 1 skipped/deferred.',
+ '[2026-04-30T13:01:18Z] Applying fixes, committing, and pushing…',
+ '[2026-04-30T13:01:32Z] Fix phase complete.',
+ ],
+ rawIssues: [
+ {
+ id: 'r1-1',
+ title: 'Race condition between PR list refresh and selection',
+ description: 'When refresh fires while the user is mid-click, the previous selection can land on a removed PR.',
+ file: 'src/renderer/stores/prStore.ts',
+ line: 142,
+ category: 'bug',
+ },
+ {
+ id: 'r1-2',
+ title: 'Missing await on github.markPRSeen',
+ description: 'Promise rejection silently swallowed; seen state never persists if the API errors.',
+ file: 'src/renderer/components/pullrequests/PRListItem.tsx',
+ line: 56,
+ category: 'bug',
+ },
+ {
+ id: 'r1-3',
+ title: 'Duplicate retry logic in pr.service vs git.service',
+ description: 'Both files re-implement exponential backoff. Could be extracted but is pre-existing code.',
+ file: 'src/main/services/pr.service.ts',
+ line: 88,
+ category: 'style',
+ },
+ ],
+ triaged: [
+ {
+ id: 'r1-1',
+ title: 'Race condition between PR list refresh and selection',
+ description: 'When refresh fires while the user is mid-click, the previous selection can land on a removed PR.',
+ file: 'src/renderer/stores/prStore.ts',
+ line: 142,
+ category: 'bug',
+ introducedInPR: true,
+ decision: 'fix',
+ justification: 'Real bug introduced by the new refresh polling. Guard the selection update against the in-flight click.',
+ },
+ {
+ id: 'r1-2',
+ title: 'Missing await on github.markPRSeen',
+ description: 'Promise rejection silently swallowed; seen state never persists if the API errors.',
+ file: 'src/renderer/components/pullrequests/PRListItem.tsx',
+ line: 56,
+ category: 'bug',
+ introducedInPR: true,
+ decision: 'fix',
+ justification: 'Confirmed missing await — added in this PR. Awaiting and surfacing the error keeps state consistent.',
+ },
+ {
+ id: 'r1-3',
+ title: 'Duplicate retry logic in pr.service vs git.service',
+ description: 'Both files re-implement exponential backoff. Could be extracted but is pre-existing code.',
+ file: 'src/main/services/pr.service.ts',
+ line: 88,
+ category: 'style',
+ introducedInPR: false,
+ decision: 'defer',
+ justification: 'Pre-existing duplication on main; out of scope for this PR. File a follow-up to extract a shared backoff helper.',
+ },
+ ],
+ },
+ {
+ index: 2,
+ startedAt: new Date(baseTime + 95_000).toISOString(),
+ phase: 'triage',
+ costUsd: 0.58,
+ log: [
+ '[2026-04-30T13:01:35Z] Reviewing diff between session/add-pr-review and main…',
+ '[2026-04-30T13:02:03Z] Review found 2 candidate issues.',
+ '[2026-04-30T13:02:03Z] Triaging 2 issues (one sub-agent each)…',
+ ],
+ rawIssues: [
+ {
+ id: 'r2-1',
+ title: 'PR review thread loader leaks listener on unmount',
+ description: 'effect adds an event listener but the cleanup is keyed off the wrong dependency.',
+ file: 'src/renderer/components/pullrequests/PRReviewPanel.tsx',
+ line: 211,
+ category: 'bug',
+ },
+ {
+ id: 'r2-2',
+ title: 'Inline thread CSS class typo',
+ description: '`text-sucess` should be `text-success`; falls back to default colour.',
+ file: 'src/renderer/components/pullrequests/InlineThread.tsx',
+ line: 47,
+ category: 'style',
+ },
+ ],
+ triaged: [],
+ },
+ ],
+}
+
+/** A "completed" snapshot — converged after 2 clean rounds following round 1. */
+export const mockReviewLoopCompleted: ReviewLoopState = {
+ ...mockReviewLoopRunning,
+ status: 'completed',
+ currentPhase: 'idle',
+ iteration: 3,
+ cumulativeCostUsd: 1.64,
+ endedAt: new Date(baseTime + 240_000).toISOString(),
+ stopReason: 'converged',
+ rounds: [
+ // Reuse round 1 from the running mock (with its 2 fixes + 1 defer)…
+ mockReviewLoopRunning.rounds[0],
+ // …then two clean rounds that triggered the converged stop.
+ {
+ index: 2,
+ startedAt: new Date(baseTime + 95_000).toISOString(),
+ endedAt: new Date(baseTime + 165_000).toISOString(),
+ phase: 'idle',
+ costUsd: 0.41,
+ log: [
+ '[2026-04-30T13:01:35Z] Reviewing diff between session/add-pr-review and main…',
+ '[2026-04-30T13:02:08Z] Review found 0 candidate issues.',
+ '[2026-04-30T13:02:08Z] No issues to triage; skipping.',
+ '[2026-04-30T13:02:08Z] No fixable issues in this round (1 clean round so far).',
+ ],
+ rawIssues: [],
+ triaged: [],
+ },
+ {
+ index: 3,
+ startedAt: new Date(baseTime + 170_000).toISOString(),
+ endedAt: new Date(baseTime + 240_000).toISOString(),
+ phase: 'idle',
+ costUsd: 0.39,
+ log: [
+ '[2026-04-30T13:02:50Z] Reviewing diff between session/add-pr-review and main…',
+ '[2026-04-30T13:03:24Z] Review found 0 candidate issues.',
+ '[2026-04-30T13:03:24Z] No issues to triage; skipping.',
+ '[2026-04-30T13:03:24Z] No fixable issues in this round (2 clean rounds so far).',
+ ],
+ rawIssues: [],
+ triaged: [],
+ },
+ ],
+}
+
diff --git a/scripts/capture-screenshots.ts b/scripts/capture-screenshots.ts
index 588cc02..68f6546 100644
--- a/scripts/capture-screenshots.ts
+++ b/scripts/capture-screenshots.ts
@@ -145,6 +145,22 @@ const targets: ScreenshotTarget[] = [
delay: 2000,
scrollTo: 'Session Startup Prompts',
},
+ {
+ name: 'review-loop-running',
+ storyId: 'app-full-layout--review-loop-running',
+ delay: 2000,
+ },
+ {
+ name: 'review-loop-completed',
+ storyId: 'app-full-layout--review-loop-completed',
+ delay: 2000,
+ },
+ {
+ name: 'review-loop-settings',
+ storyId: 'app-full-layout--review-loop-settings',
+ delay: 2000,
+ scrollTo: 'Review Loop',
+ },
{
name: 'code-attention',
storyId: 'app-full-layout--code-attention',
@@ -275,7 +291,11 @@ const targets: ScreenshotTarget[] = [
async function captureScreenshots() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
- const browser = await chromium.launch()
+ const launchOptions: Parameters[0] = {}
+ if (process.env.CHROME_BIN) {
+ launchOptions.executablePath = process.env.CHROME_BIN
+ }
+ const browser = await chromium.launch(launchOptions)
// Capture main targets
for (const target of targets) {
diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts
index ded5b10..2245768 100644
--- a/src/main/ipc/register.ts
+++ b/src/main/ipc/register.ts
@@ -13,6 +13,7 @@ import { registerFileHandlers } from './file.ipc'
import { registerPermissionsHandlers } from './permissions.ipc'
import { registerButtonHandlers } from './button.ipc'
import { registerStartupPromptHandlers } from './startup-prompt.ipc'
+import { registerReviewLoopHandlers } from './review-loop.ipc'
import { registerClaudeWebHandlers } from './claudeWeb.ipc'
import {
registerContextMapping,
@@ -34,6 +35,7 @@ export function registerAllHandlers(window: BrowserWindow) {
registerPermissionsHandlers()
registerButtonHandlers(window)
registerStartupPromptHandlers()
+ registerReviewLoopHandlers(window)
registerClaudeWebHandlers()
// Context mapping management for notification routing.
diff --git a/src/main/ipc/review-loop.ipc.ts b/src/main/ipc/review-loop.ipc.ts
new file mode 100644
index 0000000..592d7f0
--- /dev/null
+++ b/src/main/ipc/review-loop.ipc.ts
@@ -0,0 +1,69 @@
+import { BrowserWindow, ipcMain } from 'electron'
+import Store from 'electron-store'
+import { IPC } from '../../shared/constants'
+import {
+ DEFAULT_REVIEW_LOOP_CONFIG,
+ type ReviewLoopConfig,
+ type ReviewLoopProjectOverride,
+ type ReviewLoopSettings,
+ type ReviewLoopState,
+} from '../../shared/types'
+import { getStorePath } from '../store-path'
+import {
+ cancelReviewLoop,
+ getReviewLoopState,
+ setReviewLoopWindow,
+ startReviewLoop,
+ type StartReviewLoopOptions,
+} from '../services/review-loop.service'
+
+interface PersistedShape {
+ workspace: ReviewLoopConfig
+ projectOverrides: Record
+}
+
+const store = new Store({
+ name: 'review-loop',
+ cwd: getStorePath(),
+ defaults: {
+ workspace: DEFAULT_REVIEW_LOOP_CONFIG,
+ projectOverrides: {},
+ },
+})
+
+export function registerReviewLoopHandlers(window: BrowserWindow): void {
+ setReviewLoopWindow(window)
+
+ ipcMain.handle(IPC.REVIEW_LOOP_SETTINGS_GET, async (): Promise => {
+ return {
+ workspace: { ...DEFAULT_REVIEW_LOOP_CONFIG, ...store.get('workspace') },
+ projectOverrides: store.get('projectOverrides', {}),
+ }
+ })
+
+ ipcMain.handle(
+ IPC.REVIEW_LOOP_SETTINGS_SET,
+ async (_e, settings: ReviewLoopSettings): Promise => {
+ store.set('workspace', { ...DEFAULT_REVIEW_LOOP_CONFIG, ...settings.workspace })
+ store.set('projectOverrides', settings.projectOverrides ?? {})
+ }
+ )
+
+ ipcMain.handle(
+ IPC.REVIEW_LOOP_START,
+ async (_e, opts: StartReviewLoopOptions): Promise => {
+ await startReviewLoop(opts)
+ }
+ )
+
+ ipcMain.handle(IPC.REVIEW_LOOP_CANCEL, async (_e, sessionId: string): Promise => {
+ cancelReviewLoop(sessionId)
+ })
+
+ ipcMain.handle(
+ IPC.REVIEW_LOOP_STATE_GET,
+ async (_e, sessionId: string): Promise => {
+ return getReviewLoopState(sessionId)
+ }
+ )
+}
diff --git a/src/main/services/review-loop.service.ts b/src/main/services/review-loop.service.ts
new file mode 100644
index 0000000..516b63d
--- /dev/null
+++ b/src/main/services/review-loop.service.ts
@@ -0,0 +1,796 @@
+/**
+ * Review Loop orchestrator.
+ *
+ * Drives a 3-phase cycle (review → triage → fix) by spawning headless `claude`
+ * subprocesses with curated prompts. Stops on convergence (N consecutive clean
+ * rounds), iteration cap, cost cap, or manual cancel. After the loop ends, any
+ * skipped/deferred issues are summarized in a sticky PR comment so reviewers
+ * can see what was knowingly left undone.
+ */
+import { spawn, ChildProcessWithoutNullStreams, execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import type { BrowserWindow } from 'electron'
+import { IPC } from '../../shared/constants'
+import {
+ DEFAULT_REVIEW_LOOP_CONFIG,
+ type ReviewLoopConfig,
+ type ReviewLoopIssue,
+ type ReviewLoopPhase,
+ type ReviewLoopRound,
+ type ReviewLoopState,
+ type ReviewLoopStopReason,
+ type ReviewLoopTriagedIssue,
+} from '../../shared/types'
+
+const execFileAsync = promisify(execFile)
+
+const STICKY_MARKER = ''
+
+// Wall-clock cap for a single claude phase. If a subprocess produces no exit
+// after this long it is killed and the phase fails so the loop self-heals
+// instead of wedging indefinitely.
+const PHASE_TIMEOUT_MS = 30 * 60 * 1000
+
+/**
+ * Kill a spawned claude child *and* its descendant sub-agents.
+ *
+ * The child is launched with `detached: true` on POSIX, which puts it in its
+ * own process group; signalling the negative pid signals the whole group so
+ * gh, sub-shells, and Task-tool sub-agents are torn down too. On Windows we
+ * fall back to the default tree-kill semantics of child.kill().
+ */
+function killChildTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals = 'SIGTERM'): void {
+ if (!child || child.killed || child.pid == null) return
+ try {
+ if (process.platform !== 'win32') {
+ process.kill(-child.pid, signal)
+ } else {
+ child.kill(signal)
+ }
+ } catch {
+ // Already exited.
+ }
+}
+
+interface ActiveLoop {
+ sessionId: string
+ state: ReviewLoopState
+ cancelled: boolean
+ child?: ChildProcessWithoutNullStreams
+ config: ReviewLoopConfig
+ prNumber?: number
+ loopDir: string
+}
+
+const activeLoops = new Map()
+let mainWindow: BrowserWindow | null = null
+
+export function setReviewLoopWindow(w: BrowserWindow): void {
+ mainWindow = w
+}
+
+export function getReviewLoopState(sessionId: string): ReviewLoopState | null {
+ return activeLoops.get(sessionId)?.state ?? null
+}
+
+export function isReviewLoopActive(sessionId: string): boolean {
+ return activeLoops.get(sessionId)?.state.status === 'running'
+}
+
+export function cancelReviewLoop(sessionId: string): void {
+ const loop = activeLoops.get(sessionId)
+ if (!loop || loop.state.status !== 'running') return
+ loop.cancelled = true
+ if (loop.child) killChildTree(loop.child)
+}
+
+export interface StartReviewLoopOptions {
+ sessionId: string
+ worktreePath: string
+ branch: string
+ baseBranch: string
+ config: ReviewLoopConfig
+ prNumber?: number
+}
+
+export async function startReviewLoop(opts: StartReviewLoopOptions): Promise {
+ if (activeLoops.get(opts.sessionId)?.state.status === 'running') {
+ throw new Error('Review loop is already running for this session')
+ }
+
+ const config = { ...DEFAULT_REVIEW_LOOP_CONFIG, ...opts.config }
+ const loopDir = join(opts.worktreePath, '.crucible', 'review-loop')
+ await mkdir(loopDir, { recursive: true })
+
+ const state: ReviewLoopState = {
+ sessionId: opts.sessionId,
+ branch: opts.branch,
+ baseBranch: opts.baseBranch,
+ worktreePath: opts.worktreePath,
+ status: 'running',
+ currentPhase: 'idle',
+ iteration: 0,
+ rounds: [],
+ cumulativeCostUsd: 0,
+ startedAt: new Date().toISOString(),
+ skippedIssues: [],
+ }
+
+ const loop: ActiveLoop = {
+ sessionId: opts.sessionId,
+ state,
+ cancelled: false,
+ config,
+ prNumber: opts.prNumber,
+ loopDir,
+ }
+ activeLoops.set(opts.sessionId, loop)
+ emitState(loop)
+
+ // Run loop async; surface any unexpected errors via state.
+ void runLoop(loop).catch((err: unknown) => {
+ finalize(loop, 'error', err instanceof Error ? err.message : String(err))
+ })
+}
+
+/** Drive the review/triage/fix cycle until a stop condition fires. */
+async function runLoop(loop: ActiveLoop): Promise {
+ let consecutiveClean = 0
+
+ // Re-check the cost cap between phases so a round that starts under the
+ // cap can't blow several dollars past it across review→triage→fix before
+ // the next loop iteration runs.
+ const costCapTripped = (): boolean =>
+ loop.state.cumulativeCostUsd >= loop.config.costCapUsd
+
+ while (true) {
+ if (loop.cancelled) return finalize(loop, 'cancelled')
+
+ if (loop.state.iteration >= loop.config.maxIterations) {
+ return finalize(loop, 'maxIterations')
+ }
+ if (costCapTripped()) {
+ return finalize(loop, 'costCap')
+ }
+
+ const round = startRound(loop)
+
+ const reviewOk = await runReviewPhase(loop, round)
+ if (!reviewOk) return // finalize already called by phase
+ if (loop.cancelled) return finalize(loop, 'cancelled')
+ if (costCapTripped()) return finalize(loop, 'costCap')
+
+ const triageOk = await runTriagePhase(loop, round)
+ if (!triageOk) return
+ if (loop.cancelled) return finalize(loop, 'cancelled')
+ if (costCapTripped()) return finalize(loop, 'costCap')
+
+ const actionable = round.triaged.filter((i) => i.decision === 'fix').length
+
+ if (actionable > 0) {
+ consecutiveClean = 0
+ const fixOk = await runFixPhase(loop, round)
+ if (!fixOk) return
+ if (loop.cancelled) return finalize(loop, 'cancelled')
+ if (costCapTripped()) return finalize(loop, 'costCap')
+ } else {
+ consecutiveClean += 1
+ pushLog(round, `No fixable issues in this round (${consecutiveClean} clean ${consecutiveClean === 1 ? 'round' : 'rounds'} so far).`)
+ }
+
+ // Track skipped/deferred for the sticky PR comment.
+ for (const t of round.triaged) {
+ if (t.decision === 'skip' || t.decision === 'defer') {
+ loop.state.skippedIssues.push(t)
+ }
+ }
+
+ round.endedAt = new Date().toISOString()
+ round.phase = 'idle'
+ emitState(loop)
+
+ if (consecutiveClean >= loop.config.consecutiveCleanRounds) {
+ return finalize(loop, 'converged')
+ }
+ }
+}
+
+function startRound(loop: ActiveLoop): ReviewLoopRound {
+ loop.state.iteration += 1
+ const round: ReviewLoopRound = {
+ index: loop.state.iteration,
+ startedAt: new Date().toISOString(),
+ phase: 'idle',
+ rawIssues: [],
+ triaged: [],
+ costUsd: 0,
+ log: [],
+ transcript: [],
+ }
+ loop.state.rounds.push(round)
+ emitState(loop)
+ return round
+}
+
+/* ── Phases ─────────────────────────────────────────────────────────────── */
+
+async function runReviewPhase(loop: ActiveLoop, round: ReviewLoopRound): Promise {
+ setPhase(loop, round, 'review')
+ pushLog(round, `Reviewing diff between ${loop.state.branch} and ${loop.state.baseBranch}…`)
+
+ const issuesPath = join(loop.loopDir, `round-${round.index}-issues.json`)
+
+ // Remove any stale issues file from a prior run before invoking claude so we
+ // can later assert that this run actually wrote it. Otherwise a model
+ // refusal / silent tool error / partial output produces an empty array that
+ // is indistinguishable from a clean review and drives a false 'converged'.
+ try {
+ await unlink(issuesPath)
+ } catch {
+ // Not present — fine.
+ }
+
+ const prompt = buildReviewPrompt({
+ branch: loop.state.branch,
+ baseBranch: loop.state.baseBranch,
+ issuesPath,
+ })
+
+ const result = await runClaude(loop, round, prompt)
+ round.costUsd += result.costUsd
+ loop.state.cumulativeCostUsd += result.costUsd
+
+ if (loop.cancelled) return false
+ if (!result.ok) {
+ round.errorMessage = result.error ?? 'review phase failed'
+ pushLog(round, `Review phase failed: ${round.errorMessage}`)
+ finalize(loop, 'error', round.errorMessage)
+ return false
+ }
+
+ // The claude run must have produced the issues file; an empty array is a
+ // legitimate outcome only when the file exists.
+ if (!existsSync(issuesPath)) {
+ round.errorMessage = 'review phase did not write issues file'
+ pushLog(round, `Review phase failed: ${round.errorMessage}`)
+ finalize(loop, 'error', round.errorMessage)
+ return false
+ }
+
+ const issues = await readJsonSafe(issuesPath, [])
+ round.rawIssues = Array.isArray(issues) ? issues : []
+ pushLog(round, `Review found ${round.rawIssues.length} candidate ${round.rawIssues.length === 1 ? 'issue' : 'issues'}.`)
+ emitState(loop)
+ return true
+}
+
+async function runTriagePhase(loop: ActiveLoop, round: ReviewLoopRound): Promise {
+ setPhase(loop, round, 'triage')
+
+ if (round.rawIssues.length === 0) {
+ pushLog(round, 'No issues to triage; skipping.')
+ return true
+ }
+
+ const issuesPath = join(loop.loopDir, `round-${round.index}-issues.json`)
+ const triagePath = join(loop.loopDir, `round-${round.index}-triage.json`)
+ const prompt = buildTriagePrompt({
+ branch: loop.state.branch,
+ baseBranch: loop.state.baseBranch,
+ issuesPath,
+ triagePath,
+ })
+
+ pushLog(round, `Triaging ${round.rawIssues.length} ${round.rawIssues.length === 1 ? 'issue' : 'issues'} (one sub-agent each)…`)
+ const result = await runClaude(loop, round, prompt)
+ round.costUsd += result.costUsd
+ loop.state.cumulativeCostUsd += result.costUsd
+
+ if (loop.cancelled) return false
+ if (!result.ok) {
+ round.errorMessage = result.error ?? 'triage phase failed'
+ pushLog(round, `Triage phase failed: ${round.errorMessage}`)
+ finalize(loop, 'error', round.errorMessage)
+ return false
+ }
+
+ const triaged = await readJsonSafe(triagePath, [])
+ round.triaged = Array.isArray(triaged) ? triaged : []
+ const fixCount = round.triaged.filter((t) => t.decision === 'fix').length
+ const skipCount = round.triaged.filter((t) => t.decision === 'skip' || t.decision === 'defer').length
+ pushLog(round, `Triage complete: ${fixCount} to fix, ${skipCount} skipped/deferred.`)
+ emitState(loop)
+ return true
+}
+
+async function runFixPhase(loop: ActiveLoop, round: ReviewLoopRound): Promise {
+ setPhase(loop, round, 'fix')
+ const triagePath = join(loop.loopDir, `round-${round.index}-triage.json`)
+
+ // Restrict the fix prompt to only the files flagged for fixing in this
+ // round's triage. The fix phase runs with --dangerously-skip-permissions
+ // and auto-pushes, so without this scope an over-eager run could ship
+ // collateral edits upstream.
+ const allowedFiles = Array.from(
+ new Set(
+ round.triaged
+ .filter((t) => t.decision === 'fix' && typeof t.file === 'string' && t.file.trim())
+ .map((t) => t.file as string)
+ )
+ )
+
+ const prompt = buildFixPrompt({
+ branch: loop.state.branch,
+ triagePath,
+ allowedFiles,
+ })
+
+ pushLog(round, 'Applying fixes, committing, and pushing…')
+ const result = await runClaude(loop, round, prompt)
+ round.costUsd += result.costUsd
+ loop.state.cumulativeCostUsd += result.costUsd
+
+ if (loop.cancelled) return false
+ if (!result.ok) {
+ round.errorMessage = result.error ?? 'fix phase failed'
+ pushLog(round, `Fix phase failed: ${round.errorMessage}`)
+ finalize(loop, 'error', round.errorMessage)
+ return false
+ }
+ pushLog(round, 'Fix phase complete.')
+ return true
+}
+
+/* ── Prompt builders ────────────────────────────────────────────────────── */
+
+function buildReviewPrompt(o: { branch: string; baseBranch: string; issuesPath: string }): string {
+ return `You are reviewing the diff between branch "${o.branch}" and its base "${o.baseBranch}".
+
+Run \`git diff ${o.baseBranch}...${o.branch}\` to inspect what changed. Focus on issues introduced in this branch: bugs, regressions, security problems, broken invariants, missed edge cases, and incorrect or misleading code.
+
+Write your findings as a JSON array to "${o.issuesPath}" using this schema:
+
+[
+ {
+ "id": "string-stable-id-per-issue",
+ "title": "short summary",
+ "description": "what's wrong and why it matters",
+ "file": "path/relative/to/repo",
+ "line": 42,
+ "category": "bug | security | regression | logic | style | doc | test"
+ }
+]
+
+Rules:
+- Only include real, concrete issues. Skip stylistic nits unless they materially harm readability.
+- One JSON array, valid JSON, no markdown wrapper. If there are no issues, write [].
+- Write the file before exiting.`
+}
+
+function buildTriagePrompt(o: {
+ branch: string
+ baseBranch: string
+ issuesPath: string
+ triagePath: string
+}): string {
+ return `Triage the candidate issues in "${o.issuesPath}" for the branch "${o.branch}" (base: "${o.baseBranch}").
+
+For EACH issue, spawn a sub-agent (Task tool) that:
+1. Inspects the relevant code in the worktree.
+2. Determines whether the issue was introduced by this branch (vs. pre-existing on ${o.baseBranch}).
+3. Decides one of:
+ - "fix" — should be fixed in this branch now
+ - "defer" — real issue, but out of scope for this branch (will be mentioned on the PR)
+ - "skip" — real issue or trade-off we are deliberately choosing not to fix in this branch
+ (e.g. accepted limitation, conscious design decision, scope cut). Will be mentioned on the PR.
+ - "noop" — NOT a real problem: false positive, duplicate, stale, pre-existing on the base branch,
+ or you cannot confirm it is real. These are NOT mentioned on the PR.
+4. Writes a short justification (1-3 sentences).
+
+Aggregate the sub-agent verdicts and write the result as a JSON array to "${o.triagePath}" using this schema:
+
+[
+ {
+ "id": "",
+ "title": "...",
+ "description": "...",
+ "file": "...",
+ "line": 42,
+ "category": "...",
+ "introducedInPR": true,
+ "decision": "fix" | "skip" | "defer" | "noop",
+ "justification": "..."
+ }
+]
+
+Rules:
+- Output a single valid JSON array, no markdown wrapper.
+- The PR comment will list ONLY "skip" and "defer" items — these are explicit decisions or recognised
+ trade-offs reviewers should be aware of. Do NOT use "skip"/"defer" for false positives or unconfirmed
+ issues; use "noop" instead so they are excluded from the PR comment.
+- If decision is "skip" or "defer", the justification MUST clearly explain the trade-off / why we are
+ consciously not fixing it (it will be posted on the PR).
+- Be conservative: if you cannot confirm an issue was introduced by this branch and is real, mark it
+ "noop" (not "skip"), with a brief justification.
+- Write the file before exiting.`
+}
+
+function buildFixPrompt(o: { branch: string; triagePath: string; allowedFiles: string[] }): string {
+ const fileList = o.allowedFiles.length > 0
+ ? o.allowedFiles.map((f) => ` - ${f}`).join('\n')
+ : ' (none — exit without changes)'
+ return `Apply the fixes listed in "${o.triagePath}" (only those with decision === "fix").
+
+You may ONLY modify files in this allowlist (the exact set the triage flagged for fixing this round):
+${fileList}
+
+If a fix cannot be applied without editing a file outside the allowlist, leave that fix unapplied and continue with the rest. Do NOT touch any other file (including .crucible/, lockfiles, settings, screenshots, or unrelated source). The single commit produced by this phase must contain only edits to allowlisted paths.
+
+For each fix:
+1. Make the necessary code changes in the worktree.
+2. Keep the diff minimal — do not refactor unrelated code.
+3. After all fixes are applied, stage everything, create ONE commit with a clear message summarizing the round (e.g. "review-loop: fix N issues from round X"), and push to origin/${o.branch}.
+
+If you cannot safely apply a fix, leave it untouched and continue with the rest. Do not delete or rewrite unrelated files. Do not amend previous commits.`
+}
+
+/* ── Claude subprocess runner ───────────────────────────────────────────── */
+
+interface ClaudeResult {
+ ok: boolean
+ costUsd: number
+ error?: string
+}
+
+/**
+ * Run claude in headless mode with the given prompt piped on stdin.
+ * Uses --output-format stream-json so each assistant message and tool call
+ * arrives as an NDJSON event; we parse them into human-readable transcript
+ * lines on the round so the UI can show live progress.
+ */
+function runClaude(loop: ActiveLoop, round: ReviewLoopRound, prompt: string): Promise {
+ return new Promise((resolve) => {
+ const MAX_BUF_BYTES = 5 * 1024 * 1024
+ let stderrBuf = ''
+ let lineBuf = ''
+ let costUsd = 0
+ let lastEmit = 0
+ let pendingEmit: NodeJS.Timeout | null = null
+ let bufferOverflow = false
+
+ const scheduleEmit = () => {
+ const now = Date.now()
+ const elapsed = now - lastEmit
+ if (elapsed >= 200) {
+ lastEmit = now
+ emitState(loop)
+ return
+ }
+ if (pendingEmit) return
+ pendingEmit = setTimeout(() => {
+ pendingEmit = null
+ lastEmit = Date.now()
+ emitState(loop)
+ }, 200 - elapsed)
+ }
+
+ const pushTranscript = (line: string) => {
+ const trimmed = line.replace(/\s+$/g, '')
+ if (!trimmed) return
+ round.transcript.push(`[${new Date().toISOString().slice(11, 19)}] ${trimmed}`)
+ scheduleEmit()
+ }
+
+ const handleEvent = (evt: any) => {
+ if (!evt || typeof evt !== 'object') return
+ switch (evt.type) {
+ case 'system':
+ if (evt.subtype === 'init') {
+ pushTranscript(`▶ session ${evt.session_id ?? ''} started${evt.model ? ` (${evt.model})` : ''}`)
+ }
+ break
+ case 'assistant': {
+ const content = evt.message?.content
+ if (!Array.isArray(content)) return
+ for (const block of content) {
+ if (block.type === 'text' && typeof block.text === 'string') {
+ for (const line of block.text.split('\n')) pushTranscript(line)
+ } else if (block.type === 'tool_use') {
+ const name = block.name ?? 'tool'
+ const summary = summarizeToolInput(block.input)
+ pushTranscript(`🔧 ${name}${summary ? ` ${summary}` : ''}`)
+ }
+ }
+ break
+ }
+ case 'user': {
+ const content = evt.message?.content
+ if (!Array.isArray(content)) return
+ for (const block of content) {
+ if (block.type === 'tool_result' && block.is_error) {
+ const text = typeof block.content === 'string'
+ ? block.content
+ : Array.isArray(block.content)
+ ? block.content.map((c: any) => c?.text ?? '').join(' ')
+ : ''
+ pushTranscript(`⚠ tool error: ${text.slice(0, 300)}`)
+ }
+ }
+ break
+ }
+ case 'result':
+ if (typeof evt.total_cost_usd === 'number') costUsd = evt.total_cost_usd
+ else if (typeof evt.cost === 'number') costUsd = evt.cost
+ break
+ }
+ }
+
+ const consumeStdout = (chunk: Buffer) => {
+ if (bufferOverflow) return
+ lineBuf += chunk.toString('utf-8')
+ let nlIdx: number
+ while ((nlIdx = lineBuf.indexOf('\n')) >= 0) {
+ const line = lineBuf.slice(0, nlIdx).trim()
+ lineBuf = lineBuf.slice(nlIdx + 1)
+ if (!line) continue
+ try {
+ handleEvent(JSON.parse(line))
+ } catch {
+ pushTranscript(line)
+ }
+ }
+ if (lineBuf.length > MAX_BUF_BYTES) {
+ bufferOverflow = true
+ lineBuf = ''
+ pushTranscript(`✖ stdout exceeded ${MAX_BUF_BYTES} bytes without a newline — terminating`)
+ killChildTree(child)
+ }
+ }
+
+ const child = spawn(
+ 'claude',
+ ['--print', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'],
+ {
+ cwd: loop.state.worktreePath,
+ env: { ...process.env },
+ // detached on POSIX puts the child in its own process group so we can
+ // signal the whole subtree (gh, sub-agents, sub-shells) on cancel/timeout.
+ detached: process.platform !== 'win32',
+ }
+ )
+ loop.child = child
+
+ // Per-phase wall-clock timeout. Kills the subtree and surfaces a phase
+ // error so the loop can finalize cleanly when the underlying CLI hangs.
+ let timedOut = false
+ const phaseTimer = setTimeout(() => {
+ timedOut = true
+ pushTranscript(`✖ phase timed out after ${Math.round(PHASE_TIMEOUT_MS / 60000)}m — terminating`)
+ killChildTree(child)
+ }, PHASE_TIMEOUT_MS)
+
+ child.stdout.on('data', consumeStdout)
+ child.stderr.on('data', (chunk: Buffer) => {
+ const text = chunk.toString('utf-8')
+ if (stderrBuf.length < MAX_BUF_BYTES) {
+ stderrBuf = (stderrBuf + text).slice(-MAX_BUF_BYTES)
+ }
+ for (const line of text.split('\n')) {
+ if (line.trim()) pushTranscript(`stderr: ${line.trim()}`)
+ }
+ })
+ child.on('error', (err) => {
+ pushTranscript(`✖ ${err.message}`)
+ clearTimeout(phaseTimer)
+ if (pendingEmit) clearTimeout(pendingEmit)
+ emitState(loop)
+ resolve({ ok: false, costUsd, error: err.message })
+ })
+ child.on('exit', (code, signal) => {
+ loop.child = undefined
+ clearTimeout(phaseTimer)
+
+ // Drain any trailing partial line.
+ if (lineBuf.trim()) {
+ try {
+ handleEvent(JSON.parse(lineBuf.trim()))
+ } catch {
+ pushTranscript(lineBuf.trim())
+ }
+ lineBuf = ''
+ }
+
+ if (pendingEmit) clearTimeout(pendingEmit)
+ emitState(loop)
+
+ if (signal === 'SIGTERM') {
+ const error = timedOut
+ ? `phase timed out after ${Math.round(PHASE_TIMEOUT_MS / 60000)}m`
+ : bufferOverflow
+ ? 'stdout buffer exceeded'
+ : 'cancelled'
+ resolve({ ok: false, costUsd, error })
+ return
+ }
+ if (code !== 0) {
+ resolve({
+ ok: false,
+ costUsd,
+ error: stderrBuf.trim() || `claude exited with code ${code}`,
+ })
+ return
+ }
+ resolve({ ok: true, costUsd })
+ })
+
+ child.stdin.write(prompt)
+ child.stdin.end()
+ })
+}
+
+function summarizeToolInput(input: unknown): string {
+ if (!input || typeof input !== 'object') return ''
+ const obj = input as Record
+ // Prefer common identifying fields for the user-facing summary.
+ for (const key of ['file_path', 'path', 'command', 'pattern', 'description', 'subagent_type']) {
+ const val = obj[key]
+ if (typeof val === 'string' && val.trim()) return truncate(val.trim(), 120)
+ }
+ try {
+ return truncate(JSON.stringify(obj), 120)
+ } catch {
+ return ''
+ }
+}
+
+function truncate(s: string, max: number): string {
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`
+}
+
+/* ── State helpers ──────────────────────────────────────────────────────── */
+
+function setPhase(loop: ActiveLoop, round: ReviewLoopRound, phase: ReviewLoopPhase): void {
+ round.phase = phase
+ loop.state.currentPhase = phase
+ emitState(loop)
+}
+
+function pushLog(round: ReviewLoopRound, line: string): void {
+ round.log.push(`[${new Date().toISOString()}] ${line}`)
+}
+
+function emitState(loop: ActiveLoop): void {
+ if (!mainWindow || mainWindow.isDestroyed()) return
+ mainWindow.webContents.send(IPC.REVIEW_LOOP_STATE_UPDATE, structuredClone(loop.state))
+}
+
+async function readJsonSafe(filePath: string, fallback: T): Promise {
+ try {
+ if (!existsSync(filePath)) return fallback
+ const raw = await readFile(filePath, 'utf-8')
+ return JSON.parse(raw) as T
+ } catch {
+ return fallback
+ }
+}
+
+/* ── Finalize + PR comment ──────────────────────────────────────────────── */
+
+function finalize(loop: ActiveLoop, reason: ReviewLoopStopReason, errorMessage?: string): void {
+ if (loop.state.status !== 'running') return
+
+ loop.state.status =
+ reason === 'cancelled' ? 'cancelled' :
+ reason === 'error' ? 'error' :
+ 'completed'
+ loop.state.stopReason = reason
+ loop.state.currentPhase = 'idle'
+ loop.state.endedAt = new Date().toISOString()
+ if (errorMessage) loop.state.errorMessage = errorMessage
+ emitState(loop)
+
+ // Drop the loop from the active-set so completed runs don't accumulate
+ // (rounds, transcripts, raw issues) in memory for the lifetime of the app.
+ // The renderer already received the final state via emitState above and
+ // caches it locally; refreshState handles a missing entry gracefully.
+ activeLoops.delete(loop.sessionId)
+
+ // Best-effort PR comment for skipped/deferred issues.
+ void writeStickyPRComment(loop).catch(() => {
+ // Non-fatal — already finalized.
+ })
+}
+
+async function writeStickyPRComment(loop: ActiveLoop): Promise {
+ if (!loop.prNumber) return
+ if (loop.state.skippedIssues.length === 0) return
+
+ const body = renderStickyComment(loop.state)
+ const repoPath = loop.state.worktreePath
+
+ // Find existing sticky comment.
+ let existingId: number | undefined
+ try {
+ const { stdout } = await execFileAsync(
+ 'gh',
+ ['api', `repos/{owner}/{repo}/issues/${loop.prNumber}/comments`, '--paginate'],
+ { cwd: repoPath, maxBuffer: 5 * 1024 * 1024 }
+ )
+ const parsed = JSON.parse(stdout) as Array<{ id: number; body: string }>
+ const sticky = parsed.find((c) => c.body.includes(STICKY_MARKER))
+ existingId = sticky?.id
+ } catch {
+ // Fallthrough — we'll post a new comment.
+ }
+
+ try {
+ if (existingId != null) {
+ await execFileAsync(
+ 'gh',
+ [
+ 'api',
+ '-X', 'PATCH',
+ `repos/{owner}/{repo}/issues/comments/${existingId}`,
+ '-f', `body=${body}`,
+ ],
+ { cwd: repoPath }
+ )
+ } else {
+ await execFileAsync(
+ 'gh',
+ [
+ 'pr', 'comment', String(loop.prNumber),
+ '--body', body,
+ ],
+ { cwd: repoPath }
+ )
+ }
+ } catch {
+ // Best-effort — don't crash the loop on comment failure.
+ }
+}
+
+function renderStickyComment(state: ReviewLoopState): string {
+ const lines: string[] = [
+ STICKY_MARKER,
+ '## Review Loop — issues left open',
+ '',
+ `_Generated by the Crucible Code review loop on branch \`${state.branch}\` (base: \`${state.baseBranch}\`)._`,
+ '',
+ ]
+
+ // De-dupe by id with a precedence rule: 'defer' (real but out of scope)
+ // outranks 'skip' (false positive / accepted), so a later 'skip' for the
+ // same id never overwrites an earlier 'defer'. For equal precedence the
+ // most recent occurrence wins so the latest justification surfaces.
+ const rank = (d: ReviewLoopTriagedIssue['decision']): number =>
+ d === 'defer' ? 2 : d === 'skip' ? 1 : 0
+ const byId = new Map()
+ for (const issue of state.skippedIssues) {
+ const existing = byId.get(issue.id)
+ if (!existing || rank(issue.decision) >= rank(existing.decision)) {
+ byId.set(issue.id, issue)
+ }
+ }
+ const skipped = [...byId.values()]
+
+ lines.push(
+ `The loop ran for ${state.iteration} ${state.iteration === 1 ? 'round' : 'rounds'} and chose not to fix the following ${skipped.length} ${skipped.length === 1 ? 'item' : 'items'}:`,
+ ''
+ )
+ for (const i of skipped) {
+ const loc = i.file ? ` — \`${i.file}${i.line ? `:${i.line}` : ''}\`` : ''
+ lines.push(`### ${i.decision === 'defer' ? '⏭ Deferred' : '↷ Skipped'}: ${i.title}${loc}`)
+ lines.push('')
+ lines.push(`**Reason:** ${i.justification || '_(none provided)_'}`)
+ if (i.description) {
+ lines.push('')
+ lines.push(`Original finding\n\n${i.description}\n\n`)
+ }
+ lines.push('')
+ }
+ return lines.join('\n')
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index c35f352..58e8ea5 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/constants'
-import type { Project, Session, Commit, FileDiff, PullRequest, PRFile, PRComment, PRReviewEvent, PRMergeMethod, UpdateStatus, Note, PRDetail, PRConversationComment, PRCheck, PRReviewThread, SessionUsage, UsageStats, SubscriptionInfo, FileEntry, FileStat, ClaudeAccount, CustomButton, CustomButtonGroup, ButtonActionType, ButtonExecutionMode, ContextKind, GitHubCollaborator, PRLabel, StartupPrompt, ClaudeWebSession } from '../shared/types'
+import type { Project, Session, Commit, FileDiff, PullRequest, PRFile, PRComment, PRReviewEvent, PRMergeMethod, UpdateStatus, Note, PRDetail, PRConversationComment, PRCheck, PRReviewThread, SessionUsage, UsageStats, SubscriptionInfo, FileEntry, FileStat, ClaudeAccount, CustomButton, CustomButtonGroup, ButtonActionType, ButtonExecutionMode, ContextKind, GitHubCollaborator, PRLabel, StartupPrompt, ReviewLoopConfig, ReviewLoopSettings, ReviewLoopState, ClaudeWebSession } from '../shared/types'
// Multiplex many subscribers through a single ipcRenderer listener per channel.
// Without this, each useTerminal/onData/onExit caller adds its own listener and
@@ -410,6 +410,30 @@ const api = {
ipcRenderer.invoke(IPC.STARTUP_PROMPT_SAVE, projectId, prompts),
},
+ reviewLoop: {
+ getSettings: (): Promise =>
+ ipcRenderer.invoke(IPC.REVIEW_LOOP_SETTINGS_GET),
+ setSettings: (settings: ReviewLoopSettings): Promise =>
+ ipcRenderer.invoke(IPC.REVIEW_LOOP_SETTINGS_SET, settings),
+ start: (opts: {
+ sessionId: string
+ worktreePath: string
+ branch: string
+ baseBranch: string
+ config: ReviewLoopConfig
+ prNumber?: number
+ }): Promise => ipcRenderer.invoke(IPC.REVIEW_LOOP_START, opts),
+ cancel: (sessionId: string): Promise =>
+ ipcRenderer.invoke(IPC.REVIEW_LOOP_CANCEL, sessionId),
+ getState: (sessionId: string): Promise =>
+ ipcRenderer.invoke(IPC.REVIEW_LOOP_STATE_GET, sessionId),
+ onStateUpdate: (callback: (state: ReviewLoopState) => void) => {
+ const listener = (_e: unknown, state: ReviewLoopState) => callback(state)
+ ipcRenderer.on(IPC.REVIEW_LOOP_STATE_UPDATE, listener)
+ return () => ipcRenderer.removeListener(IPC.REVIEW_LOOP_STATE_UPDATE, listener)
+ },
+ },
+
claudeWeb: {
listSessions: (
repoPath: string,
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 4bd1bfc..76d653e 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -23,6 +23,7 @@ import { useSettingsStore } from './stores/settingsStore'
import { LoadingScreen } from './components/LoadingScreen'
import { useButtonStore } from './stores/buttonStore'
import { useButtonShortcuts } from './hooks/useButtonShortcuts'
+import { useReviewLoopStore } from './stores/reviewLoopStore'
export default function App() {
const { loadProjects, loadAccounts, projects } = useProjectStore()
@@ -31,6 +32,8 @@ export default function App() {
const { handleHookEvent, registerSessions, clearContextStatuses } = useNotificationStore()
const { isOpen: settingsOpen } = useSettingsStore()
const { loadButtons, loadGroups } = useButtonStore()
+ const loadReviewLoopSettings = useReviewLoopStore((s) => s.loadSettings)
+ const applyReviewLoopState = useReviewLoopStore((s) => s.applyState)
useButtonShortcuts()
@@ -54,12 +57,26 @@ export default function App() {
}, [])
useEffect(() => {
- Promise.all([loadProjects(), loadAccounts(), loadButtons(), loadGroups()]).finally(() => {
+ Promise.all([
+ loadProjects(),
+ loadAccounts(),
+ loadButtons(),
+ loadGroups(),
+ loadReviewLoopSettings(),
+ ]).finally(() => {
setLoading(false)
// Unmount after fade-out transition (500ms)
setTimeout(() => setShowLoader(false), 520)
})
- }, [loadProjects, loadAccounts])
+ }, [loadProjects, loadAccounts, loadButtons, loadGroups, loadReviewLoopSettings])
+
+ // Stream review-loop progress events from the main process into the store.
+ useEffect(() => {
+ const remove = window.api.reviewLoop.onStateUpdate((state) => {
+ applyReviewLoopState(state)
+ })
+ return remove
+ }, [applyReviewLoopState])
// Register sessions from all projects with the notification store for cross-project badges
// and recover any terminals that were running before a crash/restart
diff --git a/src/renderer/components/buttons/CustomButtonBar.tsx b/src/renderer/components/buttons/CustomButtonBar.tsx
index 3d35c8a..78dcdb7 100644
--- a/src/renderer/components/buttons/CustomButtonBar.tsx
+++ b/src/renderer/components/buttons/CustomButtonBar.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import type { ButtonPlacement } from '../../../shared/types'
import { useButtonStore } from '../../stores/buttonStore'
import { useProjectStore } from '../../stores/projectStore'
+import { useReviewLoopStore } from '../../stores/reviewLoopStore'
import { CustomButtonRenderer, ButtonGroupRenderer } from './CustomButtonRenderer'
interface CustomButtonBarProps {
@@ -11,6 +12,8 @@ interface CustomButtonBarProps {
export function CustomButtonBar({ placement }: CustomButtonBarProps) {
const { activeProjectId } = useProjectStore()
const { getGroupedButtons } = useButtonStore()
+ // Subscribe to review-loop settings so per-project toggle updates re-render the bar.
+ useReviewLoopStore((s) => s.settings)
const { ungrouped, groups } = getGroupedButtons(placement, activeProjectId)
const hasContent = ungrouped.length > 0 || groups.length > 0
diff --git a/src/renderer/components/layout/SessionWorkspace.tsx b/src/renderer/components/layout/SessionWorkspace.tsx
index cd09114..fe3145d 100644
--- a/src/renderer/components/layout/SessionWorkspace.tsx
+++ b/src/renderer/components/layout/SessionWorkspace.tsx
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { GitPanel } from '../git/GitPanel'
import { TerminalPanel } from '../terminal/TerminalPanel'
import { ReviewTerminalPanel } from '../terminal/ReviewTerminalPanel'
+import { ReviewLoopPanel } from '../review-loop/ReviewLoopPanel'
import { DynamicTerminalPanel } from '../terminal/DynamicTerminalPanel'
import { PRReviewPanel } from '../pullrequests/PRReviewPanel'
import { ResizeHandle } from '../ui'
@@ -80,8 +81,8 @@ export function SessionWorkspace() {
resetLayout(['pr', 'review'], 'pr', contextId)
} else if (activeSessionId) {
const sessionTabs: WorkspaceTab[] = sessionPR
- ? ['agent', 'git', 'pr', 'review']
- : ['agent', 'git', 'review']
+ ? ['agent', 'git', 'pr', 'review', 'review-loop']
+ : ['agent', 'git', 'review', 'review-loop']
resetLayout(sessionTabs, 'agent', activeSessionId)
} else {
resetLayout([])
@@ -217,7 +218,7 @@ export function SessionWorkspace() {
// Stable portal target elements for core tabs — created once, never destroyed.
const corePanelTargets = useRef | null>(null)
if (!corePanelTargets.current) {
- const allCoreTabs: CoreTab[] = ['agent', 'git', 'pr', 'review']
+ const allCoreTabs: CoreTab[] = ['agent', 'git', 'pr', 'review', 'review-loop']
corePanelTargets.current = {} as Record
for (const tab of allCoreTabs) {
const div = document.createElement('div')
@@ -307,6 +308,7 @@ export function SessionWorkspace() {
// Compute visibility for terminal panels
const agentVisible = columns.some((c) => c.activeTab === 'agent')
const reviewVisible = columns.some((c) => c.activeTab === 'review')
+ const reviewLoopVisible = columns.some((c) => c.activeTab === 'review-loop')
const isPausedForMain = openedAsMainBranch != null && openedAsMainBranch === activeSessionId
const pausedSession = isPausedForMain
@@ -402,6 +404,7 @@ export function SessionWorkspace() {
{/* Core panel mounting — panels never unmount, just get portaled between columns */}
{createPortal(, corePanelTargets.current.agent)}
{createPortal(, corePanelTargets.current.review)}
+ {createPortal(, corePanelTargets.current['review-loop'])}
{createPortal(, corePanelTargets.current.git)}
{createPortal(, corePanelTargets.current.pr)}
{/* Dynamic panel mounting */}
diff --git a/src/renderer/components/layout/WorkspaceColumn.tsx b/src/renderer/components/layout/WorkspaceColumn.tsx
index 8556c2c..cd2a94d 100644
--- a/src/renderer/components/layout/WorkspaceColumn.tsx
+++ b/src/renderer/components/layout/WorkspaceColumn.tsx
@@ -60,6 +60,13 @@ export const ReviewIcon = () => (
)
+export const ReviewLoopIcon = () => (
+
+)
+
export const PlusIcon = () => (