This commit is contained in:
2026-05-30 20:21:37 -04:00
parent 919113430a
commit fcc0aa618e
4 changed files with 506 additions and 433 deletions

View File

@@ -44,10 +44,35 @@ Execute tasks from task files using DAG-based dependency resolution with persist
## Dependencies ## Dependencies
1 -> 2 1 -> 2,3
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 ### Simple Checkbox Format
```markdown ```markdown

840
index.ts
View File

@@ -1,26 +1,26 @@
import * as path from "node:path"; import * as path from "node:path";
import type { import type {
ExtensionAPI, ExtensionAPI,
ExtensionContext, ExtensionContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui"; import { Box, Text } from "@earendil-works/pi-tui";
import { parseTaskFile, updateTaskInFile } from "./src/parser"; import { parseTaskFile, updateTaskInFile } from "./src/parser";
import { import {
buildExecutionPlan, buildExecutionPlan,
buildSequentialPlan, buildSequentialPlan,
formatExecutionPlan, formatExecutionPlan,
getReadyTasks, getReadyTasks,
} from "./src/dag"; } from "./src/dag";
import { ProgressTracker } from "./src/progress"; import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts"; import { buildPlanPrompt } from "./src/prompts";
import { formatReflections } from "./src/reflection"; import { formatReflections } from "./src/reflection";
import { executeBatch } from "./src/executor"; import { executeBatch } from "./src/executor";
import { import {
loadConfig, loadConfig,
resolveTaskArg, resolveTaskArg,
formatProgressStatus, formatProgressStatus,
formatAllPRDsStatus, formatAllPRDsStatus,
findProgressFile, findProgressFile,
} from "./src/utils"; } from "./src/utils";
const COMMANDS = ["status", "resume", "next", "reset"] as const; 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 * Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
*/ */
function looksLikePath(token: string): boolean { function looksLikePath(token: string): boolean {
return ( return (
token.startsWith("@") || token.startsWith("@") ||
token.startsWith("/") || token.startsWith("/") ||
token.startsWith("./") || token.startsWith("./") ||
token.startsWith("../") || token.startsWith("../") ||
token.includes("/") || token.includes("/") ||
token.endsWith(".md") || token.endsWith(".md") ||
token.endsWith(".yaml") || token.endsWith(".yaml") ||
token.endsWith(".yml") token.endsWith(".yml")
); );
} }
// ─── Extension Entry ──────────────────────────────────────────────────────── // ─── Extension Entry ────────────────────────────────────────────────────────
export default function ralphLoopExtension(pi: ExtensionAPI): void { export default function ralphLoopExtension(pi: ExtensionAPI): void {
// Register custom message renderer for ralph progress messages. // Register custom message renderer for ralph progress messages.
// Renders an expandable tool-call tree: collapsed shows last 3 + "N more", // Renders an expandable tool-call tree: collapsed shows last 3 + "N more",
// expanded (Ctrl+O) shows every tool call. // expanded (Ctrl+O) shows every tool call.
pi.registerMessageRenderer( pi.registerMessageRenderer(
"ralph-progress", "ralph-progress",
(message, { expanded }, theme) => { (message, { expanded }, theme) => {
const details = message.details as const details = message.details as
| { | {
phase?: string; phase?: string;
toolCalls?: Array<{ name: string; label: string }>; toolCalls?: Array<{ name: string; label: string }>;
} }
| undefined; | undefined;
const MAX_COLLAPSED = 3; const MAX_COLLAPSED = 3;
const lines: string[] = []; const lines: string[] = [];
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
lines.push(String(message.content)); lines.push(String(message.content));
// Build tool-call tree // Build tool-call tree
if (details?.toolCalls && details.toolCalls.length > 0) { if (details?.toolCalls && details.toolCalls.length > 0) {
const all = details.toolCalls; const all = details.toolCalls;
if (expanded) { if (expanded) {
// Expanded: show ALL tool calls // Expanded: show ALL tool calls
for (let i = 0; i < all.length; i++) { for (let i = 0; i < all.length; i++) {
const entry = all[i]; const entry = all[i];
const isLast = i === all.length - 1; const isLast = i === all.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`); const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`); lines.push(`${branch}${tag} ${entry.label}`);
} }
} else { } else {
// Collapsed: last N + "X more" // Collapsed: last N + "X more"
const shown = all.slice(-MAX_COLLAPSED); const shown = all.slice(-MAX_COLLAPSED);
const remaining = all.length - shown.length; const remaining = all.length - shown.length;
if (remaining > 0) { if (remaining > 0) {
lines.push(theme.fg("dim", ` ├── ${remaining} more`)); lines.push(theme.fg("dim", ` ├── ${remaining} more`));
} }
for (let i = 0; i < shown.length; i++) { for (let i = 0; i < shown.length; i++) {
const entry = shown[i]; const entry = shown[i];
const isLast = i === shown.length - 1; const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── "; const branch = isLast ? " └── " : " ├── ";
const tag = theme.fg("accent", `[${entry.name}]`); const tag = theme.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`); lines.push(`${branch}${tag} ${entry.label}`);
} }
} }
} }
const text = lines.join("\n"); const text = lines.join("\n");
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
box.addChild(new Text(text, 0, 0)); box.addChild(new Text(text, 0, 0));
return box; return box;
}, },
); );
pi.registerCommand("ralph", { pi.registerCommand("ralph", {
description: description:
"Execute tasks from a task file using DAG-based dependency resolution", "Execute tasks from a task file using DAG-based dependency resolution",
handler: async (args: string, ctx: ExtensionContext) => { handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean); const parts = (args || "").trim().split(/\s+/).filter(Boolean);
// Wraps pi.sendMessage() for posting status to the chat history. // Wraps pi.sendMessage() for posting status to the chat history.
// Uses "ralph-progress" customType with a "progress" phase so the // Uses "ralph-progress" customType with a "progress" phase so the
// renderer omits the label prefix entirely (no [INFO] etc.). // renderer omits the label prefix entirely (no [INFO] etc.).
// Accepts an optional meta object with toolCalls for the expandable view. // Accepts an optional meta object with toolCalls for the expandable view.
const sendProgress = ( const sendProgress = (
content: string, content: string,
meta?: { toolCalls?: Array<{ name: string; label: string }> }, meta?: { toolCalls?: Array<{ name: string; label: string }> },
) => { ) => {
pi.sendMessage({ pi.sendMessage({
customType: "ralph-progress", customType: "ralph-progress",
content, content,
display: true, display: true,
details: { phase: "progress", toolCalls: meta?.toolCalls }, details: { phase: "progress", toolCalls: meta?.toolCalls },
}); });
}; };
// If no args, show plan. If first token looks like a path (@path, /path, ./path), // If no args, show plan. If first token looks like a path (@path, /path, ./path),
// route to run so the execution mode prompt fires. // route to run so the execution mode prompt fires.
if (parts.length === 0) { if (parts.length === 0) {
return handlePlan(ctx, parts); return handlePlan(ctx, parts);
} }
if (looksLikePath(parts[0])) { if (looksLikePath(parts[0])) {
return handleRun(ctx, parts, sendProgress); return handleRun(ctx, parts, sendProgress);
} }
const command = parts[0]; const command = parts[0];
switch (command) { switch (command) {
case "run": case "run":
return handleRun(ctx, parts.slice(1), sendProgress); return handleRun(ctx, parts.slice(1), sendProgress);
case "plan": case "plan":
return handlePlan(ctx, parts.slice(1)); return handlePlan(ctx, parts.slice(1));
case "status": case "status":
return handleStatus(ctx, parts.slice(1)); return handleStatus(ctx, parts.slice(1));
case "resume": case "resume":
return handleResume(ctx, parts.slice(1), sendProgress); return handleResume(ctx, parts.slice(1), sendProgress);
case "next": case "next":
return handleNext(ctx, parts.slice(1), sendProgress); return handleNext(ctx, parts.slice(1), sendProgress);
case "reset": case "reset":
return handleReset(ctx, parts.slice(1)); return handleReset(ctx, parts.slice(1));
default: { default: {
// Auto-discover progress and offer resume // Auto-discover progress and offer resume
const found = findProgressFile(process.cwd()); const found = findProgressFile(process.cwd());
if (found) { if (found) {
ctx.ui.notify( ctx.ui.notify(
`Unknown command: ${command}\n\nFound existing progress in ${found.path}\nUse /ralph resume to continue.\n\nAvailable: ${COMMANDS.join(", ")}`, `Unknown command: ${command}\n\nFound existing progress in ${
"warning", found.path
); }\nUse /ralph resume to continue.\n\nAvailable: ${COMMANDS.join(
} else { ", ",
ctx.ui.notify( )}`,
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`, "warning",
"error", );
); } else {
} ctx.ui.notify(
} `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
} "error",
}, );
}); }
}
}
},
});
} }
// ─── /ralph plan ───────────────────────────────────────────────────────────── // ─── /ralph plan ─────────────────────────────────────────────────────────────
async function handlePlan( async function handlePlan(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
const planPrompt = buildPlanPrompt(project); const planPrompt = buildPlanPrompt(project);
const plan = buildExecutionPlan(project, new Set()); const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan); const formatted = formatExecutionPlan(plan);
ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info");
} }
// ─── /ralph run ────────────────────────────────────────────────────────────── // ─── /ralph run ──────────────────────────────────────────────────────────────
async function handleRun( async function handleRun(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
sendChatMessage?: (content: string) => void, sendChatMessage?: (content: string) => void,
): Promise<void> { ): Promise<void> {
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, // If targeting a specific task file and there's existing progress for it,
// auto-resume instead of starting fresh // auto-resume instead of starting fresh
const existingProgress = findProgressFile(process.cwd(), taskFile); const existingProgress = findProgressFile(process.cwd(), taskFile);
if (existingProgress) { if (existingProgress) {
return handleResume(ctx, [args[0]!], sendChatMessage); return handleResume(ctx, [args[0]!], sendChatMessage);
} }
// No existing progress for this task — check for any progress at all // No existing progress for this task — check for any progress at all
const found = findProgressFile(process.cwd()); const found = findProgressFile(process.cwd());
if (found && !args[0]) { if (found && !args[0]) {
// Offer to resume instead of starting fresh // Offer to resume instead of starting fresh
const shouldResume = await ctx.ui.select( const shouldResume = await ctx.ui.select(
"Found existing ralph progress. Resume?", "Found existing ralph progress. Resume?",
["Yes, resume", "No, start fresh"], ["Yes, resume", "No, start fresh"],
); );
if (shouldResume?.startsWith("Yes")) { if (shouldResume?.startsWith("Yes")) {
return handleResume(ctx, [], sendChatMessage); return handleResume(ctx, [], sendChatMessage);
} }
} }
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
// Determine projectDir: prefer existing .ralph/ location, otherwise use cwd // Determine projectDir: prefer existing .ralph/ location, otherwise use cwd
const projectDir = found const projectDir = found
? path.dirname(path.dirname(found.path)) ? path.dirname(path.dirname(found.path))
: process.cwd(); : process.cwd();
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
const progress = new ProgressTracker(projectDir, taskFile); const progress = new ProgressTracker(projectDir, taskFile);
// Set initial status // Set initial status
ctx.ui.setStatus( ctx.ui.setStatus(
"ralph", "ralph",
`Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`, `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 // Ask user for execution mode
const mode = await ctx.ui.select("Execution mode for this run?", [ const mode = await ctx.ui.select("Execution mode for this run?", [
"Parallel (DAG-optimized)", "Parallel (DAG-optimized)",
"Sequential (one at a time)", "Sequential (one at a time)",
]); ]);
const useParallel = mode?.startsWith("Parallel"); const useParallel = mode?.startsWith("Parallel");
// Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches
const plan = useParallel const plan = useParallel
? buildExecutionPlan(project, completed) ? buildExecutionPlan(project, completed)
: buildSequentialPlan(project, completed); : buildSequentialPlan(project, completed);
for (const batch of plan.batches) { for (const batch of plan.batches) {
if (progress.getState().paused) { if (progress.getState().paused) {
ctx.ui.notify( ctx.ui.notify(
"Execution paused. Use /ralph resume to continue.", "Execution paused. Use /ralph resume to continue.",
"warning", "warning",
); );
return; return;
} }
await executeBatch( await executeBatch(
batch.batchIndex, batch.batchIndex,
batch.tasks, batch.tasks,
project, project,
config, config,
progress, progress,
ctx as any, ctx as any,
{ parallel: useParallel }, { parallel: useParallel },
sendChatMessage, sendChatMessage,
projectDir, projectDir,
); );
for (const task of batch.tasks) { for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id); const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status); updateTaskInFile(taskFile, task.id, status);
} }
} }
const state = progress.getState(); const state = progress.getState();
const output = formatProgressStatus(state); const output = formatProgressStatus(state);
const reflections = progress.getAllReflections(); const reflections = progress.getAllReflections();
if (reflections.length > 0) { if (reflections.length > 0) {
ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info");
return; return;
} }
ctx.ui.notify(output, "info"); ctx.ui.notify(output, "info");
} }
// ─── /ralph status ─────────────────────────────────────────────────────────── // ─── /ralph status ───────────────────────────────────────────────────────────
async function handleStatus( async function handleStatus(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
if (args[0]) { if (args[0]) {
const taskFile = resolveTaskArg(args[0], process.cwd()); const taskFile = resolveTaskArg(args[0], process.cwd());
const existingProgress = findProgressFile(process.cwd(), taskFile); const existingProgress = findProgressFile(process.cwd(), taskFile);
if (existingProgress) { if (existingProgress) {
const projectDir = path.dirname(path.dirname(existingProgress.path)); const projectDir = path.dirname(path.dirname(existingProgress.path));
const progress = new ProgressTracker( const progress = new ProgressTracker(
projectDir, projectDir,
taskFile, taskFile,
existingProgress.prdKey, existingProgress.prdKey,
); );
ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
return; return;
} }
// No progress yet for this task — parse and show plan instead // No progress yet for this task — parse and show plan instead
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
ctx.ui.notify( ctx.ui.notify(
`No progress for ${path.basename(taskFile)}. ${project.tasks.length} tasks found.\nUse /ralph run ${args[0]} to start.`, `No progress for ${path.basename(taskFile)}. ${
"info", project.tasks.length
); } tasks found.\nUse /ralph run ${args[0]} to start.`,
return; "info",
} );
return;
}
const found = findProgressFile(process.cwd()); const found = findProgressFile(process.cwd());
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralph/progress.json found. Start with /ralph run [task-file]", "No .ralph/progress.json found. Start with /ralph run [task-file]",
"warning", "warning",
); );
return; return;
} }
ctx.ui.notify(formatAllPRDsStatus(found.state), "info"); ctx.ui.notify(formatAllPRDsStatus(found.state), "info");
} }
// ─── /ralph resume ─────────────────────────────────────────────────────────── // ─── /ralph resume ───────────────────────────────────────────────────────────
async function handleResume( async function handleResume(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
sendChatMessage?: (content: string) => void, sendChatMessage?: (content: string) => void,
): Promise<void> { ): Promise<void> {
// If a task file arg is provided, find progress for that specific PRD // If a task file arg is provided, find progress for that specific PRD
let taskFile: string; let taskFile: string;
let projectDir: string; let projectDir: string;
let found: ReturnType<typeof findProgressFile>; let found: ReturnType<typeof findProgressFile>;
if (args[0]) { if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd()); taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile); found = findProgressFile(process.cwd(), taskFile);
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /ralph run ${args[0]}`, `No existing progress for ${args[0]}. Start with /ralph run ${args[0]}`,
"warning", "warning",
); );
return; return;
} }
projectDir = path.dirname(path.dirname(found.path)); projectDir = path.dirname(path.dirname(found.path));
} else { } else {
found = findProgressFile(process.cwd()); found = findProgressFile(process.cwd());
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralph/progress.json found. Start with /ralph run [task-file]", "No .ralph/progress.json found. Start with /ralph run [task-file]",
"warning", "warning",
); );
return; return;
} }
projectDir = path.dirname(path.dirname(found.path)); projectDir = path.dirname(path.dirname(found.path));
// For no-arg resume, use the first PRD's source path or legacy sourcePath // For no-arg resume, use the first PRD's source path or legacy sourcePath
taskFile = found.state.prds taskFile = found.state.prds
? Object.values(found.state.prds)[0].sourcePath ? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath; : found.state.sourcePath;
} }
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
progress.setPaused(false); progress.setPaused(false);
// Set resume status // Set resume status
ctx.ui.setStatus("ralph", `Resuming from ${path.basename(taskFile)}`); 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 // Ask user for execution mode
const mode = await ctx.ui.select("Execution mode for this resume?", [ const mode = await ctx.ui.select("Execution mode for this run?", [
"Parallel (DAG-optimized)", "Parallel (DAG-optimized)",
"Sequential (one at a time)", "Sequential (one at a time)",
]); ]);
const useParallel = mode?.startsWith("Parallel"); const useParallel = mode?.startsWith("Parallel");
// Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches // Sequential mode: use buildSequentialPlan to avoid 29-task mega-batches
const plan = useParallel const plan = useParallel
? buildExecutionPlan(project, completed) ? buildExecutionPlan(project, completed)
: buildSequentialPlan(project, completed); : buildSequentialPlan(project, completed);
for (const batch of plan.batches) { for (const batch of plan.batches) {
await executeBatch( await executeBatch(
batch.batchIndex, batch.batchIndex,
batch.tasks, batch.tasks,
project, project,
config, config,
progress, progress,
ctx as any, ctx as any,
{ parallel: useParallel }, { parallel: useParallel },
sendChatMessage, sendChatMessage,
projectDir, projectDir,
); );
for (const task of batch.tasks) { for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id); const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status); updateTaskInFile(taskFile, task.id, status);
} }
} }
ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
} }
// ─── /ralph next ───────────────────────────────────────────────────────────── // ─── /ralph next ─────────────────────────────────────────────────────────────
async function handleNext( async function handleNext(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
sendChatMessage?: (content: string) => void, sendChatMessage?: (content: string) => void,
): Promise<void> { ): Promise<void> {
let taskFile: string; let taskFile: string;
let projectDir: string; let projectDir: string;
let found: ReturnType<typeof findProgressFile>; let found: ReturnType<typeof findProgressFile>;
if (args[0]) { if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd()); taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile); found = findProgressFile(process.cwd(), taskFile);
if (found) { if (found) {
projectDir = path.dirname(path.dirname(found.path)); projectDir = path.dirname(path.dirname(found.path));
} else { } else {
projectDir = process.cwd(); projectDir = process.cwd();
} }
} else { } else {
found = findProgressFile(process.cwd()); found = findProgressFile(process.cwd());
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralph/progress.json found. Start with /ralph run [task-file]", "No .ralph/progress.json found. Start with /ralph run [task-file]",
"warning", "warning",
); );
return; return;
} }
taskFile = found.state.prds taskFile = found.state.prds
? Object.values(found.state.prds)[0].sourcePath ? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath; : found.state.sourcePath;
projectDir = path.dirname(path.dirname(found.path)); projectDir = path.dirname(path.dirname(found.path));
} }
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
const completed = new Set(progress.getCompletedTaskIds()); const completed = new Set(progress.getCompletedTaskIds());
const ready = getReadyTasks(project, completed); const ready = getReadyTasks(project, completed);
if (ready.length === 0) { if (ready.length === 0) {
ctx.ui.notify( ctx.ui.notify(
"No tasks ready to execute. All tasks completed or blocked.", "No tasks ready to execute. All tasks completed or blocked.",
"info", "info",
); );
return; return;
} }
const nextBatch = ready.slice( const nextBatch = ready.slice(
0, 0,
config.execution.maxParallel || ready.length, config.execution.maxParallel || ready.length,
); );
for (const task of nextBatch) { for (const task of nextBatch) {
await executeBatch( await executeBatch(
0, 0,
[task], [task],
project, project,
config, config,
progress, progress,
ctx as any, ctx as any,
undefined, undefined,
sendChatMessage, sendChatMessage,
projectDir, projectDir,
); );
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id)); updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
} }
ctx.ui.notify( ctx.ui.notify(
`Executed: ${nextBatch.map((t) => t.id).join(", ")}\n\n${formatProgressStatus(progress.getState())}`, `Executed: ${nextBatch
"info", .map((t) => t.id)
); .join(", ")}\n\n${formatProgressStatus(progress.getState())}`,
"info",
);
} }
// ─── /ralph reset ──────────────────────────────────────────────────────────── // ─── /ralph reset ────────────────────────────────────────────────────────────
async function handleReset( async function handleReset(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
let projectDir: string; let projectDir: string;
if (args[0]) { if (args[0]) {
const taskFile = resolveTaskArg(args[0], process.cwd()); const taskFile = resolveTaskArg(args[0], process.cwd());
const found = findProgressFile(process.cwd(), taskFile); const found = findProgressFile(process.cwd(), taskFile);
projectDir = found ? path.dirname(path.dirname(found.path)) : process.cwd(); projectDir = found ? path.dirname(path.dirname(found.path)) : process.cwd();
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
progress.reset(); progress.reset();
} else { } else {
const found = findProgressFile(process.cwd()); const found = findProgressFile(process.cwd());
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralph/progress.json found. Start with /ralph run [task-file]", "No .ralph/progress.json found. Start with /ralph run [task-file]",
"warning", "warning",
); );
return; return;
} }
const projectDir = path.dirname(path.dirname(found.path)); const projectDir = path.dirname(path.dirname(found.path));
const progress = new ProgressTracker( const progress = new ProgressTracker(
projectDir, projectDir,
found.state.prds found.state.prds
? Object.values(found.state.prds)[0].sourcePath ? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath, : found.state.sourcePath,
); );
progress.reset(); progress.reset();
} }
ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); ctx.ui.notify("Progress reset. All task statuses cleared.", "info");
} }
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
if (hours > 0) { if (hours > 0) {
return `${hours}h ${minutes % 60}m`; return `${hours}h ${minutes % 60}m`;
} }
if (minutes > 0) { if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`; return `${minutes}m ${seconds % 60}s`;
} }
return `${seconds}s`; return `${seconds}s`;
} }

