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. + + + + + + + + + +
Review Loop tab while a loop is mid-triageReview Loop tab after the loop converged
Workspace defaults and per-project overrides for the review loop
+ +
+
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 = () => ( @@ -88,6 +95,7 @@ export function getTabIcon(tab: WorkspaceTab): React.ReactNode { if (tab === 'git') return if (tab === 'pr') return if (tab === 'review') return + if (tab === 'review-loop') return if (tab === 'code') return const base = getTabBaseType(tab) if (base === 'agent') return diff --git a/src/renderer/components/review-loop/ReviewLoopPanel.tsx b/src/renderer/components/review-loop/ReviewLoopPanel.tsx new file mode 100644 index 0000000..1dd4255 --- /dev/null +++ b/src/renderer/components/review-loop/ReviewLoopPanel.tsx @@ -0,0 +1,345 @@ +import React, { useEffect, useMemo, useRef } from 'react' +import { useReviewLoopStore } from '../../stores/reviewLoopStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useProjectStore } from '../../stores/projectStore' +import { usePRStore } from '../../stores/prStore' +import { Button } from '../ui/Button' +import type { + ReviewLoopRound, + ReviewLoopState, + ReviewLoopStopReason, + ReviewLoopTriagedIssue, +} from '../../../shared/types' + +interface Props { + visible?: boolean +} + +export function ReviewLoopPanel({ visible = true }: Props) { + const { activeSessionId, sessions } = useSessionStore() + const { projects, activeProjectId } = useProjectStore() + const { pullRequests } = usePRStore() + const states = useReviewLoopStore((s) => s.states) + const effectiveConfig = useReviewLoopStore((s) => s.effectiveConfig) + const start = useReviewLoopStore((s) => s.start) + const cancel = useReviewLoopStore((s) => s.cancel) + const refreshState = useReviewLoopStore((s) => s.refreshState) + + const session = sessions.find((s) => s.id === activeSessionId) + const project = projects.find((p) => p.id === activeProjectId) + const config = effectiveConfig(activeProjectId) + const state = activeSessionId ? states[activeSessionId] : undefined + + useEffect(() => { + if (activeSessionId) refreshState(activeSessionId) + }, [activeSessionId, refreshState]) + + if (!visible) return null + + if (!session || !project) { + return ( +
+ Select a session to use the review loop +
+ ) + } + + const sessionPR = pullRequests.find((pr) => pr.headRefName === session.branchName) + const baseBranch = session.baseBranch ?? sessionPR?.baseRefName ?? 'main' + + const isRunning = state?.status === 'running' + + const handleStart = () => { + void start({ + sessionId: session.id, + worktreePath: session.worktreePath, + branch: session.branchName, + baseBranch, + projectId: project.id, + prNumber: sessionPR?.number ?? session.prNumber, + }) + } + + const handleCancel = () => { + void cancel(session.id) + } + + return ( +
+ {/* Header */} +
+
+

Review Loop

+

+ {session.branchName}{baseBranch} + {' · '}max {config.maxIterations} rounds · stop after {config.consecutiveCleanRounds} clean · ${config.costCapUsd.toFixed(2)} cap +

+
+ {!config.enabled ? ( + Disabled for this project + ) : isRunning ? ( + + ) : ( + + )} +
+ + {/* Body */} +
+ {state ? : } +
+
+ ) +} + +function EmptyState() { + return ( +
+

Press Start review loop to begin. Each round runs three phases:

+
    +
  1. Review — Claude reviews the diff vs. base and writes findings.
  2. +
  3. Triage — A sub-agent investigates each finding and decides fix / skip / defer.
  4. +
  5. Fix — Claude applies fixes, commits, and pushes.
  6. +
+

+ The loop stops after consecutive clean rounds, the iteration cap, the cost cap, or manual cancel. + Skipped or deferred items get summarised in a sticky comment on the PR. +

+
+ ) +} + +function LoopStateView({ state }: { state: ReviewLoopState }) { + const reversed = [...state.rounds].reverse() + const latestIndex = reversed[0]?.index + return ( +
+ + {state.rounds.length === 0 && state.status === 'running' && ( +

Starting first round…

+ )} + {reversed.map((round) => ( + + ))} +
+ ) +} + +function SummaryBar({ state }: { state: ReviewLoopState }) { + const status = state.status + const phase = state.currentPhase + + const fixedTotal = useMemo( + () => + state.rounds.reduce( + (acc, r) => acc + r.triaged.filter((t) => t.decision === 'fix').length, + 0 + ), + [state.rounds] + ) + + return ( +
+
+ + + Round {state.iteration} · ${state.cumulativeCostUsd.toFixed(3)} spent · {fixedTotal} {fixedTotal === 1 ? 'fix' : 'fixes'} so far + + {state.stopReason && ( + + Stopped: {stopReasonLabel(state.stopReason)} + + )} + {state.errorMessage && ( + {state.errorMessage} + )} +
+ {state.skippedIssues.length > 0 && ( +

+ {state.skippedIssues.length} skipped/deferred {state.skippedIssues.length === 1 ? 'item' : 'items'} will be posted to the PR. +

+ )} +
+ ) +} + +function StatusPill({ + status, + phase, +}: { + status: ReviewLoopState['status'] + phase: ReviewLoopState['currentPhase'] +}) { + let color = 'text-text-muted bg-bg-tertiary' + let label: string = status + if (status === 'running') { + color = 'text-accent border border-accent/40 bg-accent/10' + label = `running · ${phase}` + } else if (status === 'completed') { + color = 'text-success border border-success/40 bg-success/10' + } else if (status === 'cancelled') { + color = 'text-warning border border-warning/40 bg-warning/10' + } else if (status === 'error') { + color = 'text-danger border border-danger/40 bg-danger/10' + } + return ( + + {label} + + ) +} + +function stopReasonLabel(r: ReviewLoopStopReason): string { + switch (r) { + case 'converged': return 'converged (consecutive clean rounds)' + case 'maxIterations': return 'iteration cap reached' + case 'costCap': return 'cost cap reached' + case 'cancelled': return 'cancelled' + case 'error': return 'error' + } +} + +function RoundCard({ + round, + isLatest, + loopRunning, +}: { + round: ReviewLoopRound + isLatest: boolean + loopRunning: boolean +}) { + const fixCount = round.triaged.filter((t) => t.decision === 'fix').length + const skipCount = round.triaged.filter( + (t) => t.decision === 'skip' || t.decision === 'defer' + ).length + const transcript = round.transcript ?? [] + const isActiveRound = isLatest && loopRunning + + return ( +
+
+

+ Round {round.index} · {round.phase} +

+ + {round.rawIssues.length} found · {fixCount} fixed · {skipCount} skipped · ${round.costUsd.toFixed(3)} + +
+ + {round.errorMessage && ( +

+ {round.errorMessage} +

+ )} + + {round.triaged.length > 0 && ( +
+ {round.triaged.map((t) => ( + + ))} +
+ )} + + {transcript.length > 0 && ( + + )} + + {round.log.length > 0 && ( +
+ Log +
+            {round.log.join('\n')}
+          
+
+ )} +
+ ) +} + +function LiveTranscript({ + lines, + defaultOpen, + autoScroll, + label, +}: { + lines: string[] + defaultOpen: boolean + autoScroll: boolean + label: string +}) { + const preRef = useRef(null) + + useEffect(() => { + if (!autoScroll) return + const el = preRef.current + if (!el) return + el.scrollTop = el.scrollHeight + }, [lines, autoScroll]) + + return ( +
+ {label} +
+        {lines.join('\n')}
+      
+
+ ) +} + +function IssueRow({ issue }: { issue: ReviewLoopTriagedIssue }) { + let decisionColor = 'text-text-muted' + if (issue.decision === 'fix') decisionColor = 'text-success' + else if (issue.decision === 'skip') decisionColor = 'text-warning' + else if (issue.decision === 'defer') decisionColor = 'text-accent' + + return ( +
+
+ + {issue.decision} + + {issue.title} + {issue.file && ( + + {issue.file}{issue.line ? `:${issue.line}` : ''} + + )} +
+ {issue.justification && ( +

+ {issue.justification} +

+ )} +
+ ) +} diff --git a/src/renderer/components/settings/ReviewLoopSettings.tsx b/src/renderer/components/settings/ReviewLoopSettings.tsx new file mode 100644 index 0000000..f6a1599 --- /dev/null +++ b/src/renderer/components/settings/ReviewLoopSettings.tsx @@ -0,0 +1,193 @@ +import React from 'react' +import type { Project, ReviewLoopConfig, ReviewLoopProjectOverride } from '../../../shared/types' +import { useReviewLoopStore } from '../../stores/reviewLoopStore' +import { ToggleGroup } from '../ui/ToggleGroup' +import { Button } from '../ui/Button' + +interface Props { + projects: Project[] +} + +export function ReviewLoopSettings({ projects }: Props) { + const settings = useReviewLoopStore((s) => s.settings) + const setWorkspaceConfig = useReviewLoopStore((s) => s.setWorkspaceConfig) + const setProjectOverride = useReviewLoopStore((s) => s.setProjectOverride) + const effectiveConfig = useReviewLoopStore((s) => s.effectiveConfig) + + return ( +
+

+ Review Loop +

+

+ Automate the review → triage → fix cycle on a branch. Stop conditions apply to the loop as a whole. +

+ + + + {projects.length > 0 && ( +
+ {projects.map((project) => { + const override = settings.projectOverrides[project.id] + const customized = override != null && Object.keys(override).length > 0 + const config = effectiveConfig(project.id) + return ( + setProjectOverride(project.id, undefined) : undefined + } + onChange={(next) => { + // Only persist deltas relative to workspace defaults; an + // override of the same value is still fine but we collapse + // when nothing differs. + const ws = settings.workspace + const delta: Partial = {} + if (next.enabled !== ws.enabled) delta.enabled = next.enabled + if (next.maxIterations !== ws.maxIterations) delta.maxIterations = next.maxIterations + if (next.consecutiveCleanRounds !== ws.consecutiveCleanRounds) delta.consecutiveCleanRounds = next.consecutiveCleanRounds + if (next.costCapUsd !== ws.costCapUsd) delta.costCapUsd = next.costCapUsd + setProjectOverride(project.id, Object.keys(delta).length === 0 ? undefined : delta) + }} + /> + ) + })} +
+ )} +
+ ) +} + +interface CardProps { + title: string + description: string + config: ReviewLoopConfig + onChange: (next: ReviewLoopConfig) => void + customized?: boolean + onReset?: () => void +} + +function ConfigCard({ title, description, config, onChange, customized, onReset }: CardProps) { + const update = (patch: Partial) => + onChange({ ...config, ...patch }) + + return ( +
+
+
+
+

{title}

+ {customized && ( + + Customized + + )} +
+

{description}

+
+ {onReset && ( + + )} +
+ +
+
+

Show review loop button

+

+ When off, the toolbar button is hidden and the loop won't run for this scope. +

+
+ update({ enabled: v === 'on' })} + /> +
+ +
+ update({ maxIterations: v })} + /> + update({ consecutiveCleanRounds: v })} + /> + update({ costCapUsd: v })} + /> +
+
+ ) +} + +interface NumberFieldProps { + label: string + hint: string + value: number + min: number + max: number + step?: number + onChange: (v: number) => void +} + +function NumberField({ label, hint, value, min, max, step = 1, onChange }: NumberFieldProps) { + return ( + + ) +} diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 1330d22..d9dd5fa 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import { Input } from '../ui/Input' import { ButtonSettings } from './ButtonSettings' import { PRListDisplaySettings } from './PRListDisplaySettings' import { StartupPromptSettings } from './StartupPromptSettings' +import { ReviewLoopSettings } from './ReviewLoopSettings' const LIGHT_THEMES = THEMES.filter((t) => !t.isDark) const DARK_THEMES = THEMES.filter((t) => t.isDark) @@ -810,6 +811,8 @@ export function SettingsPage() { + +
diff --git a/src/renderer/stores/appActions.ts b/src/renderer/stores/appActions.ts index 8efd5c4..68ffb9c 100644 --- a/src/renderer/stores/appActions.ts +++ b/src/renderer/stores/appActions.ts @@ -3,6 +3,9 @@ import { useSessionStore } from './sessionStore' import { useProjectStore } from './projectStore' import { useWorkspaceLayoutStore } from './workspaceLayoutStore' import { useSettingsStore } from './settingsStore' +import { useReviewLoopStore } from './reviewLoopStore' +import { usePRStore } from './prStore' +import { useToastStore } from './toastStore' export interface AppActionDef { id: AppAction @@ -270,6 +273,71 @@ export function getAppActions(): AppActionDef[] { window.dispatchEvent(new CustomEvent('app:toggle-panel', { detail: { panel: 'permissions' } })) }, }, + + // ── Review Loop ────────────────────────────────────────── + { + id: 'review-loop:start', + label: 'Start Review Loop', + group: 'Review Loop', + icon: 'RefreshCw', + validPlacements: ['session-toolbar'], + requiresActiveSession: true, + requiresActiveProject: true, + execute: async () => { + const { activeSessionId, sessions } = useSessionStore.getState() + const { projects, activeProjectId } = useProjectStore.getState() + const session = sessions.find((s) => s.id === activeSessionId) + const project = projects.find((p) => p.id === activeProjectId) + if (!session || !project) return + + const { pullRequests } = usePRStore.getState() + const sessionPR = pullRequests.find((pr) => pr.headRefName === session.branchName) + const baseBranch = session.baseBranch ?? sessionPR?.baseRefName ?? 'main' + + // Switch focus to the Review Loop tab so users see what's happening. + const { columns, setActiveTab } = useWorkspaceLayoutStore.getState() + const colWithTab = columns.find((c) => c.tabs.includes('review-loop')) + if (colWithTab) setActiveTab(colWithTab.id, 'review-loop') + + await useReviewLoopStore.getState().start({ + sessionId: session.id, + worktreePath: session.worktreePath, + branch: session.branchName, + baseBranch, + projectId: project.id, + prNumber: sessionPR?.number ?? session.prNumber, + }) + }, + }, + { + id: 'review-loop:cancel', + label: 'Cancel Review Loop', + group: 'Review Loop', + icon: 'X', + validPlacements: ['session-toolbar'], + requiresActiveSession: true, + execute: async () => { + const { activeSessionId } = useSessionStore.getState() + if (!activeSessionId) return + await useReviewLoopStore.getState().cancel(activeSessionId) + }, + }, + { + id: 'review-loop:toggle-tab', + label: 'Open Review Loop Tab', + group: 'Review Loop', + icon: 'RefreshCw', + validPlacements: ['session-toolbar'], + execute: () => { + const { columns, setActiveTab } = useWorkspaceLayoutStore.getState() + const col = columns.find((c) => c.tabs.includes('review-loop')) + if (col) { + setActiveTab(col.id, 'review-loop') + } else { + useToastStore.getState().addToast('info', 'Review Loop tab is only available with an active session') + } + }, + }, ] } diff --git a/src/renderer/stores/buttonStore.ts b/src/renderer/stores/buttonStore.ts index d1077a0..61fe387 100644 --- a/src/renderer/stores/buttonStore.ts +++ b/src/renderer/stores/buttonStore.ts @@ -9,6 +9,7 @@ import { useSessionStore } from './sessionStore' import { useProjectStore } from './projectStore' import { useTerminalStore } from './terminalStore' import { useWorkspaceLayoutStore } from './workspaceLayoutStore' +import { useReviewLoopStore } from './reviewLoopStore' import { getAppAction } from './appActions' interface ButtonRunState { @@ -64,6 +65,42 @@ function resolveTemplateVars( .replace(/\{\{projectName\}\}/g, context.projectName ?? '') } +/** + * Built-in buttons seeded into a fresh workspace. Identified by a stable id so + * we can avoid re-seeding if the user has deleted them. Each one is a regular + * CustomButton the user can edit/move/remove like any other. + */ +const BUILT_IN_REVIEW_LOOP_BUTTON_ID = 'built-in:review-loop:start' + +function seedBuiltInButtons(buttons: CustomButton[]): CustomButton[] { + // Already seeded (or user deleted) — leave alone. + if (buttons.some((b) => b.id === BUILT_IN_REVIEW_LOOP_BUTTON_ID)) return buttons + // Distinguish "fresh install" from "user removed it" by a sentinel flag in + // localStorage; once we've seeded once, don't bring it back. + const SEEDED_KEY = 'codecrucible.builtin-buttons.seeded' + if (typeof localStorage !== 'undefined' && localStorage.getItem(SEEDED_KEY) === '1') { + return buttons + } + + const reviewLoopButton: CustomButton = { + id: BUILT_IN_REVIEW_LOOP_BUTTON_ID, + label: 'Review Loop', + icon: 'RefreshCw', + placement: 'session-toolbar', + actionType: 'app-action', + executionMode: 'background', + command: 'review-loop:start', + scope: { type: 'global' }, + order: 1000, + } + + const next = [...buttons, reviewLoopButton] + if (typeof localStorage !== 'undefined') { + localStorage.setItem(SEEDED_KEY, '1') + } + return next +} + function matchesScope(button: { scope: CustomButton['scope'] }, projectId: string | null): boolean { if (button.scope.type === 'global') return true if (button.scope.type === 'all-projects') return projectId !== null @@ -73,18 +110,30 @@ function matchesScope(button: { scope: CustomButton['scope'] }, projectId: strin return false } +let loadButtonsInFlight: Promise | null = null + export const useButtonStore = create()((set, get) => ({ buttons: [], groups: [], runningButtons: {}, loadButtons: async () => { - try { - const buttons = await window.api.button.list() - set({ buttons }) - } catch (err: any) { - useToastStore.getState().addToast('error', err.message) - } + if (loadButtonsInFlight) return loadButtonsInFlight + loadButtonsInFlight = (async () => { + try { + const buttons = await window.api.button.list() + const seeded = seedBuiltInButtons(buttons) + if (seeded !== buttons) { + await window.api.button.save(seeded) + } + set({ buttons: seeded }) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message) + } finally { + loadButtonsInFlight = null + } + })() + return loadButtonsInFlight }, loadGroups: async () => { @@ -324,8 +373,15 @@ export const useButtonStore = create()((set, get) => ({ }, getButtonsForPlacement: (placement, projectId) => { + const reviewLoopEnabled = useReviewLoopStore.getState().effectiveConfig(projectId).enabled return get() - .buttons.filter((b) => b.placement === placement && matchesScope(b, projectId)) + .buttons.filter((b) => { + if (b.placement !== placement) return false + if (!matchesScope(b, projectId)) return false + // Hide built-in review-loop button when disabled for the active project. + if (b.id === BUILT_IN_REVIEW_LOOP_BUTTON_ID && !reviewLoopEnabled) return false + return true + }) .sort((a, b) => a.order - b.order) }, diff --git a/src/renderer/stores/reviewLoopStore.ts b/src/renderer/stores/reviewLoopStore.ts new file mode 100644 index 0000000..889d22c --- /dev/null +++ b/src/renderer/stores/reviewLoopStore.ts @@ -0,0 +1,153 @@ +import { create } from 'zustand' +import { + DEFAULT_REVIEW_LOOP_CONFIG, + type ReviewLoopConfig, + type ReviewLoopProjectOverride, + type ReviewLoopSettings, + type ReviewLoopState, +} from '../../shared/types' +import { useToastStore } from './toastStore' + +interface ReviewLoopStoreState { + settings: ReviewLoopSettings + loaded: boolean + /** Latest state per session id (only populated for sessions whose loop has been observed) */ + states: Record + + loadSettings: () => Promise + setWorkspaceConfig: (config: ReviewLoopConfig) => Promise + setProjectOverride: ( + projectId: string, + override: ReviewLoopProjectOverride | undefined + ) => Promise + /** Resolve effective config for a project, applying per-project overrides. */ + effectiveConfig: (projectId: string | null) => ReviewLoopConfig + /** Toggle the per-project enabled flag (persists). */ + setProjectEnabled: (projectId: string, enabled: boolean) => Promise + + start: (opts: { + sessionId: string + worktreePath: string + branch: string + baseBranch: string + projectId: string + prNumber?: number + }) => Promise + cancel: (sessionId: string) => Promise + refreshState: (sessionId: string) => Promise + applyState: (state: ReviewLoopState) => void +} + +const DEFAULT_SETTINGS: ReviewLoopSettings = { + workspace: DEFAULT_REVIEW_LOOP_CONFIG, + projectOverrides: {}, +} + +export const useReviewLoopStore = create()((set, get) => ({ + settings: DEFAULT_SETTINGS, + loaded: false, + states: {}, + + loadSettings: async () => { + try { + const settings = await window.api.reviewLoop.getSettings() + set({ settings, loaded: true }) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to load review loop settings') + set({ loaded: true }) + } + }, + + setWorkspaceConfig: async (config) => { + const next: ReviewLoopSettings = { ...get().settings, workspace: config } + set({ settings: next }) + try { + await window.api.reviewLoop.setSettings(next) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to save') + } + }, + + setProjectOverride: async (projectId, override) => { + const overrides = { ...get().settings.projectOverrides } + if (!override || Object.keys(override).length === 0) { + delete overrides[projectId] + } else { + overrides[projectId] = override + } + const next: ReviewLoopSettings = { ...get().settings, projectOverrides: overrides } + set({ settings: next }) + try { + await window.api.reviewLoop.setSettings(next) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to save') + } + }, + + effectiveConfig: (projectId) => { + const { workspace, projectOverrides } = get().settings + if (!projectId) return workspace + const override = projectOverrides[projectId] + if (!override) return workspace + return { + enabled: override.enabled ?? workspace.enabled, + maxIterations: override.maxIterations ?? workspace.maxIterations, + consecutiveCleanRounds: override.consecutiveCleanRounds ?? workspace.consecutiveCleanRounds, + costCapUsd: override.costCapUsd ?? workspace.costCapUsd, + } + }, + + setProjectEnabled: async (projectId, enabled) => { + const overrides = { ...get().settings.projectOverrides } + const existing = overrides[projectId] ?? {} + overrides[projectId] = { ...existing, enabled } + const next: ReviewLoopSettings = { ...get().settings, projectOverrides: overrides } + set({ settings: next }) + try { + await window.api.reviewLoop.setSettings(next) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to save') + } + }, + + start: async ({ sessionId, worktreePath, branch, baseBranch, projectId, prNumber }) => { + const config = get().effectiveConfig(projectId) + if (!config.enabled) { + useToastStore.getState().addToast('info', 'Review loop is disabled for this project') + return + } + try { + await window.api.reviewLoop.start({ + sessionId, + worktreePath, + branch, + baseBranch, + config, + prNumber, + }) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to start review loop') + } + }, + + cancel: async (sessionId) => { + try { + await window.api.reviewLoop.cancel(sessionId) + } catch (err: any) { + useToastStore.getState().addToast('error', err.message ?? 'Failed to cancel review loop') + } + }, + + refreshState: async (sessionId) => { + try { + const state = await window.api.reviewLoop.getState(sessionId) + if (state) get().applyState(state) + } catch { + // Silent — handled in UI when state is missing. + } + }, + + applyState: (state) => { + set((prev) => ({ states: { ...prev.states, [state.sessionId]: state } })) + }, +})) diff --git a/src/renderer/stores/workspaceLayoutStore.ts b/src/renderer/stores/workspaceLayoutStore.ts index 365932b..c1c2b24 100644 --- a/src/renderer/stores/workspaceLayoutStore.ts +++ b/src/renderer/stores/workspaceLayoutStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -export type CoreTab = 'agent' | 'git' | 'pr' | 'review' | 'code' +export type CoreTab = 'agent' | 'git' | 'pr' | 'review' | 'review-loop' | 'code' export type WorkspaceTab = CoreTab | `agent:${string}` | `terminal:${string}` /** Check if a tab is a dynamic (closable) instance */ @@ -21,6 +21,7 @@ export function getTabLabel(tab: WorkspaceTab): string { if (tab === 'git') return 'Worktree' if (tab === 'pr') return 'PR' if (tab === 'review') return 'Review' + if (tab === 'review-loop') return 'Review Loop' if (tab === 'code') return 'Code' if (tab.startsWith('agent:')) return `Agent ${tab.split(':')[1]}` if (tab.startsWith('terminal:')) return `Terminal ${tab.split(':')[1]}` diff --git a/src/renderer/stories/FullApp.stories.tsx b/src/renderer/stories/FullApp.stories.tsx index c2068ae..f827944 100644 --- a/src/renderer/stories/FullApp.stories.tsx +++ b/src/renderer/stories/FullApp.stories.tsx @@ -4,6 +4,7 @@ import App from '../App' import { setupStoresForStory, resetStores } from './helpers/storeSetup' import { useNotificationStore } from '../stores/notificationStore' import type { SessionStatus } from '../../shared/types' +import { mockReviewLoopRunning, mockReviewLoopCompleted } from '@mock/mockData' const meta: Meta = { title: 'App/Full Layout', @@ -296,6 +297,30 @@ export const StartupPromptEditor: Story = { ], } +export const ReviewLoopRunning: Story = { + decorators: [ + (Story) => { + setupStoresForStory({ + activeWorkspaceTab: 'review-loop', + reviewLoopState: mockReviewLoopRunning, + }) + return + }, + ], +} + +export const ReviewLoopCompleted: Story = { + decorators: [ + (Story) => { + setupStoresForStory({ + activeWorkspaceTab: 'review-loop', + reviewLoopState: mockReviewLoopCompleted, + }) + return + }, + ], +} + export const ClaudeWebSessions: Story = { decorators: [ (Story) => { @@ -305,6 +330,15 @@ export const ClaudeWebSessions: Story = { ], } +export const ReviewLoopSettings: Story = { + decorators: [ + (Story) => { + setupStoresForStory({ settingsOpen: true }) + return + }, + ], +} + export const ClaudeWebSettings: Story = { decorators: [ (Story) => { diff --git a/src/renderer/stories/helpers/storeSetup.ts b/src/renderer/stories/helpers/storeSetup.ts index ea27a98..0e49e7e 100644 --- a/src/renderer/stories/helpers/storeSetup.ts +++ b/src/renderer/stories/helpers/storeSetup.ts @@ -14,8 +14,9 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useWorkspaceLayoutStore, type WorkspaceTab } from '../../stores/workspaceLayoutStore' import { useButtonStore } from '../../stores/buttonStore' import { useStartupPromptStore } from '../../stores/startupPromptStore' +import { useReviewLoopStore } from '../../stores/reviewLoopStore' import { useClaudeWebStore } from '../../stores/claudeWebStore' -import type { SessionStatus } from '../../../shared/types' +import type { ReviewLoopState, SessionStatus } from '../../../shared/types' import { mockProjects, @@ -42,12 +43,14 @@ import { mockButtons, mockButtonGroups, mockStartupPrompts, + mockReviewLoopSettings, + mockReviewLoopRunning, } from '@mock/mockData' interface StorySetupOptions { activeProjectId?: string activeSessionId?: string - activeWorkspaceTab?: 'agent' | 'git' | 'pr' + activeWorkspaceTab?: 'agent' | 'git' | 'pr' | 'review-loop' activePRNumber?: number | null editorMode?: boolean /** Active tab inside the Code (editor) workspace. Defaults to 'code'. */ @@ -63,6 +66,8 @@ interface StorySetupOptions { openedAsMainBranch?: string | null /** Whether uncommitted changes were stashed when opening as main */ didStash?: boolean + /** Pre-populate the review-loop store for the active session */ + reviewLoopState?: ReviewLoopState } export function setupStoresForStory(options: StorySetupOptions = {}) { @@ -255,6 +260,25 @@ export function setupStoresForStory(options: StorySetupOptions = {}) { loadingProjects: new Set(), }) + // Review loop store — settings always seeded so per-project toggles render; + // optional state seeded for stories that show the running panel. Also pin + // the mockApi override so the panel's on-mount refreshState doesn't clobber + // the seeded value with the default running mock. + const reviewLoopStates: Record = {} + if (options.reviewLoopState) { + reviewLoopStates[options.reviewLoopState.sessionId] = options.reviewLoopState + if (typeof window !== 'undefined') { + (window as any).__mockReviewLoopState = options.reviewLoopState + } + } else if (typeof window !== 'undefined') { + delete (window as any).__mockReviewLoopState + } + useReviewLoopStore.setState({ + settings: mockReviewLoopSettings, + loaded: true, + states: reviewLoopStates, + }) + // Claude Web store — three remote claude/* branches authored by the current // user, sorted newest first. Mirrors what the IPC handler would return. useClaudeWebStore.setState({ @@ -294,7 +318,7 @@ export function setupStoresForStory(options: StorySetupOptions = {}) { const tabs: WorkspaceTab[] = options.workspaceTabs ?? ( options.activePRNumber ? ['pr', 'review'] - : ['agent', 'git', 'pr', 'review'] + : ['agent', 'git', 'pr', 'review', 'review-loop'] ) const savedLayout = [{ @@ -355,6 +379,11 @@ export function resetStores() { useSettingsStore.setState({ isOpen: false }) useButtonStore.setState({ buttons: [], groups: [], runningButtons: {} }) useStartupPromptStore.setState({ byProject: {}, loadingProjects: new Set() }) + useReviewLoopStore.setState({ + settings: mockReviewLoopSettings, + loaded: false, + states: {}, + }) useClaudeWebStore.setState({ sessions: [], loading: false }) useWorkspaceLayoutStore.setState({ columns: [], savedLayouts: {} }) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7fd6add..502e5be 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -160,6 +160,14 @@ export const IPC = { STARTUP_PROMPT_LIST: 'startup-prompt:list', STARTUP_PROMPT_SAVE: 'startup-prompt:save', + // Review Loop + REVIEW_LOOP_SETTINGS_GET: 'review-loop:settings:get', + REVIEW_LOOP_SETTINGS_SET: 'review-loop:settings:set', + REVIEW_LOOP_START: 'review-loop:start', + REVIEW_LOOP_CANCEL: 'review-loop:cancel', + REVIEW_LOOP_STATE_GET: 'review-loop:state:get', + REVIEW_LOOP_STATE_UPDATE: 'review-loop:state:update', + // Claude Web Sessions CLAUDE_WEB_LIST_SESSIONS: 'claude-web:list-sessions', } as const diff --git a/src/shared/types.ts b/src/shared/types.ts index 420fa78..d21c1d7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -260,6 +260,10 @@ export type AppAction = | 'app:toggle-notes' | 'app:toggle-usage' | 'app:toggle-permissions' + // Review loop + | 'review-loop:start' + | 'review-loop:cancel' + | 'review-loop:toggle-tab' export type ButtonExecutionMode = 'terminal' | 'background' export type ButtonScope = @@ -306,3 +310,101 @@ export interface StartupPrompt { inputPlaceholder?: string order: number } + +// Review Loop ──────────────────────────────────────────────────────────────── + +export type ReviewLoopPhase = + | 'idle' + | 'review' + | 'triage' + | 'fix' + | 'pr-update' + | 'cooldown' + +export type ReviewLoopStatus = + | 'idle' + | 'running' + | 'completed' + | 'cancelled' + | 'error' + +export type ReviewLoopStopReason = + | 'converged' + | 'maxIterations' + | 'costCap' + | 'cancelled' + | 'error' + +export type ReviewLoopDecision = 'fix' | 'skip' | 'defer' | 'noop' + +export interface ReviewLoopIssue { + id: string + title: string + description: string + file?: string + line?: number + category?: string +} + +export interface ReviewLoopTriagedIssue extends ReviewLoopIssue { + introducedInPR: boolean + decision: ReviewLoopDecision + justification: string +} + +export interface ReviewLoopRound { + index: number + startedAt: string + endedAt?: string + phase: ReviewLoopPhase + rawIssues: ReviewLoopIssue[] + triaged: ReviewLoopTriagedIssue[] + costUsd: number + log: string[] + /** Live human-readable lines from each claude subprocess (assistant text, tool calls, errors). */ + transcript: string[] + errorMessage?: string +} + +export interface ReviewLoopState { + sessionId: string + branch: string + baseBranch: string + worktreePath: string + status: ReviewLoopStatus + currentPhase: ReviewLoopPhase + iteration: number + rounds: ReviewLoopRound[] + cumulativeCostUsd: number + startedAt?: string + endedAt?: string + stopReason?: ReviewLoopStopReason + errorMessage?: string + skippedIssues: ReviewLoopTriagedIssue[] +} + +export interface ReviewLoopConfig { + enabled: boolean + maxIterations: number + consecutiveCleanRounds: number + costCapUsd: number +} + +export interface ReviewLoopProjectOverride { + enabled?: boolean + maxIterations?: number + consecutiveCleanRounds?: number + costCapUsd?: number +} + +export interface ReviewLoopSettings { + workspace: ReviewLoopConfig + projectOverrides: Record +} + +export const DEFAULT_REVIEW_LOOP_CONFIG: ReviewLoopConfig = { + enabled: true, + maxIterations: 5, + consecutiveCleanRounds: 2, + costCapUsd: 5, +} diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 6db56e3..ed42e34 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -143,8 +143,14 @@ test.describe('Settings page', () => { test('Match System toggle reveals the Light/Dark theme selectors', async ({ page }) => { await page.locator('button[title="Settings"]').click() - // ToggleGroup uses role="radio" for its options - await page.getByRole('radio', { name: 'On', exact: true }).click() + // Other settings sections (e.g. Review Loop) also render On/Off ToggleGroups, + // so scope the click to the row that holds the Match System toggle by walking + // up from the unique label to its sibling toggle. + const matchSystemRow = page + .getByText('Match System', { exact: true }) + .locator('..') + .locator('..') + await matchSystemRow.getByRole('radio', { name: 'On', exact: true }).click() await expect(page.getByText('Light theme', { exact: true })).toBeVisible() await expect(page.getByText('Dark theme', { exact: true })).toBeVisible() }) diff --git a/tests/unit/stores/buttonStore.test.ts b/tests/unit/stores/buttonStore.test.ts index 9e14879..99595dd 100644 --- a/tests/unit/stores/buttonStore.test.ts +++ b/tests/unit/stores/buttonStore.test.ts @@ -21,6 +21,24 @@ beforeEach(() => { ;(window as any).api = { button: buttonApi, terminal: terminalApi } useButtonStore.setState({ buttons: [], groups: [], runningButtons: {} } as any) useToastStore.setState({ toasts: [] }) + // loadButtons seeds built-in buttons (e.g. Review Loop) on first run; mark + // the workspace as already seeded so these tests assert against the API + // result alone, not the merged seed list. Use a try/catch since some test + // envs stub localStorage without a real setItem. + try { + globalThis.localStorage?.setItem('codecrucible.builtin-buttons.seeded', '1') + } catch { + // jsdom localStorage missing — fall back to a stub for this test. + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (k: string) => (k === 'codecrucible.builtin-buttons.seeded' ? '1' : null), + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }, + }) + } }) const B = (overrides: Partial<{ id: string; placement: string; order: number; groupId?: string; scope: any }> = {}) => ({