follow-up restructure.

- loop until review pass
- review now comes prior to commit
This commit is contained in:
2026-07-20 10:29:15 -04:00
parent 6aa3f6bd9f
commit 519b12b3d9
8 changed files with 1092 additions and 493 deletions

View File

@@ -26,5 +26,10 @@ export const TASK_FILE_NAMES = [
export const REFLECTION_HEADER = "## REFLECTION";
export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i;
// Review verdict parsing
export const REVIEW_HEADER = "## REVIEW VERDICT";
export const REVIEW_PATTERN =
/##\s*REVIEW\s+VERDICT\s*\n([\s\S]*?)(?=\n```|$)/i;
// Pi subprocess
export const DEFAULT_PI_ARGS = ["--no-stream"] as const;

View File

@@ -1,14 +1,31 @@
import { truncateToWidth } from "@earendil-works/pi-tui";
import * as path from "node:path";
import type { Task, Project, Reflection, ToolUsage } from "./types";
import type {
Task,
Project,
Reflection,
ToolUsage,
ReviewResult,
} from "./types";
import type { RalpiConfig } from "./types";
import type { ProgressTracker } from "./progress";
import type {
ExtensionContext,
ModelRuntime,
} from "@earendil-works/pi-coding-agent";
import { buildTaskPrompt, buildReviewPrompt, MAX_DIFF_BYTES } from "./prompts";
import {
buildTaskPrompt,
buildReviewPrompt,
buildReviewPromptUncommitted,
MAX_DIFF_BYTES,
} from "./prompts";
import { extractReflection } from "./reflection";
import {
extractReview,
saveReviewToFile as saveReviewJson,
verdictGlyph,
verdictSummary,
} from "./review";
import {
runAgentSession,
writeFileSafe,
@@ -34,6 +51,8 @@ export type SendChatMessage = (
reviewText?: string;
/** Saved file path when the review has been persisted to disk. */
reviewPath?: string;
/** Structured review result (when extractReview succeeded). */
reviewResult?: ReviewResult;
},
) => void;
@@ -168,6 +187,9 @@ export async function runTask(
parallelState?: ParallelWidgetState,
assignedModel?: unknown,
batchRender?: () => void,
/** Review feedback from a rejected review — injected when re-executing
* a task in review-gated mode so the agent knows what to fix. */
reviewFeedback?: ReviewResult,
): Promise<{
success: boolean;
reflection?: Reflection;
@@ -186,6 +208,7 @@ export async function runTask(
project,
depReflections,
config.prompts.projectContext,
reviewFeedback,
);
const taskHeader = `${task.id} · ${task.title}`;
@@ -698,106 +721,241 @@ async function executeTask(
);
if (result.success) {
// ── Auto-Commit: optionally trigger follow-up agent session for uncommitted changes ──
let finalCommitMessages = result.commitMessages ?? [];
let finalCommitSummary = result.commitSummary ?? "";
let finalReview: ReviewResult | undefined;
let reviewRetries = 0;
if (config.execution.autoCommit && config.execution.autoReview) {
// ── Review-gated commit: review FIRST, loop on reject, commit on pass ──
// The review examines uncommitted changes before the commit. If the
// verdict is "fail", the task is re-executed with the review feedback
// injected into the prompt (up to maxReviewRetries). Only when the
// review passes (or retries exhaust) does the commit session run.
const maxRetries = config.execution.maxReviewRetries;
let attempt = 0;
if (config.execution.autoCommit) {
try {
if (hasUncommittedChanges(projectDir)) {
while (hasUncommittedChanges(projectDir)) {
const status = getGitStatusPorcelain(projectDir);
let diff = getGitDiff(projectDir);
let diffNote = "";
if (diff.length > MAX_DIFF_BYTES) {
diffNote =
"\n\n... (diff truncated: omitted " +
(diff.length - MAX_DIFF_BYTES).toLocaleString() +
" bytes; run `git diff` to view the full diff)";
diff = diff.slice(0, MAX_DIFF_BYTES);
}
const commitPrompt = [
`## Auto-Commit for Task ${task.id}: ${task.title}`,
"",
"The previous task is complete. There are uncommitted changes in the repository.",
"",
"Only commit changes you made while completing this task. Do not commit pre-existing changes, changes from other work, or files unrelated to this task.",
"Review the git status and diff below to identify which changes are from your work, and stage only those files.",
"",
"Stage only the files relevant to this task with `git add <files>`, then create a meaningful git commit.",
"Use a descriptive commit message and follow conventional commits format.",
"Do NOT include the task number, task ID, or any ralpi task reference in the commit message. The commit message must describe only the work done — never mention the task ID (e.g. `task 03`, `#3`, etc.).",
"",
"### Current Changes (git status --porcelain)",
"```text",
status || "(no status output)",
"```",
"",
"### Current Tracked Diff (git diff)",
"```diff",
diff || "(no tracked diff output)",
diffNote,
"```",
].join("\n");
const reviewDiff = getGitDiff(projectDir);
if (!reviewDiff && !status) break;
// Resolve commit model (fall back to current task model)
const commitModel =
resolveModelSpec(
ctx.modelRegistry as
| { find(p: string, m: string): unknown }
| undefined,
config.execution.commitModel,
(msg) => ctx.ui.notify(msg, "warning"),
) ?? currentModel;
const reviewPrompt = buildReviewPromptUncommitted(
task,
project,
status,
reviewDiff,
config.prompts.projectContext,
);
// Build failover list: primary model first, then the rest of the pool.
const commitModels = buildFailoverModels(commitModel, roundRobin);
const reviewModel = resolveFollowUpModel(
ctx,
config.execution.reviewModel,
currentModel,
);
const reviewModels = buildFailoverModels(reviewModel, roundRobin);
const { result: commitResult, toolCalls: commitToolCalls } =
const { result: reviewResult, toolCalls: reviewToolCalls } =
await runFollowUpSession(
ctx,
config,
commitPrompt,
reviewPrompt,
projectDir,
`commit for ${task.id} · ${task.title}`,
`commit-${task.id}`,
config.execution.commitTimeoutMs,
commitModels,
`review for ${task.id} · ${task.title}${
attempt > 0 ? ` (attempt ${attempt + 1})` : ""
}`,
`review-${task.id}`,
config.execution.reviewTimeoutMs,
reviewModels,
);
if (commitResult.success) {
// Re-capture commits made during this follow-up session
const newCommits = captureGitCommits(projectDir);
if (newCommits.commitMessages.length > 0) {
finalCommitMessages = [
...finalCommitMessages,
...newCommits.commitMessages,
];
finalCommitSummary = finalCommitSummary
? `${finalCommitSummary}; ${newCommits.commitSummary}`
: newCommits.commitSummary;
}
sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, {
toolCalls: commitToolCalls,
});
} else {
if (!reviewResult.success) {
sendChatMessage?.(
`~ commit for ${task.id} · ${task.title}follow-up commit session failed: ${commitResult.error}`,
{ toolCalls: commitToolCalls },
`~ review for ${task.id} · ${task.title}review session failed: ${reviewResult.error}`,
{ toolCalls: reviewToolCalls },
);
break; // commit what we have
}
const reviewText = reviewResult.text.trim();
const review = extractReview(reviewText, task.id, "uncommitted");
finalReview = review ?? undefined;
// Persist structured review JSON when opted in.
let reviewPath: string | undefined;
if (review && config.execution.saveReviews) {
reviewPath = saveReviewJson(
projectDir,
config.paths.reviewsDir,
review,
progress.getKey(),
);
}
if (
review &&
(review.verdict === "pass" || review.verdict === "warn")
) {
// Review passed — proceed to commit.
const label = `${verdictGlyph(review.verdict)} ${verdictSummary(review)}`;
const savedHint = reviewPath ? ` · saved to ${reviewPath}` : "";
sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title}${label}${savedHint}`,
{
toolCalls: reviewToolCalls,
reviewText,
reviewPath,
reviewResult: review,
},
);
break; // good to commit
}
// Review rejected (fail) or verdict not parsed.
if (review) {
sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title}${verdictGlyph(review.verdict)} ${verdictSummary(review)}`,
{
toolCalls: reviewToolCalls,
reviewText,
reviewPath,
reviewResult: review,
},
);
} else {
const lines = reviewText.split("\n").filter((l) => l.trim());
const tail = lines.slice(-3).join("\n");
const savedHint = reviewPath ? ` · saved to ${reviewPath}` : "";
sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title} — verdict not found${savedHint}\n${tail}`,
{ toolCalls: reviewToolCalls, reviewText, reviewPath },
);
}
if (attempt >= maxRetries) {
// Retries exhausted.
if (config.execution.reviewBlockOnFail) {
sendChatMessage?.(
`${task.id} · ${task.title} — review rejected after ${maxRetries} retr${maxRetries === 1 ? "y" : "ies"} (reviewBlockOnFail)`,
);
progress.markFailed(
task.id,
`Review rejected after ${maxRetries} re-execution attempt(s)`,
);
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
roundRobin?.release(task.id);
return;
}
sendChatMessage?.(
`~ review for ${task.id} · ${task.title} — max retries (${maxRetries}) exhausted, committing anyway`,
);
break; // commit what we have
}
attempt++;
reviewRetries++;
sendChatMessage?.(
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
);
// Re-execute the task with review feedback injected.
const fixResult = await runTask(
task,
project,
config,
depReflections,
ctx,
sendChatMessage,
projectDir,
parallelState,
currentModel,
batchRender,
review ?? undefined,
);
if (!fixResult.success) {
sendChatMessage?.(
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult.error}`,
);
break; // commit what we have
}
// Merge commit messages from the fix attempt.
finalCommitMessages = [
...finalCommitMessages,
...(fixResult.commitMessages ?? []),
];
finalCommitSummary = finalCommitSummary
? `${finalCommitSummary}; ${fixResult.commitSummary ?? ""}`
: (fixResult.commitSummary ?? "");
// Loop back to review the updated changes.
}
// ── Commit (after review passes or retries exhausted) ──
if (hasUncommittedChanges(projectDir)) {
const commitResult = await runCommitSession(
ctx,
config,
task,
projectDir,
currentModel,
roundRobin,
sendChatMessage,
);
if (commitResult.success) {
finalCommitMessages = [
...finalCommitMessages,
...commitResult.commitMessages,
];
finalCommitSummary = finalCommitSummary
? `${finalCommitSummary}; ${commitResult.commitSummary}`
: commitResult.commitSummary;
}
}
} catch (error) {
sendChatMessage?.(
`~ review/commit for ${task.id} · ${task.title} — error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} else if (config.execution.autoCommit) {
// ── Commit only (no review) — legacy path ──
try {
if (hasUncommittedChanges(projectDir)) {
const commitResult = await runCommitSession(
ctx,
config,
task,
projectDir,
currentModel,
roundRobin,
sendChatMessage,
);
if (commitResult.success) {
finalCommitMessages = [
...finalCommitMessages,
...commitResult.commitMessages,
];
finalCommitSummary = finalCommitSummary
? `${finalCommitSummary}; ${commitResult.commitSummary}`
: commitResult.commitSummary;
}
}
} catch (error) {
// Don't fail the task if auto-commit fails
sendChatMessage?.(
`~ commit for ${task.id} · ${task.title} — auto-commit error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// ── Auto-Review: optionally spawn a review agent to review the latest commit ──
if (config.execution.autoReview) {
} else if (config.execution.autoReview) {
// ── Review only (no commit) — reviews latest commit — legacy path ──
try {
const commitInfo = getLatestCommitDiff(projectDir);
if (commitInfo && commitInfo.diff) {
@@ -810,17 +968,11 @@ async function executeTask(
config.prompts.projectContext,
);
// Resolve review model (fall back to current task model)
const reviewModel =
resolveModelSpec(
ctx.modelRegistry as
| { find(p: string, m: string): unknown }
| undefined,
config.execution.reviewModel,
(msg) => ctx.ui.notify(msg, "warning"),
) ?? currentModel;
// Build failover list: primary model first, then the rest of the pool.
const reviewModel = resolveFollowUpModel(
ctx,
config.execution.reviewModel,
currentModel,
);
const reviewModels = buildFailoverModels(reviewModel, roundRobin);
const { result: reviewResult, toolCalls: reviewToolCalls } =
@@ -837,35 +989,48 @@ async function executeTask(
if (reviewResult.success) {
const reviewText = reviewResult.text.trim();
const review = extractReview(
reviewText,
task.id,
commitInfo.hash,
);
finalReview = review ?? undefined;
// 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/<prdKey>/<taskId>.md
let reviewPath: string | undefined;
if (config.execution.saveReviews) {
reviewPath = saveReviewToFile(
if (review && config.execution.saveReviews) {
reviewPath = saveReviewJson(
projectDir,
config,
task.id,
reviewText,
config.paths.reviewsDir,
review,
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}${savedHint}\n${tail}`,
{ toolCalls: reviewToolCalls, reviewText, reviewPath },
);
if (review) {
const label = `${verdictGlyph(review.verdict)} ${verdictSummary(review)}`;
const savedHint = reviewPath
? ` · saved to ${reviewPath}`
: "";
sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title}${label}${savedHint}`,
{
toolCalls: reviewToolCalls,
reviewText,
reviewPath,
reviewResult: review,
},
);
} else {
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}${savedHint}\n${tail}`,
{ toolCalls: reviewToolCalls, reviewText, reviewPath },
);
}
} else {
sendChatMessage?.(
`~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`,
@@ -874,7 +1039,6 @@ async function executeTask(
}
}
} catch (error) {
// Don't fail the task if auto-review fails
sendChatMessage?.(
`~ review for ${task.id} · ${task.title} — auto-review error: ${
error instanceof Error ? error.message : String(error)
@@ -902,6 +1066,8 @@ async function executeTask(
result.outputPreview,
finalCommitMessages,
finalCommitSummary,
finalReview,
reviewRetries,
);
// Auto-update the PRD source file checkbox
try {
@@ -987,24 +1153,6 @@ 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/<prdKey>/<taskId>.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) ─────────────────────────────────────
/**
@@ -1159,6 +1307,129 @@ function buildFailoverModels(
// ─── Tool Call Formatting ────────────────────────────────────────────────
/**
* Shorthand type for the model registry's find() shape.
*/
type ModelRegistryLike = { find(p: string, m: string): unknown };
/**
* Resolve a model spec for a follow-up session (commit/review), falling back
* to `currentModel` when the config field is blank or the registry can't
* resolve it. Warns via `ctx.ui.notify` on resolution failure.
*/
function resolveFollowUpModel(
ctx: ExtensionContext,
spec: string,
currentModel: unknown,
): unknown {
return (
resolveModelSpec(
ctx.modelRegistry as ModelRegistryLike | undefined,
spec,
(msg) => ctx.ui.notify(msg, "warning"),
) ?? currentModel
);
}
/**
* Run the auto-commit follow-up agent session.
* Returns the commit messages, summary, tool calls, and success flag.
*/
async function runCommitSession(
ctx: ExtensionContext,
config: RalpiConfig,
task: Task,
projectDir: string,
currentModel: unknown,
roundRobin: ModelRoundRobin | null | undefined,
sendChatMessage?: SendChatMessage,
): Promise<{
commitMessages: string[];
commitSummary: string;
toolCalls: ToolCallEntry[];
success: boolean;
}> {
const status = getGitStatusPorcelain(projectDir);
let diff = getGitDiff(projectDir);
let diffNote = "";
if (diff.length > MAX_DIFF_BYTES) {
diffNote =
"\n\n... (diff truncated: omitted " +
(diff.length - MAX_DIFF_BYTES).toLocaleString() +
" bytes; run `git diff` to view the full diff)";
diff = diff.slice(0, MAX_DIFF_BYTES);
}
const commitPrompt = [
`## Auto-Commit for Task ${task.id}: ${task.title}`,
"",
"The previous task is complete. There are uncommitted changes in the repository.",
"",
"Only commit changes you made while completing this task. Do not commit pre-existing changes, changes from other work, or files unrelated to this task.",
"Review the git status and diff below to identify which changes are from your work, and stage only those files.",
"",
"Stage only the files relevant to this task with `git add <files>`, then create a meaningful git commit.",
"Use a descriptive commit message and follow conventional commits format.",
"Do NOT include the task number, task ID, or any ralpi task reference in the commit message. The commit message must describe only the work done — never mention the task ID (e.g. `task 03`, `#3`, etc.).",
"",
"### Current Changes (git status --porcelain)",
"```text",
status || "(no status output)",
"```",
"",
"### Current Tracked Diff (git diff)",
"```diff",
diff || "(no tracked diff output)",
diffNote,
"```",
].join("\n");
const commitModel = resolveFollowUpModel(
ctx,
config.execution.commitModel,
currentModel,
);
const commitModels = buildFailoverModels(commitModel, roundRobin);
const { result: commitResult, toolCalls: commitToolCalls } =
await runFollowUpSession(
ctx,
config,
commitPrompt,
projectDir,
`commit for ${task.id} · ${task.title}`,
`commit-${task.id}`,
config.execution.commitTimeoutMs,
commitModels,
);
if (commitResult.success) {
const newCommits = captureGitCommits(projectDir);
const commitMessages =
newCommits.commitMessages.length > 0 ? newCommits.commitMessages : [];
const commitSummary = newCommits.commitSummary || "";
sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, {
toolCalls: commitToolCalls,
});
return {
commitMessages,
commitSummary,
toolCalls: commitToolCalls,
success: true,
};
}
sendChatMessage?.(
`~ commit for ${task.id} · ${task.title} — follow-up commit session failed: ${commitResult.error}`,
{ toolCalls: commitToolCalls },
);
return {
commitMessages: [],
commitSummary: "",
toolCalls: commitToolCalls,
success: false,
};
}
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).

View File

@@ -6,6 +6,7 @@ import type {
Task,
Reflection,
ToolUsage,
ReviewResult,
} from "./types";
import { ensureDir } from "./utils";
@@ -174,6 +175,8 @@ export class ProgressTracker {
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
review?: ReviewResult,
reviewRetries?: number,
): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
@@ -185,6 +188,9 @@ export class ProgressTracker {
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
if (review) prd.tasks[taskId].review = review;
if (reviewRetries !== undefined)
prd.tasks[taskId].reviewRetries = reviewRetries;
this.save();
}
@@ -244,6 +250,26 @@ export class ProgressTracker {
this.save();
}
/** Reset all `in_progress` tasks back to `pending`.
*
* Used after a session reload: in-process agent sessions die with the
* parent session, so any task left `in_progress` is actually stalled.
* Resetting ensures the DAG re-schedules it on the next resume. Returns
* the IDs that were reset. */
resetInProgressToPending(): string[] {
const prd = this.getPRD();
const reset: string[] = [];
for (const [id, info] of Object.entries(prd.tasks)) {
if (info.status === "in_progress") {
info.status = "pending";
delete info.startedAt;
reset.push(id);
}
}
if (reset.length > 0) this.save();
return reset;
}
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();

View File

@@ -1,4 +1,4 @@
import type { Task, Project, Reflection } from "./types";
import type { Task, Project, Reflection, ReviewResult } from "./types";
import { readTaskSpec } from "./parser";
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
@@ -35,6 +35,9 @@ export function buildTaskPrompt(
project: Project,
depReflections: Reflection[],
projectContext?: string,
/** Review feedback from a rejected review — injected when re-executing
* a task in review-gated mode so the agent knows what to fix. */
reviewFeedback?: ReviewResult,
): string {
const parts: string[] = [];
@@ -130,6 +133,32 @@ export function buildTaskPrompt(
parts.push("");
}
// ── Previous Review Feedback (re-execution only) ──
if (reviewFeedback) {
parts.push("## Previous Review Feedback — FIX REQUIRED");
parts.push(
"A review agent examined your previous attempt and rejected it.",
);
parts.push(`Verdict: **${reviewFeedback.verdict.toUpperCase()}**`);
parts.push(`Summary: ${reviewFeedback.summary}`);
parts.push("");
if (reviewFeedback.findings.length > 0) {
parts.push("You MUST address these findings:");
for (const finding of reviewFeedback.findings) {
const loc = finding.file
? finding.line
? ` (${finding.file}:${finding.line})`
: ` (${finding.file})`
: "";
parts.push(`- [${finding.severity}]${loc} ${finding.message}`);
}
parts.push("");
}
parts.push("Fix every issue above. Do not re-introduce the same problems.");
parts.push("");
}
// ── Reflection Instructions ──
parts.push("## REFLECTION (REQUIRED)");
@@ -227,24 +256,141 @@ export function buildReviewPrompt(
parts.push(
"Review the commit above against the task description. Check for:",
);
parts.push(
"- **Correctness**: Does the implementation fulfill the task requirements?",
);
parts.push("- **Completeness**: Are all aspects of the task addressed?");
parts.push(
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
);
parts.push(
"- **Missing changes**: Are there files that should have been modified but weren't?",
);
parts.push(...reviewInstructions());
parts.push("");
parts.push(
"Provide a concise review with any issues found. If the commit looks good, say so explicitly.",
"Provide a concise review with any issues found. Your free-form prose",
);
parts.push("precedes the structured verdict block below.");
parts.push(...reviewVerdictBlock());
return parts.join("\n");
}
// ─── Uncommitted-Changes Review Prompt ──────────────────────────────────────
/**
* Build a review prompt for uncommitted working-tree changes (pre-commit).
* Used in review-gated mode: the review runs BEFORE committing so a rejected
* review triggers a re-execution instead of a bad commit.
*/
export function buildReviewPromptUncommitted(
task: Task,
project: Project,
status: string,
diff: string,
projectContext?: string,
): string {
const parts: string[] = [];
parts.push(`# Code Review (pre-commit): Task ${task.id}: ${task.title}`);
parts.push("");
// ── Task Description ──
parts.push("## Task Description");
if (task.description) {
parts.push(task.description);
} else {
parts.push(task.title);
}
parts.push("");
// ── Task Specification ──
if (task.file) {
const spec = readTaskSpec(project.sourceDir, task.file);
if (spec) {
parts.push("## Task Specification");
parts.push(`Full details from \`${task.file}\`:`);
parts.push("");
parts.push(spec);
parts.push("");
}
}
// ── Uncommitted Changes Under Review ──
parts.push("## Uncommitted Changes Under Review");
parts.push(
"Review the working-tree changes below against the task description.",
);
parts.push("");
parts.push("### Current Changes (git status --porcelain)");
parts.push("```text");
parts.push(status || "(no status output)");
parts.push("```");
parts.push("");
parts.push("### Current Tracked Diff (git diff)");
parts.push("```diff");
parts.push(truncateDiff(diff) || "(no tracked diff output)");
parts.push("```");
parts.push("");
// ── Project Context ──
if (projectContext) {
parts.push("## Additional Context");
parts.push(projectContext);
parts.push("");
}
// ── Review Instructions ──
parts.push("## Review Instructions");
parts.push(
"Review the uncommitted changes above against the task description. Check for:",
);
parts.push(...reviewInstructions());
parts.push("");
parts.push(
"Provide a concise review with any issues found. Your free-form prose",
);
parts.push("precedes the structured verdict block below.");
parts.push(...reviewVerdictBlock());
return parts.join("\n");
}
// ─── Shared Review Prompt Helpers ───────────────────────────────────────────
function reviewInstructions(): string[] {
return [
"- **Correctness**: Does the implementation fulfill the task requirements?",
"- **Completeness**: Are all aspects of the task addressed?",
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
"- **Missing changes**: Are there files that should have been modified but weren't?",
];
}
function reviewVerdictBlock(): string[] {
return [
"## REVIEW VERDICT (REQUIRED)",
"End your response with a verdict block in EXACTLY this format:",
"",
"```",
"## REVIEW VERDICT",
"VERDICT: [pass | warn | fail]",
"SUMMARY: [1-2 sentence overall assessment]",
"FINDINGS:",
"- [blocker] file:line description (use severity: blocker|warning|nit|info)",
"- [warning] file:line description",
"```",
"",
"Verdict guidance:",
"- **pass**: the implementation fully satisfies the task requirements; no",
" action needed. Use an empty FINDINGS section (just the header).",
"- **warn**: the implementation is acceptable but has minor issues worth fixing",
" in a follow-up; not blocking.",
"- **fail**: the implementation does not satisfy the task, or has serious bugs",
" that must be fixed before proceeding.",
"",
"Each FINDINGS line uses the form `- [severity] [file:line] message`.",
"The `file:line` part is optional. Severity must be one of:",
"`blocker`, `warning`, `nit`, `info`.",
];
}
/**
* Build the prompt for a dry-run / plan display
*/

211
src/review.ts Normal file
View File

@@ -0,0 +1,211 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ReviewResult, ReviewFinding, ReviewVerdict } from "./types";
import { REVIEW_PATTERN } from "./constants";
import { ensureDir, writeFileSafe } from "./utils";
// ─── Extract Structured Review ──────────────────────────────────────────────
/**
* Extract a structured review verdict from the review agent's output text.
* Mirrors extractReflection() — parses a `## REVIEW VERDICT` block emitted at
* the end of the response.
*
* The raw text is preserved on the ReviewResult so the expanded (Ctrl+O) view
* can still render the full free-form prose. Returns null when no verdict
* block is found (caller falls back to free-form text handling).
*/
export function extractReview(
output: string,
taskId: string,
commitHash: string,
): ReviewResult | null {
const match = output.match(REVIEW_PATTERN);
if (!match) return null;
const block = match[1];
const verdict = extractVerdict(block);
if (!verdict) return null; // verdict is the one required field
const summary = extractField(block, "SUMMARY") ?? "";
const findings = extractFindings(block);
return {
taskId,
verdict,
summary: summary || verdictLabel(verdict),
findings,
commitHash,
rawText: output.trim(),
timestamp: new Date().toISOString(),
};
}
function extractVerdict(block: string): ReviewVerdict | null {
const raw = extractField(block, "VERDICT");
if (!raw) return null;
const v = raw.toLowerCase().trim();
if (v === "pass" || v === "warn" || v === "fail") return v;
// Tolerate common synonyms
if (v === "warning" || v === "minor") return "warn";
if (v === "fail" || v === "failing" || v === "blocker") return "fail";
if (v === "ok" || v === "passing" || v === "approve") return "pass";
return null;
}
// Allowlisted static regexes — `field` is always a known literal, but we use
// a static map rather than string interpolation so there's no dynamic regex
// construction at all (`new RegExp` from a variable trips ReDoS linters).
const FIELD_PATTERNS: Record<string, RegExp> = {
VERDICT: /VERDICT:\s*(.+?)$/im,
SUMMARY: /SUMMARY:\s*(.+?)$/im,
};
function extractField(block: string, field: string): string | null {
const regex = FIELD_PATTERNS[field.toUpperCase()];
if (!regex) return null;
const match = block.match(regex);
return match ? match[1].trim() : null;
}
/**
* Parse FINDINGS: lines into structured ReviewFinding objects.
* Each finding line is expected as:
* - [severity] [file:line] message
* where severity is one of blocker|warning|nit|info.
* Falls back gracefully — an unparseable line becomes an info-severity
* finding with the raw line as the message.
*/
function extractFindings(block: string): ReviewFinding[] {
// Match the FINDINGS: header, then capture all following bullet lines.
const regex = /FINDINGS:\s*\n((?:[-*]\s+.+\n?)+)/i;
const match = block.match(regex);
if (!match) return [];
const lines = match[1]
.split("\n")
.map((l) => l.replace(/^[-*]\s*/, "").trim())
.filter(Boolean);
const findings: ReviewFinding[] = [];
const severityRe = /^\[(blocker|warning|warn|nit|info)\]\s*(.*)$/i;
for (const line of lines) {
const sm = line.match(severityRe);
if (sm) {
let sev = sm[1].toLowerCase();
if (sev === "warn") sev = "warning";
const rest = sm[2].trim();
const { file, line: lineNum, message } = parseFileRef(rest);
findings.push({
severity: sev as ReviewFinding["severity"],
file,
line: lineNum,
message,
});
} else {
// No severity bracket — treat as info
const { file, line: lineNum, message } = parseFileRef(line);
findings.push({ severity: "info", file, line: lineNum, message });
}
}
return findings;
}
/** Parse an optional `file:line` prefix from a finding message. */
function parseFileRef(rest: string): {
file?: string;
line?: number;
message: string;
} {
const m = rest.match(/^([\w./-]+):(\d+)\s*[-—]?\s*(.*)$/);
if (m) {
return { file: m[1], line: Number(m[2]), message: m[3].trim() || rest };
}
return { message: rest };
}
function verdictLabel(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "Commit satisfies the task requirements.";
case "warn":
return "Commit passes with minor issues worth addressing.";
case "fail":
return "Commit does not satisfy the task requirements.";
}
}
// ─── Save / Load Structured Reviews ─────────────────────────────────────────
/**
* Save a structured review as JSON alongside (or instead of) the markdown
* body. Mirrors saveReflectionToFile's per-loop layout so a repo can hold
* many loops without collisions:
* .ralpi/reviews/<prdKey>/<taskId>.json
*/
export function saveReviewToFile(
sourceDir: string,
reviewsDir: string,
review: ReviewResult,
prdKey: string,
): string {
const dir = path.join(sourceDir, reviewsDir, prdKey);
ensureDir(dir);
const filePath = path.join(dir, `${review.taskId}.json`);
writeFileSafe(filePath, JSON.stringify(review, null, 2));
return filePath;
}
/**
* Load a structured review from disk.
*/
export function loadReview(
sourceDir: string,
reviewsDir: string,
taskId: string,
prdKey: string,
): ReviewResult | null {
const filePath = path.join(sourceDir, reviewsDir, prdKey, `${taskId}.json`);
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ReviewResult;
} catch {
return null;
}
}
// ─── Formatting ──────────────────────────────────────────────────────────────
/** Verdict glyph for compact display in chat headers / widgets. */
export function verdictGlyph(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "✓";
case "warn":
return "⚠";
case "fail":
return "✗";
}
}
/** Short label: "PASS · 0 findings", "WARN · 2 findings", "FAIL · 3 findings" */
export function verdictSummary(review: ReviewResult): string {
const n = review.findings.length;
const noun = n === 1 ? "finding" : "findings";
return `${review.verdict.toUpperCase()} · ${n} ${noun}`;
}
/**
* Format findings as an indented markdown tree for the expanded view.
*/
export function formatFindings(review: ReviewResult): string {
if (review.findings.length === 0) return "(no findings)";
const lines: string[] = [];
for (const f of review.findings) {
const loc = f.file ? (f.line ? `${f.file}:${f.line}` : f.file) : "";
lines.push(` - [${f.severity}]${loc ? ` ${loc}` : ""}${f.message}`);
}
return lines.join("\n");
}

View File

@@ -103,6 +103,37 @@ export interface Reflection {
timestamp: string;
}
// ─── Review Model ────────────────────────────────────────────────────────────
export type ReviewVerdict = "pass" | "warn" | "fail";
export interface ReviewFinding {
/** Severity of the finding */
severity: "blocker" | "warning" | "nit" | "info";
/** File path if applicable */
file?: string;
/** Line number if applicable */
line?: number;
/** Description of the issue */
message: string;
}
export interface ReviewResult {
taskId: string;
/** Overall verdict */
verdict: ReviewVerdict;
/** 1-2 sentence overall assessment */
summary: string;
/** Structured findings (empty when verdict is "pass") */
findings: ReviewFinding[];
/** Commit hash the review was performed against */
commitHash: string;
/** Full free-form review text (preserved for display) */
rawText: string;
/** ISO timestamp */
timestamp: string;
}
export interface ToolUsage {
read: number;
write: number;
@@ -117,6 +148,8 @@ export interface TaskProgressInfo {
completedAt?: string;
durationMs?: number;
reflection?: Reflection;
/** Structured review result (when autoReview is enabled) */
review?: ReviewResult;
error?: string;
/** Tool usage counts from parsed subprocess output */
toolUsage?: ToolUsage;
@@ -126,6 +159,8 @@ export interface TaskProgressInfo {
commitMessages?: string[];
/** Summary derived from git commits */
commitSummary?: string;
/** Number of review-fix re-execution attempts made (review-gated mode) */
reviewRetries?: number;
}
export interface ProgressState {
@@ -194,6 +229,13 @@ export interface RalpiConfig {
commitTimeoutMs: number;
/** Timeout for auto-review agent sessions in milliseconds */
reviewTimeoutMs: number;
/** Max review-fix re-execution attempts before giving up and committing
* anyway (0 = no retries; review runs once, reject = commit anyway).
* Only active when both autoCommit AND autoReview are enabled. */
maxReviewRetries: number;
/** When true, a 'fail' review verdict after exhausting maxReviewRetries
* marks the task as failed instead of committing. */
reviewBlockOnFail: boolean;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
loopTimeoutMs: number;
};
@@ -227,6 +269,8 @@ export const DEFAULT_CONFIG: RalpiConfig = {
implModel: "",
commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
loopTimeoutMs: 0, // 0 = no limit
},
prompts: {

View File

@@ -40,7 +40,8 @@ export function writeFileSafe(filePath: string, content: string): void {
/**
* State persisted to disk when a ralpi execution loop is active.
* Used to re-instantiate widgets after a session reload.
* Used to re-instantiate widgets after a session reload, and to resume
* the loop non-interactively when a reload interrupts in-progress tasks.
*/
export interface LoopActiveState {
taskFile: string;
@@ -48,6 +49,11 @@ export interface LoopActiveState {
startedAt: string;
taskIds: string[];
prdKey: string;
/** Loop option snapshot at loop start, so a reload can resume without
* re-prompting the user. */
autoCommit?: boolean;
autoReview?: boolean;
saveReviews?: boolean;
}
/**