View File

@@ -71,27 +71,37 @@ export async function runTask(
const taskHeader = `${task.id} · ${task.title}`; 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 // Using setWidget instead of setWorkingMessage because the working message area
// is only visible during parent agent streaming, not during extension command execution. // is only visible during parent agent streaming, not during extension command execution.
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frameIndex = 0; let frameIndex = 0;
let lastToolLabel = "";
const theme = ctx.ui.theme; const theme = ctx.ui.theme;
const MAX_COLLAPSED = 3;
const toolCalls: ToolCallEntry[] = []; const toolCalls: ToolCallEntry[] = [];
const updateWidget = () => { const updateWidget = () => {
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]); const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
const lines = [`${frame} ${taskHeader}`]; const lines = [`${frame} ${taskHeader}`];
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
lines.push( const shown = toolCalls.slice(-MAX_COLLAPSED);
theme.fg( const remaining = toolCalls.length - shown.length;
"dim",
` ${toolCalls.length} tool${toolCalls.length !== 1 ? "s" : ""} · ${lastToolLabel}`, 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); ctx.ui.setWidget("ralph-task", lines);
}; };
@@ -119,8 +129,6 @@ export async function runTask(
name: event.toolName, name: event.toolName,
label, label,
}); });
// Update widget with latest tool call info
lastToolLabel = `[${event.toolName}] ${label}`;
updateWidget(); updateWidget();
} }
}, },

View File

@@ -88,13 +88,38 @@ function parseFioFormat(
} }
if (inDeps) { if (inDeps) {
const depMatch = line.match(/^(\d+)\s*->\s*(\d+)/); // Format 2: Arrow notation with multiple targets
if (depMatch) { // "01 -> 02,03,06 (description)" means 02, 03, 06 depend on 01
const [, from, to] = depMatch; const arrowMatch = line.match(/^(\d+)\s*->\s*([\d,\s]+?)(?:\s*\(|$)/);
if (arrowMatch) {
const [, from, targets] = arrowMatch;
const fromId = `0${from}`; const fromId = `0${from}`;
const toId = `0${to}`; const targetIds = targets
if (!dependencies[fromId]) dependencies[fromId] = []; .split(",")
dependencies[fromId].push(toId); .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.) // Parse meta blocks for task configuration (timeout, etc.)
@@ -126,6 +151,13 @@ function parseFioFormat(
const objectiveMatch = content.match(/^#\s+(.+)$/m); const objectiveMatch = content.match(/^#\s+(.+)$/m);
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined; 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 { return {
tasks, tasks,
dependencies, dependencies,