diff --git a/README.md b/README.md index ec562a2..3b17cf5 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,35 @@ Execute tasks from task files using DAG-based dependency resolution with persist ## Dependencies -1 -> 2 +1 -> 2,3 2 -> 3 ``` +#### Supported Dependency Formats + +The parser supports two dependency declaration styles in the `## Dependencies` section: + +**Arrow Notation** (recommended): +``` +1 -> 2,3,4 +5 -> 6 +``` +This means: "Task 1 must complete before tasks 2, 3, and 4 can start." + +**Natural Language**: +``` +13 depends on 17, 18, 19, 20 +14 depends on 13, 15, 16 +``` +This means: "Task 13 depends on tasks 17, 18, 19, and 20." + +**Parallel Groups** (informational only): +``` +1, 2, 3, 4 can be done in parallel +5, 6, 7, 8 can be done in parallel +``` +Note: These lines are ignored by the parser. Use explicit dependencies to control execution order. + ### Simple Checkbox Format ```markdown diff --git a/index.ts b/index.ts index d87d0be..2b3b7d9 100644 --- a/index.ts +++ b/index.ts @@ -1,26 +1,26 @@ 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 { - loadConfig, - resolveTaskArg, - formatProgressStatus, - formatAllPRDsStatus, - findProgressFile, + loadConfig, + resolveTaskArg, + formatProgressStatus, + formatAllPRDsStatus, + findProgressFile, } from "./src/utils"; const COMMANDS = ["status", "resume", "next", "reset"] as const; @@ -30,505 +30,513 @@ const COMMANDS = ["status", "resume", "next", "reset"] as const; * 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") + ); } // ─── 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 = ( + 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); - 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?: (content: string) => void, ): 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[0]!], 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 project = parseTaskFile(taskFile); - // Determine projectDir: prefer existing .ralph/ location, otherwise use cwd - 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 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 = new Set(progress.getCompletedTaskIds()); + const completed = new Set(progress.getCompletedTaskIds()); - // 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"); + // 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"); - // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches - const plan = useParallel - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); + // 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) { - if (progress.getState().paused) { - ctx.ui.notify( - "Execution paused. Use /ralph resume to continue.", - "warning", - ); - 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, - ); + 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); - } - } + 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 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; - } + 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?: (content: string) => void, ): Promise { - // If a task file arg is provided, find progress for that specific PRD - let taskFile: string; - let projectDir: string; - let found: ReturnType; + // If a task file arg is provided, find progress for that specific PRD + 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); + 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 = new Set(progress.getCompletedTaskIds()); - // Ask user for execution mode - const mode = await ctx.ui.select("Execution mode for this resume?", [ - "Parallel (DAG-optimized)", - "Sequential (one at a time)", - ]); - const useParallel = mode?.startsWith("Parallel"); + // 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"); - // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches - const plan = useParallel - ? buildExecutionPlan(project, completed) - : buildSequentialPlan(project, completed); + // 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 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); - } - } + 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?: (content: string) => void, ): 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); + 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 = new Set(progress.getCompletedTaskIds()); + 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( + 0, + [task], + project, + config, + progress, + ctx as any, + undefined, + 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(); - } + 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(); + } - ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); + 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); + 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`; + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; } diff --git a/src/executor.ts b/src/executor.ts index 0c4d7e2..58b7d41 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -71,27 +71,37 @@ export async function runTask( const taskHeader = `${task.id} · ${task.title}`; - // Live progress widget above the editor — animated spinner + tool call updates + // Live progress widget above the editor — animated spinner + tool call tree // Using setWidget instead of setWorkingMessage because the working message area // is only visible during parent agent streaming, not during extension command execution. const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frameIndex = 0; - let lastToolLabel = ""; const theme = ctx.ui.theme; + const MAX_COLLAPSED = 3; const toolCalls: ToolCallEntry[] = []; const updateWidget = () => { const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]); const lines = [`${frame} ${taskHeader}`]; + if (toolCalls.length > 0) { - lines.push( - theme.fg( - "dim", - ` ${toolCalls.length} tool${toolCalls.length !== 1 ? "s" : ""} · ${lastToolLabel}`, - ), - ); + const shown = toolCalls.slice(-MAX_COLLAPSED); + const remaining = toolCalls.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}`); + } } + ctx.ui.setWidget("ralph-task", lines); }; @@ -119,8 +129,6 @@ export async function runTask( name: event.toolName, label, }); - // Update widget with latest tool call info - lastToolLabel = `[${event.toolName}] ${label}`; updateWidget(); } }, diff --git a/src/parser.ts b/src/parser.ts index ba978be..caa70a5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -88,13 +88,38 @@ function parseFioFormat( } if (inDeps) { - const depMatch = line.match(/^(\d+)\s*->\s*(\d+)/); - if (depMatch) { - const [, from, to] = depMatch; + // Format 2: Arrow notation with multiple targets + // "01 -> 02,03,06 (description)" means 02, 03, 06 depend on 01 + const arrowMatch = line.match(/^(\d+)\s*->\s*([\d,\s]+?)(?:\s*\(|$)/); + if (arrowMatch) { + const [, from, targets] = arrowMatch; const fromId = `0${from}`; - const toId = `0${to}`; - if (!dependencies[fromId]) dependencies[fromId] = []; - dependencies[fromId].push(toId); + const targetIds = targets + .split(",") + .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] = []; + dependencies[toId].push(fromId); + } + } + + // Format 1: Natural language "X depends on A, B, C" + const dependsMatch = line.match(/^(\d+)\s+depends\s+on\s+([\d,\s]+)/i); + if (dependsMatch) { + const [, taskId, depsList] = dependsMatch; + const taskIdPadded = `0${taskId}`; + const depIds = depsList + .split(",") + .map((t) => t.trim()) + .filter((t) => t) + .map((t) => `0${t}`); + + if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; + dependencies[taskIdPadded].push(...depIds); } // Parse meta blocks for task configuration (timeout, etc.) @@ -126,6 +151,13 @@ function parseFioFormat( const objectiveMatch = content.match(/^#\s+(.+)$/m); const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined; + // Apply dependencies map to task.dependencies arrays + for (const task of tasks) { + if (dependencies[task.id]) { + task.dependencies = dependencies[task.id]; + } + } + return { tasks, dependencies,