diff --git a/index.ts b/index.ts index ae7db0a..9fa77fc 100644 --- a/index.ts +++ b/index.ts @@ -131,7 +131,7 @@ function buildPlanByMode( async function selectLoopOptions( ctx: ExtensionContext, config: import("./src/types").RalpiConfig, -): Promise<{ autoCommit: boolean; autoReview: boolean }> { +): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> { const explicit = config.execution.explicitKeys; // Skip the commit prompt when the YAML explicitly sets it. @@ -149,6 +149,7 @@ async function selectLoopOptions( } let autoReview = false; + let saveReviews = false; if (autoCommit) { // Skip the review prompt when the YAML explicitly sets it. if (explicit?.has("autoReview")) { @@ -162,9 +163,27 @@ async function selectLoopOptions( ? reviewChoice.startsWith("Yes") : config.execution.autoReview; } + + // Only ask to persist reviews when reviews are actually enabled. + if (autoReview) { + if (explicit?.has("saveReviews")) { + saveReviews = config.execution.saveReviews; + } else { + const saveChoice = await ctx.ui.select( + "Save full review output to disk?", + [ + "Yes — write each review to .ralpi/reviews//.md", + "No — keep reviews in-chat only", + ], + ); + saveReviews = saveChoice + ? saveChoice.startsWith("Yes") + : config.execution.saveReviews; + } + } } - return { autoCommit, autoReview }; + return { autoCommit, autoReview, saveReviews }; } /** @@ -350,6 +369,8 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { | { phase?: string; toolCalls?: Array<{ name: string; label: string }>; + reviewText?: string; + reviewPath?: string; } | undefined; @@ -359,6 +380,23 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" lines.push(String(message.content)); + // Review body: in expanded mode render the full review text so long + // reviews aren't lost to the 500-char preview. In collapsed mode + // show a dim hint that the review is available via Ctrl+O (the + // header already carries a short tail + saved-path hint). + const hasReview = !!details?.reviewText; + if (hasReview && expanded && details!.reviewText) { + const body = details!.reviewText.split("\n"); + for (const line of body) { + lines.push(` ${line}`); + } + } else if (hasReview && !expanded) { + const hint = details?.reviewPath + ? `press Ctrl+O for full review · saved to ${details.reviewPath}` + : "press Ctrl+O for full review"; + lines.push(theme.fg("dim", ` ├── ${hint}`)); + } + // Build tool-call tree if (details?.toolCalls && details.toolCalls.length > 0) { const all = details.toolCalls; @@ -742,16 +780,27 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { // Wraps pi.sendMessage() for posting status to the chat history. // Uses "ralpi-progress" customType with a "progress" phase so the // renderer omits the label prefix entirely (no [INFO] etc.). - // Accepts an optional meta object with toolCalls for the expandable view. + // Accepts an optional meta object with toolCalls for the expandable view, + // and reviewText/reviewPath for review messages so the expanded + // (Ctrl+O) view can render the full review body without truncation. const sendProgress: SendChatMessage = ( content: string, - meta?: { toolCalls?: Array<{ name: string; label: string }> }, + meta?: { + toolCalls?: Array<{ name: string; label: string }>; + reviewText?: string; + reviewPath?: string; + }, ) => { pi.sendMessage({ customType: "ralpi-progress", content, display: true, - details: { phase: "progress", toolCalls: meta?.toolCalls }, + details: { + phase: "progress", + toolCalls: meta?.toolCalls, + reviewText: meta?.reviewText, + reviewPath: meta?.reviewPath, + }, }); }; @@ -895,9 +944,13 @@ async function handleRun( const completed = buildCompletedSet(progress, project); const mode = await selectExecutionMode(ctx, project, taskFile, config); - const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); + const { autoCommit, autoReview, saveReviews } = await selectLoopOptions( + ctx, + config, + ); config.execution.autoCommit = autoCommit; config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; const plan = buildPlanByMode(mode, project, completed); // Show dependency chain + execution plan before starting @@ -1004,9 +1057,13 @@ async function handleResume( const completed = buildCompletedSet(progress, project); const mode = await selectExecutionMode(ctx, project, taskFile, config); - const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); + const { autoCommit, autoReview, saveReviews } = await selectLoopOptions( + ctx, + config, + ); config.execution.autoCommit = autoCommit; config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; const plan = buildPlanByMode(mode, project, completed); // Print remaining batches before executing diff --git a/src/executor.ts b/src/executor.ts index 419e00d..ae48d6e 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -27,7 +27,14 @@ import { updateTaskInFile } from "./parser"; export type SendChatMessage = ( content: string, /** Extra data passed to the message renderer for the expanded view. */ - meta?: { toolCalls?: ToolCallEntry[] }, + meta?: { + toolCalls?: ToolCallEntry[]; + /** Full review body for review messages — renderer shows it in the + * expanded (Ctrl+O) view so long reviews aren't lost to truncation. */ + reviewText?: string; + /** Saved file path when the review has been persisted to disk. */ + reviewPath?: string; + }, ) => void; export interface ToolCallEntry { @@ -830,14 +837,34 @@ async function executeTask( if (reviewResult.success) { const reviewText = reviewResult.text.trim(); - // Post review as a chat message with tool calls - const preview = - reviewText.length > 500 - ? reviewText.slice(0, 500) + "\n... (truncated)" - : reviewText; + + // Persist the full review to disk when opted in at loop + // start. Mirrors the reflections layout so a repo can + // hold many loops without collisions: + // .ralpi/reviews//.md + let reviewPath: string | undefined; + if (config.execution.saveReviews) { + reviewPath = saveReviewToFile( + projectDir, + config, + task.id, + reviewText, + progress.getKey(), + ); + } + + // Post review as a chat message. The full body is + // passed via meta.reviewText so the expanded (Ctrl+O) + // view can render it without truncation; the collapsed + // content shows a short tail + a hint to expand. + const lines = reviewText.split("\n").filter((l) => l.trim()); + const tail = lines.slice(-3).join("\n"); + const savedHint = reviewPath + ? ` \u00b7 saved to ${reviewPath}` + : ""; sendChatMessage?.( - `⚑ review for ${task.id} · ${task.title}\n${preview}`, - { toolCalls: reviewToolCalls }, + `⚑ review for ${task.id} · ${task.title}${savedHint}\n${tail}`, + { toolCalls: reviewToolCalls, reviewText, reviewPath }, ); } else { sendChatMessage?.( @@ -960,6 +987,24 @@ function saveReflectionToFile( writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); } +// ─── Save Review Output to File ───────────────────────────────────────────── +// Mirrors saveReflectionToFile's per-loop layout so a repo can hold many +// loops without collisions: .ralpi/reviews//.md + +function saveReviewToFile( + sourceDir: string, + config: RalpiConfig, + taskId: string, + reviewText: string, + prdKey: string, +): string { + const reviewsDir = path.join(sourceDir, config.paths.reviewsDir, prdKey); + ensureDir(reviewsDir); + const filePath = path.join(reviewsDir, `${taskId}.md`); + writeFileSafe(filePath, reviewText); + return filePath; +} + // ─── Follow-Up Sessions (Commit / Review) ───────────────────────────────────── /** diff --git a/src/types.ts b/src/types.ts index 565b872..35c7acd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -164,6 +164,8 @@ export interface RalpiConfig { stateDir: string; /** Directory for per-task reflections */ reflectionsDir: string; + /** Directory for per-loop review output (mirrors reflectionsDir) */ + reviewsDir: string; }; execution: { /** Task execution timeout in milliseconds */ @@ -176,6 +178,9 @@ export interface RalpiConfig { autoCommit: boolean; /** Spawn a review agent to review the commit against the task description */ autoReview: boolean; + /** Persist the full review output to `.ralpi/reviews/.md`. + * Only active when autoReview is true and the user opts in at loop start. */ + saveReviews: boolean; /** Keys under `execution:` explicitly present in a loaded config YAML. * Used to skip interactive prompts for fields the user already set. */ explicitKeys?: Set; @@ -208,6 +213,7 @@ export const DEFAULT_CONFIG: RalpiConfig = { paths: { stateDir: ".ralpi", reflectionsDir: ".ralpi/reflections", + reviewsDir: ".ralpi/reviews", }, execution: { timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) @@ -215,6 +221,7 @@ export const DEFAULT_CONFIG: RalpiConfig = { models: [], autoCommit: true, autoReview: false, + saveReviews: false, commitModel: "", reviewModel: "", implModel: "",