Files
ralpi/index.ts

955 lines
29 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
import { parseTaskFile, updateTaskInFile } from "./src/parser";
import {
buildExecutionPlan,
buildSequentialPlan,
formatDependencyChain,
formatExecutionPlan,
} from "./src/dag";
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 {
loadConfig,
resolveTaskArg,
formatProgressStatus,
findProgressFile,
writeLoopActive,
deleteLoopActive,
readLoopActive,
findRalpiDir,
} from "./src/utils";
const COMMANDS = ["plan", "resume", "reset"] as const;
type ExecutionMode = "parallel" | "sequential";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Detect if a token looks like a file path rather than a subcommand.
* Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
*/
function looksLikePath(token: string): boolean {
return (
token.startsWith("@") ||
token.startsWith("/") ||
token.startsWith("./") ||
token.startsWith("../") ||
token.includes("/") ||
token.endsWith(".md") ||
token.endsWith(".yaml") ||
token.endsWith(".yml")
);
}
/** Build the set of completed tasks from progress tracker and PRD checkboxes. */
function buildCompletedSet(
progress: ProgressTracker,
project: import("./src/types").Project,
): Set<string> {
const completed = new Set(progress.getCompletedTaskIds());
for (const task of project.tasks) {
if (task.status === "completed") {
completed.add(task.id);
}
}
return completed;
}
/** Prompt user to select an execution mode with dependency validation. */
async function selectExecutionMode(
ctx: ExtensionContext,
project: import("./src/types").Project,
taskFile: string,
config: import("./src/types").RalpiConfig,
): Promise<ExecutionMode> {
const mode = await ctx.ui.select("Execution mode for this run?", [
`Parallel (where dependencies allow)[${config.execution.maxParallel} max]`,
"Sequential (one at a time)",
]);
const isParallel = mode?.startsWith("Parallel") ?? false;
if (!isParallel) return "sequential";
// Validate dependency graph for parallel mode
if (Object.keys(project.dependencies).length === 0) {
const hasDepsSection = await fs.promises
.readFile(taskFile, "utf-8")
.then((content) => /^##\s+Dependencies\s*$/m.test(content))
.catch(() => false);
if (hasDepsSection) {
const choice = await ctx.ui.select(
"Found ## Dependencies section but no valid dependencies were parsed.\n\n" +
"This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" +
"See README.md for supported dependency formats:\n" +
"- Arrow notation: `1 -> 2,3,4`\n" +
"- Natural language: `13 depends on 17, 18, 19, 20`\n\n" +
"Fall back to sequential mode?",
["Yes, use sequential", "No, continue with parallel"],
);
if (choice?.startsWith("Yes")) {
return "sequential";
}
}
}
return "parallel";
}
/** Build an execution plan based on the selected mode. */
function buildPlanByMode(
mode: ExecutionMode,
project: Parameters<typeof buildExecutionPlan>[0],
completed: Set<string>,
) {
return mode === "parallel"
? buildExecutionPlan(project, completed)
: buildSequentialPlan(project, completed);
}
/** Run all batches in a plan, updating the task file after each batch. */
async function executePlanBatches(
plan: ReturnType<typeof buildPlanByMode>,
project: Parameters<typeof buildExecutionPlan>[0],
taskFile: string,
config: import("./src/types").RalpiConfig,
progress: ProgressTracker,
ctx: ExtensionContext,
mode: ExecutionMode,
sendChatMessage?: SendChatMessage,
projectDir?: string,
): Promise<void> {
// Write loop-active marker so widgets can be re-instantiated after a reload
if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, {
taskFile,
mode,
startedAt: new Date().toISOString(),
taskIds: allTaskIds,
prdKey: progress.getKey(),
});
}
// Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds());
try {
for (const batch of plan.batches) {
if (progress.getState().paused) {
ctx.ui.notify(
"Execution paused. Use /ralpi resume to continue.",
"warning",
);
return;
}
if (!Array.isArray(batch.tasks)) {
throw new Error(
`Batch ${
batch.batchIndex
} has invalid tasks: expected array, got ${typeof batch.tasks}`,
);
}
await executeBatch(
batch.tasks,
project,
config,
progress,
ctx,
{ parallel: mode === "parallel" },
sendChatMessage,
projectDir,
);
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
// Update failed task IDs after batch completes
const newFailed = progress.getFailedTaskIds();
for (const id of newFailed) {
failedTaskIds.add(id);
}
// In sequential mode, stop after any failure
if (mode === "sequential" && failedTaskIds.size > 0) {
break;
}
// In parallel mode, rebuild the plan to filter out newly blocked tasks
if (mode === "parallel") {
// Use buildCompletedSet to include file-based [x] completions
// (progress.getCompletedTaskIds() only knows about tasks completed
// during THIS execution session — tasks that were already [x] in the
// file before the run started would be re-included and re-executed).
const completed = buildCompletedSet(progress, project);
const newPlan = buildExecutionPlan(
project,
completed,
undefined,
failedTaskIds,
);
// Keep processed batches (up to current batch), replace the rest
// with the fresh plan — its batchIndex restarts at 0, so filtering
// by batchIndex > currentIdx would incorrectly drop the next batch.
const processedCount = plan.batches.indexOf(batch) + 1;
plan.batches.length = processedCount;
plan.batches.push(...newPlan.batches);
// Skip if nothing remaining
if (plan.batches.length === processedCount) {
break;
}
}
}
} finally {
if (projectDir) {
deleteLoopActive(projectDir);
}
}
}
// ─── Extension Entry ────────────────────────────────────────────────────────
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// Register custom message renderer for ralpi progress messages.
// Renders an expandable tool-call tree: collapsed shows last 3 + "N more",
// expanded (Ctrl+O) shows every tool call.
pi.registerMessageRenderer(
"ralpi-progress",
(message, { expanded }, theme) => {
const details = message.details as
| {
phase?: string;
toolCalls?: Array<{ name: string; label: string }>;
}
| undefined;
const MAX_COLLAPSED = 3;
const lines: string[] = [];
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
lines.push(String(message.content));
// Build tool-call tree
if (details?.toolCalls && details.toolCalls.length > 0) {
const all = details.toolCalls;
if (expanded) {
// Expanded: show ALL tool calls
for (let i = 0; i < all.length; i++) {
const entry = all[i];
const isLast = i === all.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
}
} else {
// Collapsed: last N + "X more"
const shown = all.slice(-MAX_COLLAPSED);
const remaining = all.length - shown.length;
if (remaining > 0) {
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
}
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
}
}
}
const text = lines.join("\n");
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
box.addChild(new Text(text, 0, 0));
return box;
},
);
// ─── Reload detection: re-instantiate widgets 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.
pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return;
// Find the ralpi project directory
const projectDir = findRalpiDir(ctx.cwd);
if (!projectDir) return;
// Check if a task execution loop was active before the reload
const loopState = readLoopActive(projectDir);
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). */
const readTasks = (): Record<string, { status: string }> | null => {
try {
const raw = fs.readFileSync(progressPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, any>;
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
} catch {
return null;
}
};
// Early exit: if all tasks already finished during the reload, just clean up
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");
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",
);
// 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";
});
// 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(),
};
});
// 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",
);
}
}
});
pi.registerCommand("ralpi", {
description:
"Execute tasks from a task file using DAG-based dependency resolution",
handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
// 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.
const sendProgress: SendChatMessage = (
content: string,
meta?: { toolCalls?: Array<{ name: string; label: string }> },
) => {
pi.sendMessage({
customType: "ralpi-progress",
content,
display: true,
details: { phase: "progress", toolCalls: meta?.toolCalls },
});
};
// If no args, show plan. If first token looks like a path (@path, /path, ./path),
// route to run so the execution mode prompt fires.
if (parts.length === 0) {
return handlePlan(ctx, parts);
}
if (looksLikePath(parts[0])) {
return handleRun(
ctx,
parts,
sendProgress,
ctx.model,
pi.getThinkingLevel(),
);
}
const command = parts[0];
switch (command) {
case "run":
return handleRun(
ctx,
parts.slice(1),
sendProgress,
ctx.model,
pi.getThinkingLevel(),
);
case "plan":
pi.sendUserMessage("@task-manager");
ctx.ui.notify("Opening Task Manager...", "info");
return;
case "resume":
return handleResume(
ctx,
parts.slice(1),
sendProgress,
ctx.model,
pi.getThinkingLevel(),
);
case "reset":
return handleReset(ctx, parts.slice(1));
default: {
// Auto-discover progress and offer resume
const found = findProgressFile(process.cwd());
if (found) {
ctx.ui.notify(
`Unknown command: ${command}\n\nFound existing progress in ${
found.path
}\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join(
", ",
)}`,
"warning",
);
} else {
ctx.ui.notify(
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
"error",
);
}
}
}
},
});
}
// ─── /ralpi plan ─────────────────────────────────────────────────────────────
async function handlePlan(
ctx: ExtensionContext,
args: string[],
): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
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 planPrompt = buildPlanPrompt(project);
const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan);
ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info");
}
// ─── /ralpi run ──────────────────────────────────────────────────────────────
async function handleRun(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: SendChatMessage,
parentModel?: unknown,
parentThinkingLevel?: unknown,
): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
// If targeting a specific task file and there's existing progress for it,
// auto-resume instead of starting fresh
const existingProgress = findProgressFile(process.cwd(), taskFile);
if (existingProgress) {
return handleResume(
ctx,
args.slice(0, 1),
sendChatMessage,
parentModel,
parentThinkingLevel,
);
}
// No existing progress for this task — check for any progress at all
const found = findProgressFile(process.cwd());
if (found && !args[0]) {
// Offer to resume instead of starting fresh
const shouldResume = await ctx.ui.select(
"Found existing ralpi progress. Resume?",
["Yes, resume", "No, start fresh"],
);
if (shouldResume?.startsWith("Yes")) {
return handleResume(
ctx,
[],
sendChatMessage,
parentModel,
parentThinkingLevel,
);
}
}
const projectDir = found
? path.dirname(path.dirname(found.path))
: process.cwd();
const project = parseTaskFile(taskFile);
const config = loadConfig(projectDir);
config.model = parentModel ?? ctx.model;
config.thinkingLevel = parentThinkingLevel;
const progress = new ProgressTracker(projectDir, taskFile);
const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config);
const plan = buildPlanByMode(mode, project, completed);
// Show dependency chain + execution plan before starting
const depChain = formatDependencyChain(project);
const formattedPlan = formatExecutionPlan(plan);
if (mode === "parallel") {
ctx.ui.notify(
`${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`,
"info",
);
} else {
ctx.ui.notify(
`${formattedPlan}\n\nStarting sequential execution...`,
"info",
);
}
await executePlanBatches(
plan,
project,
taskFile,
config,
progress,
ctx,
mode,
sendChatMessage,
projectDir,
);
const state = progress.getState();
const output = formatProgressStatus(state);
const reflections = progress.getAllReflections();
if (reflections.length > 0) {
ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info");
return;
}
ctx.ui.notify(output, "info");
}
// ─── /ralpi status ───────────────────────────────────────────────────────────
// (removed — use /ralpi plan to invoke @task-manager)
// ─── /ralpi resume ───────────────────────────────────────────────────────────
async function handleResume(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: SendChatMessage,
parentModel?: unknown,
parentThinkingLevel?: unknown,
): Promise<void> {
let taskFile: string;
let projectDir: string;
let found: ReturnType<typeof findProgressFile>;
if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile);
if (!found) {
ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
} else {
found = findProgressFile(process.cwd());
if (!found) {
ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
// For no-arg resume, use the first PRD's source path or legacy sourcePath
taskFile = found.state.prds
? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath;
}
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, found.prdKey);
progress.setPaused(false);
const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config);
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,
);
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
}
// ─── /ralpi next ─────────────────────────────────────────────────────────────
// (removed — use /ralpi run to execute tasks)
// ─── /ralpi reset ────────────────────────────────────────────────────────────
async function handleReset(
ctx: ExtensionContext,
args: string[],
): Promise<void> {
if (args[0]) {
const taskFile = resolveTaskArg(args[0], process.cwd());
const found = findProgressFile(process.cwd(), taskFile);
const projectDir = found
? path.dirname(path.dirname(found.path))
: process.cwd();
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
progress.reset();
} else {
const found = findProgressFile(process.cwd());
if (!found) {
ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
"warning",
);
return;
}
const projectDir = path.dirname(path.dirname(found.path));
const progress = new ProgressTracker(
projectDir,
found.state.prds
? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath,
);
progress.reset();
}
ctx.ui.notify("Progress reset. All task statuses cleared.", "info");
}