diff --git a/mock/mockData.ts b/mock/mockData.ts index b34f84b..89da832 100644 --- a/mock/mockData.ts +++ b/mock/mockData.ts @@ -652,6 +652,7 @@ export const mockReviewLoopRunning: ReviewLoopState = { branch: 'session/add-pr-review', baseBranch: 'main', worktreePath: '/Users/dev/.codecrucible-worktrees/CodeCrucible/add-pr-review', + variant: 'pro', status: 'running', currentPhase: 'triage', iteration: 2, diff --git a/src/main/ipc/review-loop.ipc.ts b/src/main/ipc/review-loop.ipc.ts index 592d7f0..db9ab42 100644 --- a/src/main/ipc/review-loop.ipc.ts +++ b/src/main/ipc/review-loop.ipc.ts @@ -16,6 +16,13 @@ import { startReviewLoop, type StartReviewLoopOptions, } from '../services/review-loop.service' +import { + cancelReviewLoopLite, + getReviewLoopLiteState, + hasReviewLoopLite, + setReviewLoopLiteWindow, + startReviewLoopLite, +} from '../services/review-loop-lite.service' interface PersistedShape { workspace: ReviewLoopConfig @@ -33,6 +40,7 @@ const store = new Store({ export function registerReviewLoopHandlers(window: BrowserWindow): void { setReviewLoopWindow(window) + setReviewLoopLiteWindow(window) ipcMain.handle(IPC.REVIEW_LOOP_SETTINGS_GET, async (): Promise => { return { @@ -52,18 +60,24 @@ export function registerReviewLoopHandlers(window: BrowserWindow): void { ipcMain.handle( IPC.REVIEW_LOOP_START, async (_e, opts: StartReviewLoopOptions): Promise => { - await startReviewLoop(opts) + if (opts.config?.variant === 'pro') { + await startReviewLoop(opts) + } else { + await startReviewLoopLite(opts) + } } ) ipcMain.handle(IPC.REVIEW_LOOP_CANCEL, async (_e, sessionId: string): Promise => { - cancelReviewLoop(sessionId) + // Cancel on whichever variant is running for this session. + if (hasReviewLoopLite(sessionId)) cancelReviewLoopLite(sessionId) + else cancelReviewLoop(sessionId) }) ipcMain.handle( IPC.REVIEW_LOOP_STATE_GET, async (_e, sessionId: string): Promise => { - return getReviewLoopState(sessionId) + return getReviewLoopLiteState(sessionId) ?? getReviewLoopState(sessionId) } ) } diff --git a/src/main/services/review-loop-lite.service.ts b/src/main/services/review-loop-lite.service.ts new file mode 100644 index 0000000..a96b0e7 --- /dev/null +++ b/src/main/services/review-loop-lite.service.ts @@ -0,0 +1,582 @@ +/** + * Review Loop — Lite variant. + * + * A lighter, unstructured cousin of review-loop.service.ts. No JSON + * intermediates, no sticky PR comment, no structured issue list. The UI only + * ever shows the raw session transcript. + * + * Per round: + * 1. review : `claude --print` with `/review ` (or `/review` + diff) + * 2. triage : `claude --print` with the review output dumped in, asking for + * a sub-agent investigation per issue. Captures session_id. + * 3. fix : `claude --print --resume ` with "do what you think + * needs doing, commit, and push" — same context as triage. + * + * Round-level convergence: if the fix turn produces no new commit on HEAD, + * count that as a "clean" round. After N consecutive clean rounds, stop. + * + * Safety net: snapshot HEAD + dirty paths at loop start; after each fix turn, + * if the worktree contains uncommitted changes that weren't there at start, + * make a trailing commit so nothing is left behind. + */ +import { spawn, ChildProcessWithoutNullStreams, execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { BrowserWindow } from 'electron' +import { IPC } from '../../shared/constants' +import { + DEFAULT_REVIEW_LOOP_CONFIG, + type ReviewLoopConfig, + type ReviewLoopPhase, + type ReviewLoopRound, + type ReviewLoopState, + type ReviewLoopStopReason, +} from '../../shared/types' + +const execFileAsync = promisify(execFile) + +const PHASE_TIMEOUT_MS = 30 * 60 * 1000 + +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 + /** HEAD sha at loop start — the baseline for "did this round produce a commit". */ + startSha: string +} + +const activeLoops = new Map() +let mainWindow: BrowserWindow | null = null + +export function setReviewLoopLiteWindow(w: BrowserWindow): void { + mainWindow = w +} + +export function hasReviewLoopLite(sessionId: string): boolean { + return activeLoops.has(sessionId) +} + +export function getReviewLoopLiteState(sessionId: string): ReviewLoopState | null { + return activeLoops.get(sessionId)?.state ?? null +} + +export function cancelReviewLoopLite(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 StartReviewLoopLiteOptions { + sessionId: string + worktreePath: string + branch: string + baseBranch: string + config: ReviewLoopConfig + prNumber?: number +} + +export async function startReviewLoopLite(opts: StartReviewLoopLiteOptions): 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 startSha = await readHeadSha(opts.worktreePath) + + const state: ReviewLoopState = { + sessionId: opts.sessionId, + branch: opts.branch, + baseBranch: opts.baseBranch, + worktreePath: opts.worktreePath, + variant: 'lite', + 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, + startSha, + } + activeLoops.set(opts.sessionId, loop) + emitState(loop) + + void runLoop(loop).catch((err: unknown) => { + finalize(loop, 'error', err instanceof Error ? err.message : String(err)) + }) +} + +async function runLoop(loop: ActiveLoop): Promise { + let consecutiveClean = 0 + let priorHead = loop.startSha + + 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) + + // ── Review ── + const reviewOk = await runReviewPhase(loop, round) + if (!reviewOk) return + if (loop.cancelled) return finalize(loop, 'cancelled') + if (costCapTripped()) return finalize(loop, 'costCap') + + // ── Triage (captures session id for fix to resume) ── + const triageResult = await runTriagePhase(loop, round) + if (!triageResult.ok) return + if (loop.cancelled) return finalize(loop, 'cancelled') + if (costCapTripped()) return finalize(loop, 'costCap') + + // ── Fix (resumes triage session) ── + if (triageResult.sessionId) { + const fixOk = await runFixPhase(loop, round, triageResult.sessionId) + if (!fixOk) return + if (loop.cancelled) return finalize(loop, 'cancelled') + if (costCapTripped()) return finalize(loop, 'costCap') + } else { + pushLog(round, 'Skipping fix phase: no session id captured from triage.') + } + + // ── Safety net: commit any uncommitted changes left over ── + await trailingCommitIfDirty(loop, round) + + // ── Convergence check ── + const newHead = await readHeadSha(loop.state.worktreePath).catch(() => priorHead) + if (newHead === priorHead) { + consecutiveClean += 1 + pushLog(round, `No new commit this round (${consecutiveClean} clean ${consecutiveClean === 1 ? 'round' : 'rounds'} so far).`) + } else { + consecutiveClean = 0 + priorHead = newHead + } + + 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') + + let prompt: string + if (loop.prNumber) { + pushLog(round, `Running /review ${loop.prNumber}…`) + prompt = `/review ${loop.prNumber}` + } else { + pushLog(round, `Running /review on diff ${loop.state.baseBranch}...${loop.state.branch}…`) + const diff = await readDiff(loop.state.worktreePath, loop.state.baseBranch, loop.state.branch) + prompt = `/review\n\nHere is the diff between this branch and its base (${loop.state.baseBranch}...${loop.state.branch}):\n\n\`\`\`diff\n${diff}\n\`\`\`` + } + + const result = await runClaude(loop, round, prompt, undefined) + 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 + } + return true +} + +interface TriageResult { ok: boolean; sessionId?: string } + +async function runTriagePhase(loop: ActiveLoop, round: ReviewLoopRound): Promise { + setPhase(loop, round, 'triage') + pushLog(round, 'Triaging review output…') + + const reviewTranscript = round.transcript.join('\n') + + const prompt = `Below is the output from a code review I just ran on branch "${loop.state.branch}" (base: "${loop.state.baseBranch}"): + + +${reviewTranscript} + + +For each of the issues listed above, use a sub-agent (Task tool) to investigate it. For each one: + +- Check that the issue was introduced on this branch (not pre-existing on ${loop.state.baseBranch}). +- Check that the issue is real — not a false positive, misunderstanding, or stale finding. +- Decide what we should do, if anything. Options include fixing it now, deferring it, skipping it deliberately, or dismissing it as not a real problem. +- Explain what you'd do and why. + +Present your findings to me as a markdown table with columns: Issue · Real? · Introduced here? · Decision · Reason. + +Do not make any changes yet. Just show me your triaged issues.` + + const result = await runClaude(loop, round, prompt, undefined) + round.costUsd += result.costUsd + loop.state.cumulativeCostUsd += result.costUsd + + if (loop.cancelled) return { ok: false } + if (!result.ok) { + round.errorMessage = result.error ?? 'triage phase failed' + pushLog(round, `Triage phase failed: ${round.errorMessage}`) + finalize(loop, 'error', round.errorMessage) + return { ok: false } + } + return { ok: true, sessionId: result.sessionId } +} + +async function runFixPhase(loop: ActiveLoop, round: ReviewLoopRound, resumeId: string): Promise { + setPhase(loop, round, 'fix') + pushLog(round, `Applying fixes (resuming session ${resumeId.slice(0, 8)}…)…`) + + const prompt = `Now do what you think needs doing based on the triage above. Apply the fixes you decided on, commit the result with a clear message, and push to origin/${loop.state.branch}.` + + const result = await runClaude(loop, round, prompt, resumeId) + 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 + } + return true +} + +/* ── Safety net + git helpers ───────────────────────────────────────────── */ + +async function trailingCommitIfDirty(loop: ActiveLoop, round: ReviewLoopRound): Promise { + try { + const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: loop.state.worktreePath }) + if (!stdout.trim()) return + pushLog(round, 'Worktree has uncommitted changes after fix phase — making a trailing commit.') + await execFileAsync('git', ['add', '-A'], { cwd: loop.state.worktreePath }) + await execFileAsync( + 'git', + ['commit', '-m', `review-loop: trailing changes from round ${round.index}`], + { cwd: loop.state.worktreePath } + ) + try { + await execFileAsync('git', ['push', 'origin', loop.state.branch], { cwd: loop.state.worktreePath }) + } catch (err) { + pushLog(round, `Trailing push failed: ${err instanceof Error ? err.message : String(err)}`) + } + } catch (err) { + pushLog(round, `Trailing commit check failed: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function readHeadSha(cwd: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd }) + return stdout.trim() + } catch { + return '' + } +} + +async function readDiff(cwd: string, base: string, branch: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['diff', `${base}...${branch}`], + { cwd, maxBuffer: 25 * 1024 * 1024 } + ) + return stdout + } catch (err) { + return `(failed to read diff: ${err instanceof Error ? err.message : String(err)})` + } +} + +/* ── Claude subprocess runner ───────────────────────────────────────────── */ + +interface ClaudeResult { + ok: boolean + costUsd: number + sessionId?: string + error?: string +} + +function runClaude( + loop: ActiveLoop, + round: ReviewLoopRound, + prompt: string, + resumeId: string | undefined +): Promise { + return new Promise((resolve) => { + const MAX_BUF_BYTES = 5 * 1024 * 1024 + let stderrBuf = '' + let lineBuf = '' + let costUsd = 0 + let sessionId: string | undefined + 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') { + if (typeof evt.session_id === 'string') sessionId = evt.session_id + 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 args = ['--print', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'] + if (resumeId) args.push('--resume', resumeId) + + const child = spawn('claude', args, { + cwd: loop.state.worktreePath, + env: { ...process.env }, + detached: process.platform !== 'win32', + }) + loop.child = child + + 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) + + 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, sessionId, error }) + return + } + if (code !== 0) { + resolve({ + ok: false, + costUsd, + sessionId, + error: stderrBuf.trim() || `claude exited with code ${code}`, + }) + return + } + resolve({ ok: true, costUsd, sessionId }) + }) + + child.stdin.write(prompt) + child.stdin.end() + }) +} + +function summarizeToolInput(input: unknown): string { + if (!input || typeof input !== 'object') return '' + const obj = input as Record + 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)) +} + +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) + + activeLoops.delete(loop.sessionId) +} diff --git a/src/main/services/review-loop.service.ts b/src/main/services/review-loop.service.ts index 516b63d..d414f76 100644 --- a/src/main/services/review-loop.service.ts +++ b/src/main/services/review-loop.service.ts @@ -110,6 +110,7 @@ export async function startReviewLoop(opts: StartReviewLoopOptions): Promise - {state ? : } + {state ? : } ) } -function EmptyState() { +function EmptyState({ variant }: { variant: 'lite' | 'pro' }) { + if (variant === 'lite') { + return ( +
+

Press Start review loop to begin. Each round runs:

+
    +
  1. Review/review on the PR (or the diff vs. base).
  2. +
  3. Triage — sub-agents investigate each issue and propose a decision as a table.
  4. +
  5. Fix — same session as triage: "do what you think needs doing, commit, and push".
  6. +
+

+ The UI shows the raw session output. Stops after consecutive rounds with no new commit, the iteration cap, the cost cap, or manual cancel. +

+
+ ) + } return (

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

@@ -119,6 +134,7 @@ function EmptyState() { function LoopStateView({ state }: { state: ReviewLoopState }) { const reversed = [...state.rounds].reverse() const latestIndex = reversed[0]?.index + const variant = state.variant ?? 'pro' return (
@@ -129,6 +145,7 @@ function LoopStateView({ state }: { state: ReviewLoopState }) { @@ -140,6 +157,7 @@ function LoopStateView({ state }: { state: ReviewLoopState }) { function SummaryBar({ state }: { state: ReviewLoopState }) { const status = state.status const phase = state.currentPhase + const variant = state.variant ?? 'pro' const fixedTotal = useMemo( () => @@ -154,8 +172,11 @@ function SummaryBar({ state }: { state: ReviewLoopState }) {
+ + {variant} + - Round {state.iteration} · ${state.cumulativeCostUsd.toFixed(3)} spent · {fixedTotal} {fixedTotal === 1 ? 'fix' : 'fixes'} so far + Round {state.iteration} · ${state.cumulativeCostUsd.toFixed(3)} spent{variant === 'pro' ? ` · ${fixedTotal} ${fixedTotal === 1 ? 'fix' : 'fixes'} so far` : ''} {state.stopReason && ( @@ -166,7 +187,7 @@ function SummaryBar({ state }: { state: ReviewLoopState }) { {state.errorMessage} )}
- {state.skippedIssues.length > 0 && ( + {variant === 'pro' && state.skippedIssues.length > 0 && (

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

@@ -216,10 +237,12 @@ function stopReasonLabel(r: ReviewLoopStopReason): string { function RoundCard({ round, + variant, isLatest, loopRunning, }: { round: ReviewLoopRound + variant: 'lite' | 'pro' isLatest: boolean loopRunning: boolean }) { @@ -237,7 +260,9 @@ function RoundCard({ Round {round.index} · {round.phase}

- {round.rawIssues.length} found · {fixCount} fixed · {skipCount} skipped · ${round.costUsd.toFixed(3)} + {variant === 'pro' + ? `${round.rawIssues.length} found · ${fixCount} fixed · ${skipCount} skipped · $${round.costUsd.toFixed(3)}` + : `$${round.costUsd.toFixed(3)}`}
@@ -247,7 +272,7 @@ function RoundCard({

)} - {round.triaged.length > 0 && ( + {variant === 'pro' && round.triaged.length > 0 && (
{round.triaged.map((t) => ( diff --git a/src/renderer/components/settings/ReviewLoopSettings.tsx b/src/renderer/components/settings/ReviewLoopSettings.tsx index f6a1599..5d6f98e 100644 --- a/src/renderer/components/settings/ReviewLoopSettings.tsx +++ b/src/renderer/components/settings/ReviewLoopSettings.tsx @@ -53,6 +53,7 @@ export function ReviewLoopSettings({ projects }: Props) { const ws = settings.workspace const delta: Partial = {} if (next.enabled !== ws.enabled) delta.enabled = next.enabled + if (next.variant !== ws.variant) delta.variant = next.variant 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 @@ -124,6 +125,25 @@ function ConfigCard({ title, description, config, onChange, customized, onReset />
+
+
+

Variant

+

+ Lite — unstructured: /review → triage table → "do what you think". UI shows raw session output.
+ Pro — structured 3-phase pipeline with JSON intermediates, issue list, and sticky PR comment. +

+
+ update({ variant: v as 'lite' | 'pro' })} + /> +
+
()((set, get) => ( if (!override) return workspace return { enabled: override.enabled ?? workspace.enabled, + variant: override.variant ?? workspace.variant, maxIterations: override.maxIterations ?? workspace.maxIterations, consecutiveCleanRounds: override.consecutiveCleanRounds ?? workspace.consecutiveCleanRounds, costCapUsd: override.costCapUsd ?? workspace.costCapUsd, diff --git a/src/shared/types.ts b/src/shared/types.ts index 5671233..3626be8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -349,12 +349,16 @@ export interface ReviewLoopTriagedIssue extends ReviewLoopIssue { justification: string } +export type ReviewLoopVariant = 'pro' | 'lite' + export interface ReviewLoopRound { index: number startedAt: string endedAt?: string phase: ReviewLoopPhase + /** Pro-only: structured issues from review phase. Empty for Lite. */ rawIssues: ReviewLoopIssue[] + /** Pro-only: triaged issues. Empty for Lite. */ triaged: ReviewLoopTriagedIssue[] costUsd: number log: string[] @@ -368,6 +372,7 @@ export interface ReviewLoopState { branch: string baseBranch: string worktreePath: string + variant: ReviewLoopVariant status: ReviewLoopStatus currentPhase: ReviewLoopPhase iteration: number @@ -377,11 +382,14 @@ export interface ReviewLoopState { endedAt?: string stopReason?: ReviewLoopStopReason errorMessage?: string + /** Pro-only: items the loop chose not to fix; surfaced in sticky PR comment. Empty for Lite. */ skippedIssues: ReviewLoopTriagedIssue[] } export interface ReviewLoopConfig { enabled: boolean + /** Lite (default): unstructured, /review-driven, single shared session for triage→fix. Pro: structured 3-phase pipeline with JSON intermediates and PR comments. */ + variant: ReviewLoopVariant maxIterations: number consecutiveCleanRounds: number costCapUsd: number @@ -389,6 +397,7 @@ export interface ReviewLoopConfig { export interface ReviewLoopProjectOverride { enabled?: boolean + variant?: ReviewLoopVariant maxIterations?: number consecutiveCleanRounds?: number costCapUsd?: number @@ -401,6 +410,7 @@ export interface ReviewLoopSettings { export const DEFAULT_REVIEW_LOOP_CONFIG: ReviewLoopConfig = { enabled: true, + variant: 'lite', maxIterations: 5, consecutiveCleanRounds: 2, costCapUsd: 5,