good!
This commit is contained in:
27
README.md
27
README.md
@@ -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
840
index.ts
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user