From 73d1ee1a47448fa558bfa978126926fafa56956b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 30 May 2026 20:35:02 -0400 Subject: [PATCH] dependency parsing broken --- index.ts | 947 ++++++++++++++++++++++++++---------------------- src/executor.ts | 18 +- src/parser.ts | 4 +- 3 files changed, 522 insertions(+), 447 deletions(-) diff --git a/index.ts b/index.ts index 2b3b7d9..b00553e 100644 --- a/index.ts +++ b/index.ts @@ -1,542 +1,611 @@ +import * as fs from "node:fs"; import * as path from "node:path"; import type { - ExtensionAPI, - ExtensionContext, + 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, - formatExecutionPlan, - getReadyTasks, + buildExecutionPlan, + buildSequentialPlan, + formatExecutionPlan, + getReadyTasks, } from "./src/dag"; import { ProgressTracker } from "./src/progress"; import { buildPlanPrompt } from "./src/prompts"; import { formatReflections } from "./src/reflection"; -import { executeBatch } from "./src/executor"; +import { executeBatch, type SendChatMessage } from "./src/executor"; import { - loadConfig, - resolveTaskArg, - formatProgressStatus, - formatAllPRDsStatus, - findProgressFile, + loadConfig, + resolveTaskArg, + formatProgressStatus, + formatAllPRDsStatus, + findProgressFile, } from "./src/utils"; const COMMANDS = ["status", "resume", "next", "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") - ); + 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 { + 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, +): Promise { + const mode = await ctx.ui.select("Execution mode for this run?", [ + "Parallel (where dependencies allow)", + "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[0], + completed: Set, +) { + 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, + project: Parameters[0], + taskFile: string, + config: import("./src/types").RalphConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + mode: ExecutionMode, + sendChatMessage?: SendChatMessage, + projectDir?: string, +): Promise { + for (const batch of plan.batches) { + if (progress.getState().paused) { + ctx.ui.notify( + "Execution paused. Use /ralph 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); + } + } } // ─── Extension Entry ──────────────────────────────────────────────────────── export default function ralphLoopExtension(pi: ExtensionAPI): void { - // Register custom message renderer for ralph progress messages. - // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", - // expanded (Ctrl+O) shows every tool call. - pi.registerMessageRenderer( - "ralph-progress", - (message, { expanded }, theme) => { - const details = message.details as - | { - phase?: string; - toolCalls?: Array<{ name: string; label: string }>; - } - | undefined; + // Register custom message renderer for ralph progress messages. + // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", + // expanded (Ctrl+O) shows every tool call. + pi.registerMessageRenderer( + "ralph-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[] = []; + const MAX_COLLAPSED = 3; + const lines: string[] = []; - // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" - lines.push(String(message.content)); + // 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; + // 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 (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`)); - } + 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}`); - } - } - } + 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; - }, - ); + 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; + }, + ); - pi.registerCommand("ralph", { - 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); + pi.registerCommand("ralph", { + 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 "ralph-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 = ( - content: string, - meta?: { toolCalls?: Array<{ name: string; label: string }> }, - ) => { - pi.sendMessage({ - customType: "ralph-progress", - content, - display: true, - details: { phase: "progress", toolCalls: meta?.toolCalls }, - }); - }; + // Wraps pi.sendMessage() for posting status to the chat history. + // Uses "ralph-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: "ralph-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); - } + // 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); + } - const command = parts[0]; - switch (command) { - case "run": - return handleRun(ctx, parts.slice(1), sendProgress); - case "plan": - return handlePlan(ctx, parts.slice(1)); - case "status": - return handleStatus(ctx, parts.slice(1)); - case "resume": - return handleResume(ctx, parts.slice(1), sendProgress); - case "next": - return handleNext(ctx, parts.slice(1), sendProgress); - 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 /ralph resume to continue.\n\nAvailable: ${COMMANDS.join( - ", ", - )}`, - "warning", - ); - } else { - ctx.ui.notify( - `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`, - "error", - ); - } - } - } - }, - }); + const command = parts[0]; + switch (command) { + case "run": + return handleRun(ctx, parts.slice(1), sendProgress); + case "plan": + return handlePlan(ctx, parts.slice(1)); + case "status": + return handleStatus(ctx, parts.slice(1)); + case "resume": + return handleResume(ctx, parts.slice(1), sendProgress); + case "next": + return handleNext(ctx, parts.slice(1), sendProgress); + 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 /ralph resume to continue.\n\nAvailable: ${COMMANDS.join( + ", ", + )}`, + "warning", + ); + } else { + ctx.ui.notify( + `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`, + "error", + ); + } + } + } + }, + }); } // ─── /ralph plan ───────────────────────────────────────────────────────────── async function handlePlan( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); - const project = parseTaskFile(taskFile); + 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); + const planPrompt = buildPlanPrompt(project); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); - ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); + ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); } // ─── /ralph run ────────────────────────────────────────────────────────────── async function handleRun( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: (content: string) => void, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, ): Promise { - const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); + 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[0]!], sendChatMessage); - } + // 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); + } - // 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 ralph progress. Resume?", - ["Yes, resume", "No, start fresh"], - ); + // 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 ralph progress. Resume?", + ["Yes, resume", "No, start fresh"], + ); - if (shouldResume?.startsWith("Yes")) { - return handleResume(ctx, [], sendChatMessage); - } - } + if (shouldResume?.startsWith("Yes")) { + return handleResume(ctx, [], sendChatMessage); + } + } - const project = parseTaskFile(taskFile); + const projectDir = found + ? path.dirname(path.dirname(found.path)) + : process.cwd(); - // Determine projectDir: prefer existing .ralph/ location, otherwise use cwd - const projectDir = found - ? path.dirname(path.dirname(found.path)) - : process.cwd(); + const project = parseTaskFile(taskFile); + const config = loadConfig(projectDir); + const progress = new ProgressTracker(projectDir, taskFile); - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile); + // Set initial status + ctx.ui.setStatus( + "ralph", + `Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`, + ); - // Set initial status - ctx.ui.setStatus( - "ralph", - `Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`, - ); + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile); + const plan = buildPlanByMode(mode, project, completed); - const completed = new Set(progress.getCompletedTaskIds()); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); - // Ask user for execution mode - const mode = await ctx.ui.select("Execution mode for this run?", [ - "Parallel (DAG-optimized)", - "Sequential (one at a time)", - ]); - const useParallel = mode?.startsWith("Parallel"); + const state = progress.getState(); + const output = formatProgressStatus(state); - // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches - const plan = useParallel - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); + const reflections = progress.getAllReflections(); + if (reflections.length > 0) { + ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); + return; + } - for (const batch of plan.batches) { - if (progress.getState().paused) { - ctx.ui.notify( - "Execution paused. Use /ralph resume to continue.", - "warning", - ); - return; - } - - await executeBatch( - batch.batchIndex, - batch.tasks, - project, - config, - progress, - ctx as any, - { parallel: useParallel }, - sendChatMessage, - projectDir, - ); - - for (const task of batch.tasks) { - const status = progress.getTaskStatus(task.id); - updateTaskInFile(taskFile, task.id, status); - } - } - - 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"); + ctx.ui.notify(output, "info"); } // ─── /ralph status ─────────────────────────────────────────────────────────── async function handleStatus( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - if (args[0]) { - const taskFile = resolveTaskArg(args[0], process.cwd()); - const existingProgress = findProgressFile(process.cwd(), taskFile); - if (existingProgress) { - const projectDir = path.dirname(path.dirname(existingProgress.path)); - const progress = new ProgressTracker( - projectDir, - taskFile, - existingProgress.prdKey, - ); - ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); - return; - } - // No progress yet for this task — parse and show plan instead - const project = parseTaskFile(taskFile); - ctx.ui.notify( - `No progress for ${path.basename(taskFile)}. ${ - project.tasks.length - } tasks found.\nUse /ralph run ${args[0]} to start.`, - "info", - ); - return; - } + if (args[0]) { + const taskFile = resolveTaskArg(args[0], process.cwd()); + const existingProgress = findProgressFile(process.cwd(), taskFile); + if (existingProgress) { + const projectDir = path.dirname(path.dirname(existingProgress.path)); + const progress = new ProgressTracker( + projectDir, + taskFile, + existingProgress.prdKey, + ); + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + return; + } + // No progress yet for this task — parse and show plan instead + const project = parseTaskFile(taskFile); + ctx.ui.notify( + `No progress for ${path.basename(taskFile)}. ${ + project.tasks.length + } tasks found.\nUse /ralph run ${args[0]} to start.`, + "info", + ); + return; + } - const found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralph/progress.json found. Start with /ralph run [task-file]", - "warning", - ); - return; - } + const found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralph/progress.json found. Start with /ralph run [task-file]", + "warning", + ); + return; + } - ctx.ui.notify(formatAllPRDsStatus(found.state), "info"); + ctx.ui.notify(formatAllPRDsStatus(found.state), "info"); } // ─── /ralph resume ─────────────────────────────────────────────────────────── async function handleResume( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: (content: string) => void, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, ): Promise { - // If a task file arg is provided, find progress for that specific PRD - let taskFile: string; - let projectDir: string; - let found: ReturnType; + let taskFile: string; + let projectDir: string; + let found: ReturnType; - 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 /ralph run ${args[0]}`, - "warning", - ); - return; - } - projectDir = path.dirname(path.dirname(found.path)); - } else { - found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralph/progress.json found. Start with /ralph 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; - } + 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 /ralph run ${args[0]}`, + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralph/progress.json found. Start with /ralph 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); - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile, found.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); + const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); - progress.setPaused(false); + progress.setPaused(false); - // Set resume status - ctx.ui.setStatus("ralph", `Resuming from ${path.basename(taskFile)}`); + // Set resume status + ctx.ui.setStatus("ralph", `Resuming from ${path.basename(taskFile)}`); - const completed = new Set(progress.getCompletedTaskIds()); + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile); + const plan = buildPlanByMode(mode, project, completed); - // Ask user for execution mode - const mode = await ctx.ui.select("Execution mode for this run?", [ - "Parallel (DAG-optimized)", - "Sequential (one at a time)", - ]); - const useParallel = mode?.startsWith("Parallel"); + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); - // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches - const plan = useParallel - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); - - for (const batch of plan.batches) { - await executeBatch( - batch.batchIndex, - batch.tasks, - project, - config, - progress, - ctx as any, - { parallel: useParallel }, - sendChatMessage, - projectDir, - ); - - for (const task of batch.tasks) { - const status = progress.getTaskStatus(task.id); - updateTaskInFile(taskFile, task.id, status); - } - } - - ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); } // ─── /ralph next ───────────────────────────────────────────────────────────── async function handleNext( - ctx: ExtensionContext, - args: string[], - sendChatMessage?: (content: string) => void, + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, ): Promise { - let taskFile: string; - let projectDir: string; - let found: ReturnType; + let taskFile: string; + let projectDir: string; + let found: ReturnType; - if (args[0]) { - taskFile = resolveTaskArg(args[0], process.cwd()); - found = findProgressFile(process.cwd(), taskFile); - if (found) { - projectDir = path.dirname(path.dirname(found.path)); - } else { - projectDir = process.cwd(); - } - } else { - found = findProgressFile(process.cwd()); - if (!found) { - ctx.ui.notify( - "No .ralph/progress.json found. Start with /ralph run [task-file]", - "warning", - ); - return; - } - taskFile = found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath; - projectDir = path.dirname(path.dirname(found.path)); - } + if (args[0]) { + taskFile = resolveTaskArg(args[0], process.cwd()); + found = findProgressFile(process.cwd(), taskFile); + if (found) { + projectDir = path.dirname(path.dirname(found.path)); + } else { + projectDir = process.cwd(); + } + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralph/progress.json found. Start with /ralph run [task-file]", + "warning", + ); + return; + } + taskFile = found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath; + projectDir = path.dirname(path.dirname(found.path)); + } - const project = parseTaskFile(taskFile); - const config = loadConfig(projectDir); - const progress = new ProgressTracker(projectDir, taskFile, found?.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); + const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); - const completed = new Set(progress.getCompletedTaskIds()); - const ready = getReadyTasks(project, completed); + const completed = buildCompletedSet(progress, project); + const ready = getReadyTasks(project, completed); - if (ready.length === 0) { - ctx.ui.notify( - "No tasks ready to execute. All tasks completed or blocked.", - "info", - ); - return; - } + if (ready.length === 0) { + ctx.ui.notify( + "No tasks ready to execute. All tasks completed or blocked.", + "info", + ); + return; + } - const nextBatch = ready.slice( - 0, - config.execution.maxParallel || ready.length, - ); + const nextBatch = ready.slice( + 0, + config.execution.maxParallel || ready.length, + ); - for (const task of nextBatch) { - await executeBatch( - 0, - [task], - project, - config, - progress, - ctx as any, - undefined, - sendChatMessage, - projectDir, - ); - updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id)); - } + for (const task of nextBatch) { + await executeBatch( + [task], + project, + config, + progress, + ctx, + { parallel: false }, + sendChatMessage, + projectDir, + ); + updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id)); + } - ctx.ui.notify( - `Executed: ${nextBatch - .map((t) => t.id) - .join(", ")}\n\n${formatProgressStatus(progress.getState())}`, - "info", - ); + ctx.ui.notify( + `Executed: ${nextBatch + .map((t) => t.id) + .join(", ")}\n\n${formatProgressStatus(progress.getState())}`, + "info", + ); } // ─── /ralph reset ──────────────────────────────────────────────────────────── async function handleReset( - ctx: ExtensionContext, - args: string[], + ctx: ExtensionContext, + args: string[], ): Promise { - let projectDir: string; - if (args[0]) { - const taskFile = resolveTaskArg(args[0], process.cwd()); - const found = findProgressFile(process.cwd(), taskFile); - 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 .ralph/progress.json found. Start with /ralph 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(); - } + 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 .ralph/progress.json found. Start with /ralph 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"); -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; + ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); } diff --git a/src/executor.ts b/src/executor.ts index 58b7d41..841a33f 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import type { Task, Project, Reflection, ToolUsage } from "./types"; import type { RalphConfig } from "./types"; import type { ProgressTracker } from "./progress"; -import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { buildTaskPrompt } from "./prompts"; import { extractReflection } from "./reflection"; import { @@ -36,7 +36,7 @@ export async function runTask( project: Project, config: RalphConfig, depReflections: Reflection[], - ctx: ExtensionCommandContext, + ctx: ExtensionContext, sendChatMessage?: SendChatMessage, projectDir: string = project.sourceDir, ): Promise<{ @@ -210,16 +210,22 @@ function saveSessionOutput( * Execute a batch of tasks (sequentially or in parallel) */ export async function executeBatch( - _batchIndex: number, tasks: Task[], project: Project, config: RalphConfig, progress: ProgressTracker, - ctx: ExtensionCommandContext, + ctx: ExtensionContext, options?: { parallel?: boolean }, sendChatMessage?: SendChatMessage, projectDir?: string, ): Promise { + // Defensive: ensure tasks is an iterable array + if (!Array.isArray(tasks)) { + throw new Error( + `executeBatch received invalid tasks: expected array, got ${typeof tasks}`, + ); + } + // Check if we should run parallel const shouldParallel = options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0; @@ -259,7 +265,7 @@ async function executeBatchParallel( project: Project, config: RalphConfig, progress: ProgressTracker, - ctx: ExtensionCommandContext, + ctx: ExtensionContext, sendChatMessage?: SendChatMessage, projectDir?: string, ): Promise { @@ -300,7 +306,7 @@ async function executeTask( project: Project, config: RalphConfig, progress: ProgressTracker, - ctx: ExtensionCommandContext, + ctx: ExtensionContext, sendChatMessage?: SendChatMessage, projectDir: string = project.sourceDir, ): Promise { diff --git a/src/parser.ts b/src/parser.ts index caa70a5..a0c9970 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -99,7 +99,7 @@ function parseFioFormat( .map((t) => t.trim()) .filter((t) => t) .map((t) => `0${t}`); - + // Each target depends on the source for (const toId of targetIds) { if (!dependencies[toId]) dependencies[toId] = []; @@ -117,7 +117,7 @@ function parseFioFormat( .map((t) => t.trim()) .filter((t) => t) .map((t) => `0${t}`); - + if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; dependencies[taskIdPadded].push(...depIds); }