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

592
index.ts
View File

@@ -15,11 +15,9 @@ import {
import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts";
import { formatReflections } from "./src/reflection";
import {
executeBatch,
SPINNER_FRAMES,
type SendChatMessage,
} from "./src/executor";
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
import type { ReviewResult } from "./src/types";
import { executeBatch, type SendChatMessage } from "./src/executor";
import {
loadConfig,
resolveTaskArg,
@@ -243,7 +241,9 @@ async function executePlanBatches(
sendChatMessage?: SendChatMessage,
projectDir?: string,
): Promise<void> {
// Write loop-active marker so widgets can be re-instantiated after a reload
// Write loop-active marker so a session reload can detect an interrupted
// loop and resume it (in-process agent sessions die on reload — the marker
// + progress.json in_progress tasks are the signal to re-run them).
if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, {
@@ -252,6 +252,9 @@ async function executePlanBatches(
startedAt: new Date().toISOString(),
taskIds: allTaskIds,
prdKey: progress.getKey(),
autoCommit: config.execution.autoCommit,
autoReview: config.execution.autoReview,
saveReviews: config.execution.saveReviews,
});
}
@@ -371,6 +374,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
}
| undefined;
@@ -380,12 +384,34 @@ 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) {
// Structured review: when we have a ReviewResult, render verdict +
// findings tree. In expanded mode show findings detail; collapsed
// shows the verdict summary + a hint to expand.
const hasReview = !!details?.reviewText || !!details?.reviewResult;
if (details?.reviewResult) {
const rv = details.reviewResult;
const glyph = verdictGlyph(rv.verdict);
const summary = verdictSummary(rv);
if (expanded) {
// Show verdict, summary, then findings tree, then raw text.
lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`);
lines.push(` ${rv.summary}`);
if (rv.findings.length > 0) {
lines.push(` ${formatFindings(rv)}`);
}
if (details.reviewText) {
const body = details.reviewText.split("\n");
for (const line of body) {
lines.push(` ${line}`);
}
}
} else {
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", ` ├── ${glyph} ${summary} · ${hint}`));
}
} else if (hasReview && expanded && details!.reviewText) {
const body = details!.reviewText.split("\n");
for (const line of body) {
lines.push(` ${line}`);
@@ -436,13 +462,15 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
},
);
// ─── Reload detection: re-instantiate widgets when session reloads ──────
// ─── Reload detection: resume interrupted loops when session reloads ──
//
// When the user types /reload while ralpi tasks are executing, the old
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
// disappear. This handler detects the reload, reads the persisted loop-active
// marker and progress.json, and re-creates live-status widgets that show
// task progress with spinner animation and tool calls from session files.
// ralpi runs task agent sessions in-process (createAgentSession), so they
// do NOT survive a /reload. When the new session starts, this handler
// reads the persisted loop-active marker + progress.json: if any task is
// still `in_progress`, the loop was interrupted mid-task and we resume it
// (resetting those tasks to pending so the DAG re-schedules them), using
// the mode + loop options snapshotted in loop-active.json so the resume is
// non-interactive.
pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return;
@@ -455,89 +483,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
if (!loopState) return;
// Load progress state
let abortPolling = false;
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
// Parse the task file to get task titles
const titleMap = new Map<string, string>();
try {
const project = parseTaskFile(loopState.taskFile);
for (const task of project.tasks) {
titleMap.set(task.id, task.title);
}
} catch {
// If parsing fails, just use IDs without titles
}
/** Read recent tool calls from a task's session file. */
const readRecentToolCalls = (
taskId: string,
maxLines = 30,
): Array<{ name: string; label: string }> => {
try {
const files = fs
.readdirSync(sessionsDir)
.filter((f) => f.startsWith(taskId + "-"))
.sort();
if (files.length === 0) return [];
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
const content = fs.readFileSync(sessionPath, "utf-8");
const lines = content
.split("\n")
.filter((l) => l.trim())
.slice(-maxLines);
const calls: Array<{ name: string; label: string }> = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "tool_execution_start") {
calls.push({
name: event.toolName,
label: formatToolLabel(event.toolName, event.args),
});
}
} catch {
// Skip malformed lines
}
}
return calls;
} catch {
return [];
}
};
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).
*/
function sanitizeLabel(s: string): string {
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** Format a tool call argument into a short label. */
function formatToolLabel(name: string, args: unknown): string {
const a = args as Record<string, unknown> | undefined;
if (!a) return name;
if (name === "bash")
return sanitizeLabel(String(a.command ?? "").slice(0, 70));
if (name === "write" || name === "read" || name === "edit")
return sanitizeLabel(String(a.path ?? "").slice(0, 60));
if (name === "grep")
return sanitizeLabel(
`${a.pattern ?? "?"}${String(a.path ?? "").slice(0, 40)}`,
);
if (name === "find")
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
if (name === "ls")
return sanitizeLabel(String(a.path ?? ".").slice(0, 60));
return name;
}
/** Re-read progress from disk (old tasks still writing to it). */
/** Re-read progress from disk. */
const readTasks = (): Record<string, { status: string }> | null => {
try {
const raw = fs.readFileSync(progressPath, "utf-8");
@@ -548,226 +496,89 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
}
};
// Early exit: if all tasks already finished during the reload, just clean up
// ralpi agent sessions run in-process (createAgentSession), so they do
// NOT survive a session reload. Any task left `in_progress` is therefore
// stalled — its agent died with the previous session. Detect that state
// and actively resume the loop instead of passively polling (which would
// spin forever waiting for a dead task to complete).
const initialTasks = readTasks();
if (initialTasks) {
const remaining = Object.values(initialTasks).filter(
(t) => t.status === "in_progress",
).length;
if (remaining === 0) {
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
t.status === "in_progress" ? [id] : [],
);
if (inProgressIds.length === 0) {
// Nothing was mid-flight — loop either finished cleanly between
// the reload landing and this handler running, or was stopped
// between tasks. Clean up the stale marker and bail.
ctx.ui.notify(
"ralpi loop has no in-progress task to resume — marking complete.",
"info",
);
deleteLoopActive(projectDir);
return;
}
}
// Show a status notification for the reconnect
const taskCount = loopState.taskIds.length;
ctx.ui.notify(
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
"info",
);
const taskCount = loopState.taskIds.length;
ctx.ui.notify(
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
"info",
);
// Shared state for the widget
let tickCount = 0;
const MAX_COLLAPSED = 3;
if (loopState.mode === "parallel") {
// ── Parallel mode: single batch widget ──
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
let widgetTui: { requestRender(): void } | null = null;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
const lines: string[] = [];
// Only show tasks that have started (in_progress, completed, failed).
// Pending/unstarted tasks are noise after a reload.
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
const info = tasks[id];
return info && info.status !== "pending";
// Build the sendProgress wrapper so resumed task messages render the
// same expandable tool-call tree as an interactive run.
const sendProgress: SendChatMessage = (
content: string,
meta?: {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
},
) => {
pi.sendMessage({
customType: "ralpi-progress",
content,
display: true,
details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
});
// If no tasks have started yet, show nothing — polling will pick up
// changes within 500ms.
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
for (const id of sortedIds) {
const info = tasks[id]!;
const title = titleMap.get(id);
const header = title ? `${id} · ${title}` : id;
// Status icon
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls for active tasks
const toolCalls = readRecentToolCalls(id);
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildBatchLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
// Load config from the project directory so model + thinking level
// resolve the same way the interactive command handler does.
const config = loadConfig(projectDir);
// 100ms tick: advances spinner frame every tick, refreshes
// progress + tool calls every 5 ticks (500ms).
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const activeCount = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
if (activeCount === 0) {
clearInterval(tickTimer);
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}
}
}, 100);
// Clean up timer when extension is shut down
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// ── Sequential mode: per-task widget ──
const currentTaskId = loopState.taskIds.find((id) => {
const tasks = readTasks();
return tasks?.[id]?.status === "in_progress";
});
if (currentTaskId) {
const widgetKey = `ralpi-task-${currentTaskId}`;
let widgetTui: { requestRender(): void } | null = null;
const buildLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
const info = tasks?.[currentTaskId];
const title = titleMap.get(currentTaskId);
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
const lines: string[] = [];
if (!info || info.status === "pending") {
return [t.fg("dim", "(starting task...)")];
}
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls
const toolCalls = readRecentToolCalls(currentTaskId);
if (toolCalls.length > 0) {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
if (remaining > 0) {
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
}
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const status = tasks[currentTaskId]?.status;
if (status !== "in_progress") {
clearInterval(tickTimer);
// Keep widget visible a moment, then clean up
setTimeout(() => {
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}, 3000);
}
}
}, 100);
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// No task actively in progress — show a "resume" hint
ctx.ui.notify(
"No running task found. Use /ralpi resume to continue execution.",
"warning",
try {
await resumeLoop(
ctx,
loopState.taskFile,
projectDir,
loopState.prdKey,
sendProgress,
config.model ?? ctx.model,
pi.getThinkingLevel(),
{
mode: loopState.mode,
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
autoReview: loopState.autoReview ?? config.execution.autoReview,
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
skipFinalStatus: false,
},
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
// Leave loop-active.json in place so the user can retry via
// /ralpi resume after addressing the underlying error.
}
return;
}
});
@@ -781,7 +592,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// 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,
// and reviewText/reviewPath for review messages so the expanded
// and reviewText/reviewPath/reviewResult for review messages so the expanded
// (Ctrl+O) view can render the full review body without truncation.
const sendProgress: SendChatMessage = (
content: string,
@@ -789,6 +600,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
},
) => {
pi.sendMessage({
@@ -800,6 +612,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
});
};
@@ -997,6 +810,122 @@ async function handleRun(
// ─── /ralpi resume ───────────────────────────────────────────────────────────
/**
* Resume core: given a resolved task file, project dir, and PRD key,
* build the remaining plan and execute it. Used by both the explicit
* `/ralpi resume` command and the auto-resume on session reload.
*
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
* passed to skip interactive prompts — this is how a reload resumes
* non-interactively using the snapshot stored in loop-active.json.
* When omitted, the user is prompted as usual.
*/
async function resumeLoop(
ctx: ExtensionContext,
taskFile: string,
projectDir: string,
prdKey: string | undefined,
sendChatMessage: SendChatMessage | undefined,
parentModel: unknown,
parentThinkingLevel: unknown,
options?: {
mode?: ExecutionMode;
autoCommit?: boolean;
autoReview?: boolean;
saveReviews?: boolean;
skipFinalStatus?: boolean;
},
): Promise<void> {
const project = parseTaskFile(taskFile);
if (!Array.isArray(project.tasks)) {
throw new Error(
`Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`,
);
}
const config = loadConfig(projectDir);
config.model = parentModel ?? ctx.model;
config.thinkingLevel = parentThinkingLevel;
const progress = new ProgressTracker(projectDir, taskFile, prdKey);
progress.setPaused(false);
// Any task left `in_progress` died with the previous session (ralpi runs
// agents in-process). Reset them to `pending` so the DAG re-schedules
// them cleanly. Without this they'd still be re-run (they're not in the
// completed set), but the progress.json would carry a stale in_progress
// state during the rebuild window.
const resetIds = progress.resetInProgressToPending();
if (resetIds.length > 0) {
// Keep the source-file checkboxes in sync so a later parse sees these
// tasks as `pending` rather than `in_progress`.
for (const id of resetIds) {
try {
updateTaskInFile(taskFile, id, "pending");
} catch {
// Best-effort — progress.json is the source of truth for scheduling.
}
}
ctx.ui.notify(
`Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`,
"info",
);
}
const completed = buildCompletedSet(progress, project);
const mode =
options?.mode ??
(await selectExecutionMode(ctx, project, taskFile, config));
let autoCommit: boolean;
let autoReview: boolean;
let saveReviews: boolean;
if (
options?.autoCommit !== undefined &&
options?.autoReview !== undefined &&
options?.saveReviews !== undefined
) {
autoCommit = options.autoCommit;
autoReview = options.autoReview;
saveReviews = options.saveReviews;
} else {
const opt = await selectLoopOptions(ctx, config);
autoCommit = opt.autoCommit;
autoReview = opt.autoReview;
saveReviews = opt.saveReviews;
}
config.execution.autoCommit = autoCommit;
config.execution.autoReview = autoReview;
config.execution.saveReviews = saveReviews;
const plan = buildPlanByMode(mode, project, completed);
// Print remaining batches before executing
const formattedPlan = formatExecutionPlan(plan);
if (mode === "parallel") {
ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
} else {
ctx.ui.notify(
`${formattedPlan}\n\nResuming sequential execution...`,
"info",
);
}
await executePlanBatches(
plan,
project,
taskFile,
config,
progress,
ctx,
mode,
sendChatMessage,
projectDir,
);
if (!options?.skipFinalStatus) {
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
}
}
async function handleResume(
ctx: ExtensionContext,
args: string[],
@@ -1042,54 +971,15 @@ async function handleResume(
prdKey = selected.prdKey;
}
const project = parseTaskFile(taskFile);
if (!Array.isArray(project.tasks)) {
throw new Error(
`Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`,
);
}
const config = loadConfig(projectDir);
config.model = parentModel ?? ctx.model;
config.thinkingLevel = parentThinkingLevel;
const progress = new ProgressTracker(projectDir, taskFile, prdKey);
progress.setPaused(false);
const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config);
const { autoCommit, autoReview, saveReviews } = await selectLoopOptions(
await resumeLoop(
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
const formattedPlan = formatExecutionPlan(plan);
if (mode === "parallel") {
ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
} else {
ctx.ui.notify(
`${formattedPlan}\n\nResuming sequential execution...`,
"info",
);
}
await executePlanBatches(
plan,
project,
taskFile,
config,
progress,
ctx,
mode,
sendChatMessage,
projectDir,
prdKey,
sendChatMessage,
parentModel,
parentThinkingLevel,
);
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
}
// ─── /ralpi next ─────────────────────────────────────────────────────────────