This commit is contained in:
2026-05-30 19:37:17 -04:00
parent 81e0e8ec1c
commit e6a8c8bedc
19 changed files with 2393 additions and 858 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.pi-lens

View File

@@ -10,6 +10,13 @@ Execute tasks from task files using DAG-based dependency resolution with persist
- **Reflection system**: Each task produces a reflection for downstream tasks
- **Retry with backoff**: Failed tasks retry with exponential backoff
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML
- **Chat progress**: Real-time progress messages in Pi chat via `pi.sendMessage`
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
- **Git commit capture**: Captures git commit messages and generates summaries per task
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
- **Session saving**: Saves full task output for expandable session review
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
- **Custom message renderer**: Compact UI labels with expandable details in Pi TUI
## Usage
@@ -76,8 +83,20 @@ maxParallel: 3
projectContext: "Additional context for all tasks"
```
### Task-Level Timeout
You can set a timeout for individual tasks using a meta block in the task file:
```markdown
- [ ] 01: Setup project structure
timeout: 10m
```
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
## State Files
- `.ralph/progress.json` - Execution progress
- `.ralph/reflections/` - Per-task reflections
- `.ralph/prompts/` - Generated prompts
- `.ralph/sessions/` - Full task output for review

543
index.ts Normal file
View File

@@ -0,0 +1,543 @@
import * as path from "node:path";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
import { parseTaskFile, updateTaskInFile } from "./src/parser";
import {
buildExecutionPlan,
buildSequentialPlan,
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,
} from "./src/utils";
const COMMANDS = ["status", "resume", "next", "reset"] as const;
/**
* Detect if a token looks like a file path rather than a subcommand.
* Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
*/
function looksLikePath(token: string): boolean {
return (
token.startsWith("@") ||
token.startsWith("/") ||
token.startsWith("./") ||
token.startsWith("../") ||
token.includes("/") ||
token.endsWith(".md") ||
token.endsWith(".yaml") ||
token.endsWith(".yml")
);
}
// ─── Extension Entry ────────────────────────────────────────────────────────
export default function ralphLoopExtension(pi: ExtensionAPI): void {
// Register custom message renderer for ralph progress messages
pi.registerMessageRenderer(
"ralph-progress",
(message, { expanded }, theme) => {
const details = message.details as
| {
taskId?: string;
taskTitle?: string;
phase?: string;
timestamp?: number;
durationMs?: number;
toolUsage?: Record<string, number>;
commits?: number;
error?: string;
}
| undefined;
const phase = details?.phase ?? "info";
const phaseLabel =
phase === "starting"
? theme.fg("accent", "[RUNNING]")
: phase === "completed"
? theme.fg("success", "[DONE]")
: phase === "failed"
? theme.fg("error", "[FAIL]")
: phase === "batch_start"
? theme.fg("accent", "[BATCH]")
: phase === "retry"
? theme.fg("warning", "[RETRY]")
: phase === "progress"
? ""
: theme.fg("dim", "[INFO]");
let text = phaseLabel
? `${phaseLabel} ${message.content}`
: String(message.content);
// Show expanded details
if (expanded && details) {
const lines: string[] = [];
if (details.taskId) lines.push(` Task: ${details.taskId}`);
if (details.durationMs) {
const dur = formatDuration(details.durationMs);
lines.push(` Duration: ${dur}`);
}
if (details.toolUsage) {
const tools = Object.entries(details.toolUsage)
.filter(([, v]) => v > 0)
.map(([k, v]) => `[${k}]: ${v}`)
.join(" ");
if (tools) lines.push(` Tools: ${tools}`);
}
if (details.commits && details.commits > 0) {
lines.push(` Commits: ${details.commits}`);
}
if (details.error) {
lines.push(` Error: ${details.error}`);
}
if (details.timestamp) {
const time = new Date(details.timestamp).toLocaleTimeString();
lines.push(` Time: ${time}`);
}
if (lines.length > 0) {
text += "\n" + lines.join("\n");
}
}
// Use Box with customMessageBg for consistent styling
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);
// 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.).
const sendProgress = (content: string) => {
pi.sendMessage({
customType: "ralph-progress",
content,
display: true,
details: { phase: "progress" },
});
};
// 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",
);
}
}
}
},
});
}
// ─── /ralph plan ─────────────────────────────────────────────────────────────
async function handlePlan(
ctx: ExtensionContext,
args: string[],
): Promise<void> {
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);
ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info");
}
// ─── /ralph run ──────────────────────────────────────────────────────────────
async function handleRun(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: (content: string) => void,
): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
// If targeting a specific task file and there's existing progress for it,
// auto-resume instead of starting fresh
const existingProgress = findProgressFile(process.cwd(), taskFile);
if (existingProgress) {
return handleResume(ctx, [args[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"],
);
if (shouldResume?.startsWith("Yes")) {
return handleResume(ctx, [], sendChatMessage);
}
}
const project = parseTaskFile(taskFile);
// 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);
// Set initial status
ctx.ui.setStatus(
"ralph",
`Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`,
);
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");
// 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;
}
await executeBatch(
batch.batchIndex,
batch.tasks,
project,
config,
progress,
ctx as any,
{ parallel: useParallel },
sendChatMessage,
);
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
const state = progress.getState();
const output = formatProgressStatus(state);
const reflections = progress.getAllReflections();
if (reflections.length > 0) {
ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info");
return;
}
ctx.ui.notify(output, "info");
}
// ─── /ralph status ───────────────────────────────────────────────────────────
async function handleStatus(
ctx: ExtensionContext,
args: string[],
): Promise<void> {
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;
}
ctx.ui.notify(formatAllPRDsStatus(found.state), "info");
}
// ─── /ralph resume ───────────────────────────────────────────────────────────
async function handleResume(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: (content: string) => void,
): Promise<void> {
// If a task file arg is provided, find progress for that specific PRD
let taskFile: string;
let projectDir: string;
let found: ReturnType<typeof findProgressFile>;
if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile);
if (!found) {
ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /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);
progress.setPaused(false);
// Set resume status
ctx.ui.setStatus("ralph", `Resuming from ${path.basename(taskFile)}`);
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");
// 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,
);
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
}
// ─── /ralph next ─────────────────────────────────────────────────────────────
async function handleNext(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: (content: string) => void,
): Promise<void> {
let taskFile: string;
let projectDir: string;
let found: ReturnType<typeof findProgressFile>;
if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile);
if (found) {
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 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;
}
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,
);
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
}
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[],
): Promise<void> {
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");
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}

65
package-lock.json generated Normal file
View File

@@ -0,0 +1,65 @@
{
"name": "ralph-loop",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ralph-loop",
"version": "1.0.0",
"dependencies": {
"yaml": "^2.4.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
}
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View File

@@ -7,30 +7,30 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
* Returns ordered batches of parallelizable tasks.
*/
export function buildExecutionPlan(
project: Project,
completed: Set<string>,
parallelGroup?: number,
project: Project,
completed: Set<string>,
parallelGroup?: number,
): ExecutionPlan {
const allTasks = new Map(project.tasks.map(t => [t.id, t]));
const allTasks = new Map(project.tasks.map((t) => [t.id, t]));
// Filter out already completed tasks
const pendingTasks = project.tasks.filter(t => !completed.has(t.id));
// Filter out already completed tasks
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
// If parallel_group is explicitly set, use group-based batching
if (parallelGroup !== undefined) {
return {
batches: buildParallelGroupBatches(pendingTasks, allTasks, completed),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
};
}
// If parallel_group is explicitly set, use group-based batching
if (parallelGroup !== undefined) {
return {
batches: buildParallelGroupBatches(pendingTasks, allTasks, completed),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
};
}
// Use dependency-based Kahn's algorithm
return {
batches: buildBatches(pendingTasks, allTasks, completed),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
};
// Use dependency-based Kahn's algorithm
return {
batches: buildBatches(pendingTasks, allTasks, completed),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
};
}
// ─── Sequential Plan ─────────────────────────────────────────────────────────
@@ -39,65 +39,65 @@ export function buildExecutionPlan(
* Build a sequential execution plan (one task per batch)
*/
export function buildSequentialPlan(
project: Project,
completed: Set<string>,
project: Project,
completed: Set<string>,
): ExecutionPlan {
const pendingTasks = project.tasks.filter(t => !completed.has(t.id));
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
tasks: [task],
batchIndex: i,
}));
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
tasks: [task],
batchIndex: i,
}));
return {
batches,
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
};
return {
batches,
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
};
}
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
function buildBatches(
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
): ExecutionBatch[] {
const batches: ExecutionBatch[] = [];
const done = new Set(completed);
const remaining = new Set(pendingTasks.map(t => t.id));
const batches: ExecutionBatch[] = [];
const done = new Set(completed);
const remaining = new Set(pendingTasks.map((t) => t.id));
while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied
const ready: Task[] = [];
for (const task of pendingTasks) {
if (!remaining.has(task.id)) continue;
while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied
const ready: Task[] = [];
for (const task of pendingTasks) {
if (!remaining.has(task.id)) continue;
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
dep => done.has(dep) || !allTasks.has(dep)
);
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !allTasks.has(dep),
);
if (depsSatisfied) {
ready.push(task);
}
}
if (depsSatisfied) {
ready.push(task);
}
}
// Cycle detection: no tasks ready but some remain
if (ready.length === 0) {
const cycleTasks = Array.from(remaining);
throw new Error(
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`
);
}
// Cycle detection: no tasks ready but some remain
if (ready.length === 0) {
const cycleTasks = Array.from(remaining);
throw new Error(
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
);
}
batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) {
done.add(task.id);
remaining.delete(task.id);
}
}
batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) {
done.add(task.id);
remaining.delete(task.id);
}
}
return batches;
return batches;
}
// ─── Parallel Group Batching ─────────────────────────────────────────────────
@@ -107,26 +107,24 @@ function buildBatches(
* Groups execute in ascending order; tasks within a group run concurrently.
*/
function buildParallelGroupBatches(
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
): ExecutionBatch[] {
const groups = new Map<number, Task[]>();
const groups = new Map<number, Task[]>();
for (const task of pendingTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
}
for (const task of pendingTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
}
const sortedGroups = Array.from(groups.entries()).sort(
(a, b) => a[0] - b[0]
);
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
return sortedGroups.map(([groupNum, tasks], i) => ({
tasks,
batchIndex: i,
}));
return sortedGroups.map(([groupNum, tasks], i) => ({
tasks,
batchIndex: i,
}));
}
// ─── Cycle Detection ─────────────────────────────────────────────────────────
@@ -135,51 +133,51 @@ function buildParallelGroupBatches(
* Detect cycles in the task dependency graph
*/
export function detectCycles(project: Project): string[] {
const adj = new Map<string, string[]>();
for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []);
}
const adj = new Map<string, string[]>();
for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []);
}
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const task of project.tasks) {
color.set(task.id, WHITE);
}
for (const task of project.tasks) {
color.set(task.id, WHITE);
}
const cycleNodes: string[] = [];
const cycleNodes: string[] = [];
function dfs(node: string): boolean {
color.set(node, GRAY);
const deps = adj.get(node) || [];
function dfs(node: string): boolean {
color.set(node, GRAY);
const deps = adj.get(node) || [];
for (const dep of deps) {
if (!adj.has(dep)) continue;
const depColor = color.get(dep);
for (const dep of deps) {
if (!adj.has(dep)) continue;
const depColor = color.get(dep);
if (depColor === GRAY) {
cycleNodes.push(dep);
return true;
}
if (depColor === WHITE && dfs(dep)) {
cycleNodes.push(node);
return true;
}
}
if (depColor === GRAY) {
cycleNodes.push(dep);
return true;
}
if (depColor === WHITE && dfs(dep)) {
cycleNodes.push(node);
return true;
}
}
color.set(node, BLACK);
return false;
}
color.set(node, BLACK);
return false;
}
for (const task of project.tasks) {
if (color.get(task.id) === WHITE) {
dfs(task.id);
}
}
for (const task of project.tasks) {
if (color.get(task.id) === WHITE) {
dfs(task.id);
}
}
return [...new Set(cycleNodes)];
return [...new Set(cycleNodes)];
}
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
@@ -188,14 +186,14 @@ export function detectCycles(project: Project): string[] {
* Get tasks that are ready to execute (all dependencies completed)
*/
export function getReadyTasks(
project: Project,
completed: Set<string>,
project: Project,
completed: Set<string>,
): Task[] {
return project.tasks.filter(task => {
if (completed.has(task.id)) return false;
const deps = task.dependencies || [];
return deps.every(dep => completed.has(dep));
});
return project.tasks.filter((task) => {
if (completed.has(task.id)) return false;
const deps = task.dependencies || [];
return deps.every((dep) => completed.has(dep));
});
}
// ─── Critical Path ───────────────────────────────────────────────────────────
@@ -204,67 +202,70 @@ export function getReadyTasks(
* Calculate the critical path (longest path through the DAG)
*/
export function getCriticalPath(project: Project): Task[] {
const taskMap = new Map(project.tasks.map(t => [t.id, t]));
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
// Initialize
for (const task of project.tasks) {
dist.set(task.id, 1);
prev.set(task.id, null);
}
// Initialize
for (const task of project.tasks) {
dist.set(task.id, 1);
prev.set(task.id, null);
}
// Topological sort
const sorted: Task[] = [];
const visited = new Set<string>();
// Topological sort
const sorted: Task[] = [];
const visited = new Set<string>();
function visit(id: string) {
if (visited.has(id)) return;
visited.add(id);
const task = taskMap.get(id);
if (!task) return;
function visit(id: string) {
if (visited.has(id)) return;
visited.add(id);
const task = taskMap.get(id);
if (!task) return;
for (const dep of task.dependencies || []) {
visit(dep);
}
sorted.push(task);
}
for (const dep of task.dependencies || []) {
visit(dep);
}
sorted.push(task);
}
for (const task of project.tasks) {
visit(task.id);
}
for (const task of project.tasks) {
visit(task.id);
}
// Relax edges
for (const task of sorted) {
for (const dep of task.dependencies || []) {
const depTask = taskMap.get(dep);
if (!depTask) continue;
// Relax edges
for (const task of sorted) {
for (const dep of task.dependencies || []) {
const depDist = dist.get(dep);
if (depDist === undefined) continue;
const newDist = dist.get(dep) + 1;
if (newDist > dist.get(task.id)!) {
dist.set(task.id, newDist);
prev.set(task.id, dep);
}
}
}
const newDist = depDist + 1;
const currentDist = dist.get(task.id) ?? 0;
if (newDist > currentDist) {
dist.set(task.id, newDist);
prev.set(task.id, dep);
}
}
}
// Trace back from the longest path end
let maxTask = project.tasks[0];
for (const task of project.tasks) {
if (dist.get(task.id) > dist.get(maxTask.id)) {
maxTask = task;
}
}
// Trace back from the longest path end
let maxTask = project.tasks[0];
for (const task of project.tasks) {
const taskDist = dist.get(task.id) ?? 0;
const maxDist = dist.get(maxTask.id) ?? 0;
if (taskDist > maxDist) {
maxTask = task;
}
}
const path: Task[] = [];
let current: string | null = maxTask.id;
while (current) {
const task = taskMap.get(current);
if (task) path.unshift(task);
current = prev.get(current) || null;
}
const path: Task[] = [];
let current: string | null = maxTask.id;
while (current) {
const task = taskMap.get(current);
if (task) path.unshift(task);
current = prev.get(current) || null;
}
return path;
return path;
}
// ─── Format Execution Plan ───────────────────────────────────────────────────
@@ -273,24 +274,26 @@ export function getCriticalPath(project: Project): Task[] {
* Format the execution plan for display
*/
export function formatExecutionPlan(plan: ExecutionPlan): string {
const lines: string[] = [];
lines.push("## Execution Plan");
lines.push("");
lines.push(`Total tasks: ${plan.totalTasks}`);
lines.push(`Batches: ${plan.batches.length}`);
const lines: string[] = [];
lines.push("## Execution Plan");
lines.push("");
lines.push(`Total tasks: ${plan.totalTasks}`);
lines.push(`Batches: ${plan.batches.length}`);
if (plan.skippedTasks.length > 0) {
lines.push(`Already completed: ${plan.skippedTasks.map(t => t.id).join(", ")}`);
}
lines.push("");
if (plan.skippedTasks.length > 0) {
lines.push(
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
);
}
lines.push("");
for (const batch of plan.batches) {
lines.push(`### Batch ${batch.batchIndex + 1}`);
for (const task of batch.tasks) {
lines.push(`- ${task.id}: ${task.title}`);
}
lines.push("");
}
for (const batch of plan.batches) {
lines.push(`### Batch ${batch.batchIndex + 1}`);
for (const task of batch.tasks) {
lines.push(`- ${task.id}: ${task.title}`);
}
lines.push("");
}
return lines.join("\n");
return lines.join("\n");
}

View File

@@ -1,25 +1,51 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { Task, Project, ExecutionPlan, Reflection } from "./types";
import type { Task, Project, Reflection, ToolUsage } from "./types";
import type { RalphConfig } from "./types";
import { ProgressTracker } from "./progress";
import type { ProgressTracker } from "./progress";
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { buildTaskPrompt } from "./prompts";
import { extractReflection } from "./reflection";
import { getPiPath, spawnPi, extractTextFromEvent, writeFileSafe, ensureDir } from "./utils";
import {
runAgentSession,
writeFileSafe,
ensureDir,
captureGitCommits,
formatDuration,
} from "./utils";
/** Optional callback to post a progress message into the chat history. */
export type SendChatMessage = (content: string) => void;
interface ToolCallEntry {
name: string;
label: string;
}
// ─── Run Single Task ────────────────────────────────────────────────────────
/**
* Execute a single task by spawning pi with the task prompt
* Execute a single task by spawning an async Pi agent session.
* Non-blocking — the TUI remains responsive throughout.
*/
export async function runTask(
task: Task,
project: Project,
config: RalphConfig,
depReflections: Reflection[],
): Promise<{ success: boolean; reflection?: Reflection; error?: string; durationMs: number }> {
ctx: ExtensionCommandContext,
sendChatMessage?: SendChatMessage,
): Promise<{
success: boolean;
reflection?: Reflection;
error?: string;
durationMs: number;
toolUsage?: ToolUsage;
outputPreview?: string;
sessionFile?: string;
commitMessages?: string[];
commitSummary?: string;
}> {
const startMs = Date.now();
const piPath = getPiPath();
// Build prompt
const prompt = buildTaskPrompt(
@@ -29,58 +55,193 @@ export async function runTask(
config.prompts.projectContext,
);
// Write prompt to temp file
const promptDir = path.join(project.sourceDir, ".ralph", "prompts");
ensureDir(promptDir);
const promptFile = path.join(promptDir, `${task.id}.md`);
// Write prompt to .ralph/ with timestamp (for debugging)
const ralphDir = path.join(project.sourceDir, ".ralph");
ensureDir(ralphDir);
const promptFile = path.join(ralphDir, `prompt-${startMs}.md`);
writeFileSafe(promptFile, prompt);
console.log(`[ralph] Running task ${task.id}: ${task.title}`);
console.log(`[ralph] Prompt written to ${promptFile}`);
// Footer shows just the task title (no batch prefix)
ctx.ui.setStatus("ralph", task.title);
// Spawn pi
const result = spawnPi(promptFile, piPath, config.execution.maxParallel > 0 ? [] : []);
// Animated spinner in Pi's streaming area — shows as actual spinner, not static text
const taskHeader = `${task.id} · ${task.title}`;
ctx.ui.setWorkingMessage(taskHeader);
// Use task-level timeout if set, otherwise fall back to config
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
// Collect tool call entries during execution
const toolCalls: ToolCallEntry[] = [];
let lastUpdateCount = 0;
const UPDATE_THROTTLE = 5;
// Run task asynchronously via Pi SDK — event loop stays responsive
const output = await runAgentSession(
prompt,
project.sourceDir,
timeoutMs,
(event) => {
if (event.type === "tool_execution_start") {
toolCalls.push({
name: event.toolName,
label: formatToolArg(event.toolName, event.args),
});
// Send periodic chat update every N tool calls
if (toolCalls.length - lastUpdateCount >= UPDATE_THROTTLE) {
lastUpdateCount = toolCalls.length;
sendChatMessage?.(buildRunningMessage(taskHeader, toolCalls));
}
}
},
);
const durationMs = Date.now() - startMs;
if (result.code !== 0) {
// Clear working message after task finishes
ctx.ui.setWorkingMessage();
ctx.ui.setStatus("ralph", undefined);
if (!output.success) {
sendChatMessage?.(`${taskHeader}${output.error}`);
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
return {
success: false,
error: result.stderr || `pi exited with code ${result.code}`,
error: output.error,
durationMs,
};
}
// Extract output text
const output = extractTextFromEvent(result.stdout);
const agentText = output.text;
const toolUsage = output.toolUsage;
// Extract reflection
const reflection = extractReflection(output, task.id, task.title);
// Capture git commits made during this task
const { commitMessages, commitSummary } = captureGitCommits(
project.sourceDir,
);
// Save full session transcript to .ralph/sessions/
const sessionFile = saveSessionOutput(
project.sourceDir,
task.id,
JSON.stringify(output.events, null, 2),
);
// Build output preview (first 500 chars of agent text)
const outputPreview =
agentText.length > 500
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
: agentText;
// Extract reflection from agent output
const reflection = extractReflection(agentText, task.id, task.title);
// Post completion chat message with tree format
const dur = formatDuration(durationMs);
const tree = formatToolCallTree(taskHeader, toolCalls, dur);
sendChatMessage?.(tree);
return {
success: true,
reflection,
reflection: reflection ?? undefined,
durationMs,
toolUsage,
outputPreview,
sessionFile,
commitMessages,
commitSummary,
};
}
// ─── Save Session Output ────────────────────────────────────────────────────
function saveSessionOutput(
sourceDir: string,
taskId: string,
output: string,
): string {
const sessionsDir = path.join(sourceDir, ".ralph", "sessions");
ensureDir(sessionsDir);
const fileName = `${taskId}-${Date.now()}.txt`;
const filePath = path.join(sessionsDir, fileName);
writeFileSafe(filePath, output);
return filePath;
}
// ─── Execute Batch ───────────────────────────────────────────────────────────
/**
* Execute a batch of tasks (sequentially or in parallel)
*/
export async function executeBatch(
batchIndex: number,
_batchIndex: number,
tasks: Task[],
project: Project,
config: RalphConfig,
progress: ProgressTracker,
ctx: ExtensionCommandContext,
options?: { parallel?: boolean },
sendChatMessage?: SendChatMessage,
): Promise<void> {
console.log(`\n[ralph] === Batch ${batchIndex + 1} (${tasks.length} task${tasks.length > 1 ? "s" : ""}) ===`);
// Check if we should run parallel
const shouldParallel =
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
// For now, execute sequentially (parallel support requires more complex event handling)
if (shouldParallel) {
await executeBatchParallel(
tasks,
project,
config,
progress,
ctx,
sendChatMessage,
);
return;
}
// Execute sequentially
for (const task of tasks) {
await executeTask(task, project, config, progress);
await executeTask(task, project, config, progress, ctx, sendChatMessage);
}
}
/**
* Execute tasks in parallel using child processes
*/
async function executeBatchParallel(
tasks: Task[],
project: Project,
config: RalphConfig,
progress: ProgressTracker,
ctx: ExtensionCommandContext,
sendChatMessage?: SendChatMessage,
): Promise<void> {
const maxParallel = config.execution.maxParallel;
const results: Array<{ task: Task; result: Promise<any> }> = [];
for (const task of tasks) {
results.push({
task,
result: executeTask(
task,
project,
config,
progress,
ctx,
sendChatMessage,
),
});
// Limit concurrency
if (results.length >= maxParallel) {
const first = results.shift();
if (first) await first.result;
}
}
// Wait for remaining tasks
for (const { result } of results) {
await result;
}
}
@@ -91,6 +252,8 @@ async function executeTask(
project: Project,
config: RalphConfig,
progress: ProgressTracker,
ctx: ExtensionCommandContext,
sendChatMessage?: SendChatMessage,
): Promise<void> {
const maxRetries = config.execution.maxRetries;
let retries = 0;
@@ -106,7 +269,14 @@ async function executeTask(
);
// Run the task
const result = await runTask(task, project, config, depReflections);
const result = await runTask(
task,
project,
config,
depReflections,
ctx,
sendChatMessage,
);
if (result.success) {
// Save reflection
@@ -114,26 +284,34 @@ async function executeTask(
saveReflectionToFile(project.sourceDir, config, result.reflection);
}
// Mark completed
progress.markCompleted(task.id, result.durationMs, result.reflection);
console.log(`[ralph] Task ${task.id} completed in ${formatMs(result.durationMs)}`);
// Mark completed with all metadata
progress.markCompleted(
task.id,
result.durationMs,
result.reflection,
result.toolUsage,
result.sessionFile,
result.outputPreview,
result.commitMessages,
result.commitSummary,
);
return;
}
// Task failed, check if we should retry
if (retries < maxRetries) {
retries = progress.incrementRetry(task.id);
console.log(
`[ralph] Task ${task.id} failed (attempt ${retries}/${maxRetries}): ${result.error}`,
ctx.ui.notify(
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
"warning",
);
// Exponential backoff
const delay = config.execution.retryDelayMs * Math.pow(2, retries - 1);
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
await sleep(delay);
} else {
// Max retries exceeded
progress.markFailed(task.id, result.error || "Unknown error");
console.log(`[ralph] Task ${task.id} FAILED after ${maxRetries} retries`);
throw new Error(`Task ${task.id} failed: ${result.error}`);
}
} catch (error) {
@@ -160,15 +338,115 @@ function saveReflectionToFile(
// ─── Helpers ─────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatMs(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainSec = seconds % 60;
return `${minutes}m ${remainSec}s`;
// ─── Tool Call Formatting ────────────────────────────────────────────────
const MAX_DETAIL_TOOL_CALLS = 3;
/**
* Format a tool call argument into a short label.
*/
function formatToolArg(name: string, args: unknown): string {
const a = args as Record<string, unknown>;
switch (name) {
case "bash":
return truncateMiddle(String(a.command ?? ""), 70);
case "write":
case "read":
return truncateMiddle(String(a.path ?? ""), 60);
case "edit":
return truncateMiddle(String(a.path ?? ""), 60);
case "grep":
return `${a.pattern ?? "?"}${truncateMiddle(String(a.path ?? ""), 40)}`;
case "find":
return `${a.path ?? "."}${a.glob ?? "*"}`;
case "ls":
return truncateMiddle(String(a.path ?? "."), 60);
default:
return name;
}
return `${seconds}s`;
}
/**
* Truncate a long string in the middle, keeping start and end visible.
*/
function truncateMiddle(s: string, maxLen: number): string {
if (s.length <= maxLen) return s;
const half = Math.floor((maxLen - 3) / 2);
return s.slice(0, half) + "…" + s.slice(s.length - half);
}
/**
* Build a brief running-status chat message (displayed during task execution).
*
* ```
* ⠿ 05 · billing-subscriptions-trials
* ├── 12 tools
* └── [bash] find /path -name "*.tsx"
* ```
*/
function buildRunningMessage(
header: string,
toolCalls: ToolCallEntry[],
): string {
const lines: string[] = [`${header}`];
const last = toolCalls[toolCalls.length - 1];
if (last) {
lines.push(` ├── ${toolCalls.length} tools`);
lines.push(` └── [${last.name}] ${last.label}`);
}
return lines.join("\n");
}
/**
* Build a tree-format chat message showing tool calls.
*
* ```
* ✓ 05 · billing-subscriptions-trials (2m 14s)
* ├── 24 reads, 14 bash, 6 writes, 5 edits
* ├── [bash] find /path -name "*.tsx"
* ├── [write] /path/routes/pricing.tsx
* └── [bash] npm test ...
* ```
*
* Older calls are summarized as a tool-type breakdown above detailed entries.
* Newest calls appear at the bottom.
*/
function formatToolCallTree(
header: string,
toolCalls: ToolCallEntry[],
duration: string,
): string {
const lines: string[] = [`${header} (${duration})`];
if (toolCalls.length === 0) {
return lines.join("\n");
}
// Show tool-type breakdown instead of "N more"
const typeCounts = new Map<string, number>();
for (const t of toolCalls) {
typeCounts.set(t.name, (typeCounts.get(t.name) ?? 0) + 1);
}
const summary = [...typeCounts.entries()]
.map(([name, count]) => `${count} ${name}`)
.join(", ");
// Determine which entries to show in detail (last N)
const shown = toolCalls.slice(-MAX_DETAIL_TOOL_CALLS);
// Tool-type summary line BEFORE detailed entries
lines.push(` ├── ${summary}`);
// Detailed entries (newest at bottom)
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const prefix = isLast ? " └──" : " ├──";
lines.push(`${prefix} [${entry.name}] ${entry.label}`);
}
return lines.join("\n");
}

View File

@@ -1,187 +0,0 @@
import * as path from "node:path";
import type { ExtensionContext } from "@pi/extension-api";
import { parseTaskFile, updateTaskInFile } from "./parser";
import { buildExecutionPlan, buildSequentialPlan, formatExecutionPlan, getReadyTasks } from "./dag";
import { ProgressTracker } from "./progress";
import { buildPlanPrompt } from "./prompts";
import { formatReflections } from "./reflection";
import { executeBatch } from "./executor";
import { loadConfig, resolveTaskArg, formatProgressStatus, getPiPath } from "./utils";
import { COMMANDS } from "./constants";
// ─── Extension Entry ────────────────────────────────────────────────────────
export function register(context: ExtensionContext) {
context.registerSlashCommand({
name: "ralph",
description: "Execute tasks from a task file using DAG-based dependency resolution",
handler: async (args: string[]) => {
const [subcommand, ...rest] = args;
const command = subcommand || "plan";
switch (command) {
case "run":
return handleRun(context, rest);
case "plan":
return handlePlan(context, rest);
case "status":
return handleStatus(context, rest);
case "resume":
return handleResume(context, rest);
case "next":
return handleNext(context, rest);
case "reset":
return handleReset(context, rest);
default:
return `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`;
}
},
});
}
// ─── /ralph plan ─────────────────────────────────────────────────────────────
async function handlePlan(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
// Show plan
const planPrompt = buildPlanPrompt(project);
const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan);
return `${planPrompt}\n\n${formatted}`;
}
// ─── /ralph run ──────────────────────────────────────────────────────────────
async function handleRun(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
// Build execution plan
const completed = new Set(progress.getCompletedTaskIds());
const plan = buildExecutionPlan(project, completed);
// Execute batches
for (const batch of plan.batches) {
// Check if paused
if (progress.getState().paused) {
return `Execution paused. Use /ralph resume to continue.`;
}
await executeBatch(
batch.batchIndex,
batch.tasks,
project,
config,
progress,
);
// Update task file
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
// Final status
const state = progress.getState();
const output = formatProgressStatus(state);
// Show reflections
const reflections = progress.getAllReflections();
if (reflections.length > 0) {
return `${output}\n\n${formatReflections(reflections)}`;
}
return output;
}
// ─── /ralph status ───────────────────────────────────────────────────────────
async function handleStatus(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
return formatProgressStatus(progress.getState());
}
// ─── /ralph resume ───────────────────────────────────────────────────────────
async function handleResume(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
// Unpause
progress.setPaused(false);
// Get remaining batches
const completed = new Set(progress.getCompletedTaskIds());
const plan = buildExecutionPlan(project, completed);
// Execute remaining batches
for (const batch of plan.batches) {
await executeBatch(
batch.batchIndex,
batch.tasks,
project,
config,
progress,
);
// Update task file
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
return formatProgressStatus(progress.getState());
}
// ─── /ralph next ─────────────────────────────────────────────────────────────
async function handleNext(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
const completed = new Set(progress.getCompletedTaskIds());
const ready = getReadyTasks(project, completed);
if (ready.length === 0) {
return "No tasks ready to execute. All tasks completed or blocked.";
}
// Execute just the next batch (first ready tasks)
const nextBatch = ready.slice(0, config.execution.maxParallel || ready.length);
for (const task of nextBatch) {
await executeBatch(
0,
[task],
project,
config,
progress,
);
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
}
return `Executed: ${nextBatch.map(t => t.id).join(", ")}\n\n${formatProgressStatus(progress.getState())}`;
}
// ─── /ralph reset ────────────────────────────────────────────────────────────
async function handleReset(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
progress.reset();
return "Progress reset. All task statuses cleared.";
}

View File

@@ -12,158 +12,203 @@ import type { Task, Project } from "./types";
* - YAML format (tasks: [...])
*/
export function parseTaskFile(filePath: string): Project {
const absolutePath = path.resolve(filePath);
const content = fs.readFileSync(absolutePath, "utf-8");
const ext = path.extname(filePath).toLowerCase();
const dir = path.dirname(absolutePath);
const absolutePath = path.resolve(filePath);
const content = fs.readFileSync(absolutePath, "utf-8");
const ext = path.extname(filePath).toLowerCase();
const dir = path.dirname(absolutePath);
if (ext === ".yaml" || ext === ".yml") {
return parseYaml(content, absolutePath, dir);
}
if (ext === ".yaml" || ext === ".yml") {
return parseYaml(content, absolutePath, dir);
}
// Markdown: detect format
if (hasDependenciesSection(content)) {
return parseFioFormat(content, absolutePath, dir);
}
return parseSimpleCheckbox(content, absolutePath, dir);
// Markdown: detect format
if (hasDependenciesSection(content)) {
return parseFioFormat(content, absolutePath, dir);
}
return parseSimpleCheckbox(content, absolutePath, dir);
}
// ─── Fio Format Parser ───────────────────────────────────────────────────────
function hasDependenciesSection(content: string): boolean {
return /^##\s+Dependencies\s*$/m.test(content);
return /^##\s+Dependencies\s*$/m.test(content);
}
function parseFioFormat(content: string, sourcePath: string, sourceDir: string): Project {
const lines = content.split("\n");
const tasks: Task[] = [];
const dependencies: Record<string, string[]> = {};
let inTasks = false;
let inDeps = false;
function parseFioFormat(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
const lines = content.split("\n");
const tasks: Task[] = [];
const dependencies: Record<string, string[]> = {};
let inTasks = false;
let inDeps = false;
for (const line of lines) {
if (/^##\s+Tasks\s*$/m.test(line)) {
inTasks = true;
inDeps = false;
continue;
}
if (/^##\s+Dependencies\s*$/m.test(line)) {
inTasks = false;
inDeps = true;
continue;
}
if (/^##\s/.test(line) && !/^##\s+Tasks/.test(line) && !/^##\s+Dependencies/.test(line)) {
inTasks = false;
inDeps = false;
continue;
}
for (const line of lines) {
if (/^##\s+Tasks\s*$/m.test(line)) {
inTasks = true;
inDeps = false;
continue;
}
if (/^##\s+Dependencies\s*$/m.test(line)) {
inTasks = false;
inDeps = true;
continue;
}
if (
/^##\s/.test(line) &&
!/^##\s+Tasks/.test(line) &&
!/^##\s+Dependencies/.test(line)
) {
inTasks = false;
inDeps = false;
continue;
}
if (inTasks) {
const match = line.match(/^-+\s+\[([ ~x!-])\]\s+(\d+)\s+[—–-]\s+(.+?)(?:\s*→\s*`([^`]+)`)?/);
if (match) {
const [, , id, title, file] = match;
tasks.push({
id: `0${id}`,
title: title.trim(),
description: undefined,
file: file || undefined,
status: charToStatus(match[1]),
dependencies: [],
});
}
}
if (inTasks) {
// Match all tasks on a line (supports compact single-line formats)
const taskPattern =
/-+\s+\[(.)\]\s+(\d+)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
let match: RegExpExecArray | null;
while ((match = taskPattern.exec(line)) !== null) {
const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line);
tasks.push({
id: `0${id}`,
title: title.trim(),
description: undefined,
file: file || undefined,
status: charToStatus(status),
dependencies: [],
timeoutMs,
index: tasks.length,
});
}
}
if (inDeps) {
const depMatch = line.match(/^(\d+)\s*->\s*(\d+)/);
if (depMatch) {
const [, from, to] = depMatch;
const fromId = `0${from}`;
const toId = `0${to}`;
if (!dependencies[fromId]) dependencies[fromId] = [];
dependencies[fromId].push(toId);
}
}
}
if (inDeps) {
const depMatch = line.match(/^(\d+)\s*->\s*(\d+)/);
if (depMatch) {
const [, from, to] = depMatch;
const fromId = `0${from}`;
const toId = `0${to}`;
if (!dependencies[fromId]) dependencies[fromId] = [];
dependencies[fromId].push(toId);
}
// Extract exit criteria
const exitCriteria: string[] = [];
const exitIdx = lines.findIndex(l => /^##\s+Exit\s+Criteria/i.test(l));
if (exitIdx >= 0) {
for (let i = exitIdx + 1; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) break;
const m = lines[i].match(/^-\s+(.+)$/);
if (m) exitCriteria.push(m[1].trim());
}
}
// Parse meta blocks for task configuration (timeout, etc.)
const metaMatch = line.match(
/^0?(\d+)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
);
if (metaMatch) {
const [, taskId, value, unit] = metaMatch;
const task = tasks.find((t) => t.id === `0${taskId}`);
if (task) {
task.timeoutMs = parseTimeoutValue(Number(value), unit);
}
}
}
}
// Extract objective from top-level heading
const objectiveMatch = content.match(/^#\s+(.+)$/m);
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
// Extract exit criteria
const exitCriteria: string[] = [];
const exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l));
if (exitIdx >= 0) {
for (let i = exitIdx + 1; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) break;
const m = lines[i].match(/^-\s+(.+)$/);
if (m) exitCriteria.push(m[1].trim());
}
}
return { tasks, dependencies, sourcePath, sourceDir, exitCriteria, objective };
// Extract objective from top-level heading
const objectiveMatch = content.match(/^#\s+(.+)$/m);
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
return {
tasks,
dependencies,
sourcePath,
sourceDir,
exitCriteria,
objective,
};
}
// ─── Simple Checkbox Parser ──────────────────────────────────────────────────
function parseSimpleCheckbox(content: string, sourcePath: string, sourceDir: string): Project {
const tasks: Task[] = [];
const lines = content.split("\n");
let idx = 0;
function parseSimpleCheckbox(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
const tasks: Task[] = [];
const lines = content.split("\n");
let idx = 0;
for (const line of lines) {
const match = line.match(/^-+\s+\[([ ~x!-])\]\s+(.+)$/);
if (match) {
const [, statusChar, title] = match;
const id = `${String(idx).padStart(2, "0")}`;
tasks.push({
id,
title: title.trim(),
status: charToStatus(statusChar),
dependencies: [],
});
idx++;
}
}
for (const line of lines) {
const match = line.match(/^-+\s+\[(.)\]\s+(.+)$/);
if (match) {
const [, statusChar, title] = match;
const id = `${String(idx).padStart(2, "0")}`;
tasks.push({
id,
title: title.trim(),
status: charToStatus(statusChar),
dependencies: [],
});
idx++;
}
}
return { tasks, dependencies: {}, sourcePath, sourceDir };
return { tasks, dependencies: {}, sourcePath, sourceDir };
}
// ─── YAML Parser ─────────────────────────────────────────────────────────────
function parseYaml(content: string, sourcePath: string, sourceDir: string): Project {
// Lazy-load yaml (may not be installed)
let YAML: typeof import("yaml");
try {
YAML = require("yaml");
} catch {
throw new Error("YAML parsing requires the 'yaml' package. Run: npm install yaml");
}
function parseYaml(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
// Lazy-load yaml (may not be installed)
let YAML: typeof import("yaml");
try {
YAML = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
const doc = YAML.parse(content);
const tasks: Task[] = [];
const doc = YAML.parse(content);
const tasks: Task[] = [];
if (doc.tasks && Array.isArray(doc.tasks)) {
doc.tasks.forEach((t: any, idx: number) => {
tasks.push({
id: t.id || `${String(idx).padStart(2, "0")}`,
title: t.title || t.name || `Task ${idx}`,
description: t.description,
file: t.file,
status: (t.status as Task["status"]) || "pending",
dependencies: t.depends_on || t.dependencies || [],
parallelGroup: t.parallel_group,
});
});
}
if (doc.tasks && Array.isArray(doc.tasks)) {
doc.tasks.forEach((t: any, idx: number) => {
tasks.push({
id: t.id || `${String(idx).padStart(2, "0")}`,
title: t.title || t.name || `Task ${idx}`,
description: t.description,
file: t.file,
status: (t.status as Task["status"]) || "pending",
dependencies: t.depends_on || t.dependencies || [],
parallelGroup: t.parallel_group,
timeoutMs: parseTimeoutFromMeta(t.timeout),
index: idx,
});
});
}
return {
tasks,
dependencies: doc.dependencies || {},
sourcePath,
sourceDir,
exitCriteria: doc.exit_criteria || doc.exitCriteria,
objective: doc.objective,
};
return {
tasks,
dependencies: doc.dependencies || {},
sourcePath,
sourceDir,
exitCriteria: doc.exit_criteria || doc.exitCriteria,
objective: doc.objective,
};
}
// ─── Task Spec Reader ────────────────────────────────────────────────────────
@@ -172,9 +217,9 @@ function parseYaml(content: string, sourcePath: string, sourceDir: string): Proj
* Read the detailed task specification from a task file
*/
export function readTaskSpec(taskDir: string, taskFile: string): string {
const fullPath = path.resolve(taskDir, taskFile);
if (!fs.existsSync(fullPath)) return "";
return fs.readFileSync(fullPath, "utf-8");
const fullPath = path.resolve(taskDir, taskFile);
if (!fs.existsSync(fullPath)) return "";
return fs.readFileSync(fullPath, "utf-8");
}
// ─── Task File Updater ───────────────────────────────────────────────────────
@@ -182,30 +227,34 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
/**
* Update task status in the source markdown file
*/
export function updateTaskInFile(filePath: string, taskId: string, status: Task["status"]): void {
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
export function updateTaskInFile(
filePath: string,
taskId: string,
status: Task["status"],
): void {
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
// Try Fio numbered format first
const fioPattern = new RegExp(
`(^-\\s+\\[)([ ~x!-])(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
"m"
);
if (fioPattern.test(content)) {
content = content.replace(fioPattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
return;
}
// Try Fio numbered format first
const fioPattern = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
"m",
);
if (fioPattern.test(content)) {
content = content.replace(fioPattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
return;
}
// Try simple checkbox format
const simplePattern = new RegExp(
`(-\\s+\\[)([ ~x!-])(\\]\\s+${escapeRegex(taskId)}`,
"m"
);
if (simplePattern.test(content)) {
content = content.replace(simplePattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
}
// Try simple checkbox format
const simplePattern = new RegExp(
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
"m",
);
if (simplePattern.test(content)) {
content = content.replace(simplePattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
}
}
// ─── Auto-Detect Dependencies ────────────────────────────────────────────────
@@ -214,60 +263,129 @@ export function updateTaskInFile(filePath: string, taskId: string, status: Task[
* Auto-detect dependencies by analyzing task file references
*/
export function autoDetectDependencies(project: Project): Project {
const tasks = project.tasks.map(t => ({ ...t, dependencies: [...t.dependencies] }));
const taskMap = new Map(tasks.map(t => [t.id, t]));
const taskFiles = new Map(
tasks.filter(t => t.file).map(t => [path.resolve(project.sourceDir, t.file!), t])
);
const tasks = project.tasks.map((t) => ({
...t,
dependencies: [...t.dependencies],
}));
const taskFiles = new Map(
tasks
.filter((t) => t.file)
.map((t) => [path.resolve(project.sourceDir, t.file!), t]),
);
for (const [filePath, task] of taskFiles) {
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, "utf-8");
for (const [filePath, task] of taskFiles) {
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, "utf-8");
// Check if this task's file references another task's file
for (const [file, refTask] of taskFiles) {
if (refTask.id === task.id) continue;
if (content.includes(file) || content.includes(refTask.title)) {
if (!task.dependencies.includes(refTask.id)) {
task.dependencies.push(refTask.id);
}
}
}
}
// Check if this task's file references another task's file
for (const [file, refTask] of taskFiles) {
if (refTask.id === task.id) continue;
if (content.includes(file) || content.includes(refTask.title)) {
if (!task.dependencies.includes(refTask.id)) {
task.dependencies.push(refTask.id);
}
}
}
}
const dependencies: Record<string, string[]> = {};
for (const task of tasks) {
if (task.dependencies.length > 0) {
dependencies[task.id] = task.dependencies;
}
}
const dependencies: Record<string, string[]> = {};
for (const task of tasks) {
if (task.dependencies.length > 0) {
dependencies[task.id] = task.dependencies;
}
}
return { ...project, tasks, dependencies };
return { ...project, tasks, dependencies };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Timeout Parsing ────────────────────────────────────────────────────────
/**
* Parse timeout from a task line (e.g., "timeout: 15m" or "# timeout=30s")
*/
function parseTimeoutFromLine(line: string): number | undefined {
// Match patterns like "timeout: 15m", "# timeout=30s", "timeout: 5min"
const match = line.match(/(?:timeout|timelimit)[\s:=]+(\d+)(?:m|min|s|ms)?/i);
if (match) {
return parseTimeoutValue(Number(match[1]), match[2]);
}
return undefined;
}
/**
* Parse a timeout value with unit suffix
*/
function parseTimeoutValue(value: number, unit?: string): number {
const u = (unit || "m").toLowerCase();
switch (u) {
case "ms":
return value;
case "s":
return value * 1000;
case "m":
case "min":
return value * 60 * 1000;
default:
return value * 60 * 1000; // default to minutes
}
}
/**
* Parse timeout from YAML meta field (string or number)
* Supports: "15m", "30s", "5min", 15 (minutes), 900000 (ms)
*/
function parseTimeoutFromMeta(
timeout: string | number | undefined,
): number | undefined {
if (timeout === undefined) return undefined;
if (typeof timeout === "number") {
// Assume minutes if < 1000, milliseconds if >= 1000
return timeout < 1000 ? timeout * 60 * 1000 : timeout;
}
const match = timeout.match(/^(\d+)(ms|s|m|min)?$/i);
if (match) {
return parseTimeoutValue(Number(match[1]), match[2]);
}
return undefined;
}
function charToStatus(char: string): Task["status"] {
switch (char) {
case " ": return "pending";
case "~": return "in_progress";
case "x": return "completed";
case "!": return "failed";
case "-": return "skipped";
default: return "pending";
}
switch (char) {
case " ":
return "pending";
case "~":
return "in_progress";
case "x":
return "completed";
case "!":
return "failed";
case "-":
return "skipped";
default:
return "pending";
}
}
function statusToChar(status: Task["status"]): string {
switch (status) {
case "pending": return " ";
case "in_progress": return "~";
case "completed": return "x";
case "failed": return "!";
case "skipped": return "-";
}
switch (status) {
case "pending":
return " ";
case "in_progress":
return "~";
case "completed":
return "x";
case "failed":
return "!";
case "skipped":
return "-";
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -1,20 +1,33 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ProgressState, Task, Reflection } from "./types";
import type { ProgressState, PRDProgress, Task, Reflection, ToolUsage } from "./types";
import { ensureDir } from "./utils";
/**
* Derive a stable PRD key from a source path relative to the project dir.
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
*/
export function derivePRDKey(projectDir: string, sourcePath: string): string {
const rel = path.relative(projectDir, sourcePath);
return rel.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
/**
* Manages persistent progress state for a ralph execution.
* State is stored as JSON in .ralph/progress.json
* State is stored as JSON in .ralph/progress.json.
* Supports multiple PRDs in progress simultaneously via the `prds` field.
* Falls back to legacy flat format for backward compatibility.
*/
export class ProgressTracker {
private statePath: string;
private state: ProgressState;
private prdKey: string;
constructor(projectDir: string, sourcePath: string) {
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralph");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
@@ -23,13 +36,59 @@ export class ProgressTracker {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
return JSON.parse(raw) as ProgressState;
const parsed = JSON.parse(raw) as ProgressState;
// Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat
return parsed;
}
// Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode
parsed.prds = {
[this.prdKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
};
return parsed;
}
// Different PRD — create new entry alongside existing ones
if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed;
}
// Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey(path.dirname(this.statePath), parsed.sourcePath);
parsed.prds = {
[legacyKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
[this.prdKey]: this.freshPRD(sourcePathHint),
};
return parsed;
} catch {
// Fall through to create new
}
}
return this.freshState(sourcePathHint);
}
private freshPRD(sourcePath: string): PRDProgress {
return {
sourcePath: sourcePathHint,
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
@@ -37,9 +96,47 @@ export class ProgressTracker {
};
}
private freshState(sourcePath: string): ProgressState {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
prds: {
[this.prdKey]: {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
},
},
};
}
/** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress {
if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
}
if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
}
return this.state.prds[this.prdKey];
}
/** Save current state to disk */
save(): void {
this.state.lastUpdatedAt = new Date().toISOString();
const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused;
fs.writeFileSync(
this.statePath,
JSON.stringify(this.state, null, 2),
@@ -49,9 +146,10 @@ export class ProgressTracker {
/** Mark a task as in progress */
markInProgress(taskId: string): void {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "in_progress";
this.state.tasks[taskId].startedAt = new Date().toISOString();
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
@@ -60,89 +158,108 @@ export class ProgressTracker {
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
sessionFile?: string,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
): void {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "completed";
this.state.tasks[taskId].completedAt = new Date().toISOString();
this.state.tasks[taskId].durationMs = durationMs;
if (reflection) {
this.state.tasks[taskId].reflection = reflection;
}
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
this.save();
}
/** Mark a task as failed */
markFailed(taskId: string, error: string): void {
this.ensureTask(taskId);
this.state.tasks[taskId].status = "failed";
this.state.tasks[taskId].error = error;
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error;
this.save();
}
/** Get task status */
getTaskStatus(taskId: string): Task["status"] {
return this.state.tasks[taskId]?.status ?? "pending";
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
return Object.entries(this.state.tasks)
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(this.state.tasks)) {
if (info.reflection) {
reflections.push(info.reflection);
}
for (const info of Object.values(prd.tasks)) {
if (info.reflection) reflections.push(info.reflection);
}
return reflections;
}
/** Get reflections for specific dependency tasks */
getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD();
return depIds
.map((id) => this.state.tasks[id]?.reflection)
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Increment retry count */
incrementRetry(taskId: string): number {
this.ensureTask(taskId);
this.state.tasks[taskId].retries++;
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].retries++;
this.save();
return this.state.tasks[taskId].retries;
return prd.tasks[taskId].retries;
}
/** Set paused state */
setPaused(paused: boolean): void {
this.state.paused = paused;
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Get the raw state (for status display) */
getState(): ProgressState {
return this.state;
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Reset all progress */
/** Get all PRDs (for multi-PRD status display) */
getAllPRDs(): Record<string, PRDProgress> {
return this.state.prds ?? {};
}
/** Get the PRD key for this tracker */
getKey(): string {
return this.prdKey;
}
/** Reset all progress for this PRD */
reset(): void {
this.state = {
sourcePath: this.state.sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
private ensureTask(taskId: string): void {
if (!this.state.tasks[taskId]) {
this.state.tasks[taskId] = { status: "pending", retries: 0 };
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
}

View File

@@ -1,136 +1,180 @@
// ─── Task Model ───────────────────────────────────────────────────────────────
export type TaskStatus = "pending" | "in_progress" | "completed" | "failed" | "skipped";
export type TaskStatus =
| "pending"
| "in_progress"
| "completed"
| "failed"
| "skipped";
export type TaskStatusChar = " " | "~" | "x" | "!" | "-";
export interface Task {
/** Unique task identifier */
id: string;
/** Task title */
title: string;
/** Detailed task description */
description?: string;
/** Path to detailed spec file (relative to sourceDir) */
file?: string;
/** Current status */
status: TaskStatus;
/** Task IDs this task depends on */
dependencies: string[];
/** Explicit parallel group (optional, overrides dependency-based batching) */
parallelGroup?: number;
/** Unique task identifier */
id: string;
/** Task title */
title: string;
/** Detailed task description */
description?: string;
/** Path to detailed spec file (relative to sourceDir) */
file?: string;
/** Current status */
status: TaskStatus;
/** Task IDs this task depends on */
dependencies: string[];
/** Explicit parallel group (optional, overrides dependency-based batching) */
parallelGroup?: number;
/** Task-level timeout in milliseconds (parsed from meta block) */
timeoutMs?: number;
/** Original index in task list for deterministic ordering */
index?: number;
}
export interface Project {
/** Project-level objective / goal */
objective?: string;
/** All tasks in the project */
tasks: Task[];
/** Explicit dependency map: taskId → [dependency taskIds] */
dependencies: Record<string, string[]>;
/** Exit criteria (from README ## Exit Criteria section) */
exitCriteria?: string[];
/** Path to the source task file */
sourcePath: string;
/** Directory containing the source file */
sourceDir: string;
/** Project-level objective / goal */
objective?: string;
/** All tasks in the project */
tasks: Task[];
/** Explicit dependency map: taskId → [dependency taskIds] */
dependencies: Record<string, string[]>;
/** Exit criteria (from README ## Exit Criteria section) */
exitCriteria?: string[];
/** Path to the source task file */
sourcePath: string;
/** Directory containing the source file */
sourceDir: string;
}
// ─── Execution Plan ───────────────────────────────────────────────────────────
export interface ExecutionBatch {
/** Tasks that can run concurrently in this batch */
tasks: Task[];
/** Batch number (0-indexed) */
batchIndex: number;
/** Tasks that can run concurrently in this batch */
tasks: Task[];
/** Batch number (0-indexed) */
batchIndex: number;
}
export interface ExecutionPlan {
/** Ordered batches (each batch contains parallelizable tasks) */
batches: ExecutionBatch[];
/** Total task count */
totalTasks: number;
/** Tasks skipped (already completed) */
skippedTasks: Task[];
/** Ordered batches (each batch contains parallelizable tasks) */
batches: ExecutionBatch[];
/** Total task count */
totalTasks: number;
/** Tasks skipped (already completed) */
skippedTasks: Task[];
}
// ─── Progress Model ───────────────────────────────────────────────────────────
export interface Reflection {
taskId: string;
title: string;
/** What was accomplished */
summary: string;
/** Key decisions, patterns, and learnings for downstream tasks */
keyLearnings: string[];
/** Files created or modified */
filesChanged: string[];
/** Unresolved issues or caveats */
blockers?: string[];
/** ISO timestamp */
timestamp: string;
taskId: string;
title: string;
/** What was accomplished */
summary: string;
/** Key decisions, patterns, and learnings for downstream tasks */
keyLearnings: string[];
/** Files created or modified */
filesChanged: string[];
/** Unresolved issues or caveats */
blockers?: string[];
/** ISO timestamp */
timestamp: string;
}
export interface ToolUsage {
read: number;
write: number;
edit: number;
bash: number;
other: number;
}
export interface TaskProgressInfo {
status: Task["status"];
startedAt?: string;
completedAt?: string;
retries: number;
durationMs?: number;
reflection?: Reflection;
error?: string;
/** Tool usage counts from parsed subprocess output */
toolUsage?: ToolUsage;
/** Path to session output file */
sessionFile?: string;
/** Truncated output preview for expanded view */
outputPreview?: string;
/** Git commit messages from task execution */
commitMessages?: string[];
/** Summary derived from git commits */
commitSummary?: string;
}
export interface ProgressState {
/** Path to the source task file */
sourcePath: string;
/** Per-task status tracking */
tasks: Record<string, {
status: Task["status"];
startedAt?: string;
completedAt?: string;
retries: number;
durationMs?: number;
reflection?: Reflection;
error?: string;
}>;
/** When execution started */
startedAt: string;
/** When execution last updated */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped */
paused: boolean;
/** Path to the source task file (legacy single-PRD mode) */
sourcePath: string;
/** Per-task status tracking (legacy single-PRD mode) */
tasks: Record<string, TaskProgressInfo>;
/** When execution started (legacy single-PRD mode) */
startedAt: string;
/** When execution last updated (legacy single-PRD mode) */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped (legacy single-PRD mode) */
paused: boolean;
/** Multiple PRDs tracked simultaneously (keyed by normalized source path) */
prds?: Record<string, PRDProgress>;
}
export interface PRDProgress {
/** Path to the source task file for this PRD */
sourcePath: string;
/** Per-task status tracking */
tasks: Record<string, TaskProgressInfo>;
/** When execution started */
startedAt: string;
/** When execution last updated */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped */
paused: boolean;
}
// ─── Configuration ────────────────────────────────────────────────────────────
export interface RalphConfig {
paths: {
/** Directory for ralph state files */
stateDir: string;
/** Directory for per-task reflections */
reflectionsDir: string;
};
execution: {
/** Maximum retries per task */
maxRetries: number;
/** Delay between retries in milliseconds */
retryDelayMs: number;
/** Task execution timeout in milliseconds */
timeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */
maxParallel: number;
};
prompts: {
/** Additional context injected into every task prompt */
projectContext: string;
/** Custom prompt suffix for reflection extraction */
reflectionPrompt: string;
};
paths: {
/** Directory for ralph state files */
stateDir: string;
/** Directory for per-task reflections */
reflectionsDir: string;
};
execution: {
/** Maximum retries per task */
maxRetries: number;
/** Delay between retries in milliseconds */
retryDelayMs: number;
/** Task execution timeout in milliseconds */
timeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */
maxParallel: number;
};
prompts: {
/** Additional context injected into every task prompt */
projectContext: string;
/** Custom prompt suffix for reflection extraction */
reflectionPrompt: string;
};
}
export const DEFAULT_CONFIG: RalphConfig = {
paths: {
stateDir: ".ralph",
reflectionsDir: ".ralph/reflections",
},
execution: {
maxRetries: 3,
retryDelayMs: 5000,
timeoutMs: 30 * 60 * 1000, // 30 minutes
maxParallel: 3,
},
prompts: {
projectContext: "",
reflectionPrompt: "",
},
paths: {
stateDir: ".ralph",
reflectionsDir: ".ralph/reflections",
},
execution: {
maxRetries: 3,
retryDelayMs: 5000,
timeoutMs: 30 * 60 * 1000, // 30 minutes
maxParallel: 3,
},
prompts: {
projectContext: "",
reflectionPrompt: "",
},
};

View File

@@ -1,8 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
import type { RalphConfig, ProgressState, Task } from "./types";
import type {
RalphConfig,
PRDProgress,
ProgressState,
ToolUsage,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
// ─── Directory Helpers ───────────────────────────────────────────────────────
@@ -23,39 +34,50 @@ export function writeFileSafe(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, "utf-8");
}
// ─── Command Helpers ─────────────────────────────────────────────────────────
// ─── Async Agent Session ────────────────────────────────────────────────────
// ─── Progress Discovery ─────────────────────────────────────────────────────
/**
* Check if a command exists in PATH
* Find the nearest .ralph/progress.json by walking up from the given directory.
* For a specific sourcePath, finds the matching PRD entry.
*/
export function commandExists(command: string): boolean {
try {
const { execSync } = require("node:child_process");
execSync(`which ${command}`, { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export function findProgressFile(
startDir: string,
sourcePath?: string,
): { path: string; state: ProgressState; prdKey?: string } | null {
let current = path.resolve(startDir);
const root = path.parse(current).root;
/**
* Get the path to the pi executable
*/
export function getPiPath(): string {
// Check if PI_PATH environment variable is set
const envPath = process.env.PI_PATH;
if (envPath && fs.existsSync(envPath)) {
return envPath;
while (current !== root) {
const candidate = path.join(current, ".ralph", "progress.json");
if (fs.existsSync(candidate)) {
try {
const raw = fs.readFileSync(candidate, "utf-8");
const state = JSON.parse(raw) as ProgressState;
// If looking for a specific source path, find matching PRD
if (sourcePath && state.prds) {
const resolvedSource = path.resolve(sourcePath);
for (const [key, prd] of Object.entries(state.prds)) {
if (path.resolve(prd.sourcePath) === resolvedSource) {
return { path: candidate, state, prdKey: key };
}
}
// No matching PRD found, continue walking up
current = path.dirname(current);
continue;
}
return { path: candidate, state };
} catch {
return null;
}
}
current = path.dirname(current);
}
// Try to find pi in PATH
if (commandExists("pi")) {
return "pi";
}
throw new Error(
"pi executable not found. Set PI_PATH or ensure pi is in PATH.",
);
return null;
}
// ─── Config ──────────────────────────────────────────────────────────────────
@@ -71,7 +93,7 @@ function parseSimpleYaml(content: string): Record<string, any> {
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
let value: string | boolean | number = match[2].trim();
// Parse booleans
if (value === "true") value = true;
@@ -113,13 +135,18 @@ function mergeConfig(
export function loadConfig(projectDir: string): RalphConfig {
const configPath = path.join(projectDir, ".ralph", "config.yaml");
// Return defaults silently when config file does not exist
if (!fs.existsSync(configPath)) {
return { ...DEFAULT_CONFIG };
}
try {
const content = fs.readFileSync(configPath, "utf-8");
// Simple YAML parsing (key: value format)
const config = parseSimpleYaml(content);
return mergeConfig(DEFAULT_CONFIG, config);
} catch (error) {
console.warn("Failed to load .ralph/config.yaml, using defaults:", error);
} catch {
// Malformed config — fall back to defaults silently
return { ...DEFAULT_CONFIG };
}
}
@@ -127,17 +154,18 @@ export function loadConfig(projectDir: string): RalphConfig {
// ─── Task Resolution ─────────────────────────────────────────────────────────
/**
* Resolve a task argument to a file path
* Resolve a task argument to a file path.
* Strips leading `@` (from autocomplete) before resolution.
*/
export function resolveTaskArg(
arg: string,
cwd: string,
): string {
export function resolveTaskArg(arg: string, cwd: string): string {
// Strip leading @ from autocomplete
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
const candidates = [
path.resolve(cwd, arg),
path.resolve(cwd, arg + ".md"),
path.resolve(cwd, arg + ".yaml"),
path.resolve(cwd, arg + ".yml"),
path.resolve(cwd, cleanArg),
path.resolve(cwd, cleanArg + ".md"),
path.resolve(cwd, cleanArg + ".yaml"),
path.resolve(cwd, cleanArg + ".yml"),
];
for (const candidate of candidates) {
@@ -145,13 +173,17 @@ export function resolveTaskArg(
}
// Try looking for README.md in the arg directory
if (fs.statSync(path.resolve(cwd, arg)).isDirectory()) {
const readme = path.resolve(cwd, arg, "README.md");
if (fs.existsSync(readme)) return readme;
try {
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
const readme = path.resolve(cwd, cleanArg, "README.md");
if (fs.existsSync(readme)) return readme;
}
} catch {
// Directory doesn't exist, fall through to error
}
throw new Error(
`Task file not found: ${arg}\nSearched: ${candidates.join("\n ")}`,
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
);
}
@@ -175,33 +207,38 @@ export function formatDuration(ms: number): string {
}
/**
* Format progress status for display
* Format progress status for display. Accepts a single PRDProgress entry.
*/
export function formatProgressStatus(state: ProgressState): string {
export function formatProgressStatus(state: PRDProgress): string {
const lines: string[] = [];
const tasks = state.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
t => t.status === "completed",
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
t => t.status === "failed",
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
t => t.status === "in_progress",
(t) => t.status === "in_progress",
).length;
lines.push("## Progress");
lines.push("");
lines.push(`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`);
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed" ? "[x]" :
info.status === "in_progress" ? "[~]" :
info.status === "failed" ? "[!]" :
"[ ]";
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
@@ -222,58 +259,274 @@ export function formatProgressStatus(state: ProgressState): string {
return lines.join("\n");
}
// ─── Pi Subprocess ───────────────────────────────────────────────────────────
/**
* Spawn a pi subprocess with the given prompt file
* Format progress status for all PRDs in a ProgressState.
*/
export function spawnPi(
promptFile: string,
piPath: string,
args?: string[],
): { stdout: string; stderr: string; code: number | null } {
const spawnArgs = ["--prompt", promptFile, ...(args || [])];
export function formatAllPRDsStatus(state: ProgressState): string {
const prds = state.prds;
if (!prds || Object.keys(prds).length <= 1) {
// Single PRD — use simple format
const prd = prds
? Object.values(prds)[0]
: (state as unknown as PRDProgress);
return formatProgressStatus(prd);
}
const result = spawnSync(piPath, spawnArgs, {
encoding: "utf-8",
timeout: 60 * 60 * 1000, // 1 hour
maxBuffer: 10 * 1024 * 1024, // 10MB
});
const lines: string[] = [];
lines.push("## Progress (all PRDs)");
lines.push("");
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
code: result.status,
};
}
for (const [key, prd] of Object.entries(prds)) {
const tasks = prd.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
/**
* Extract text content from pi event stream output
*/
export function extractTextFromEvent(output: string): string {
// If output is JSON event stream, extract text fields
if (output.startsWith("{") || output.startsWith("data:")) {
const lines = output.split("\n");
const texts: string[] = [];
lines.push(`### ${key}`);
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const line of lines) {
// Try to parse NDJSON events
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6));
if (event.type === "text" && event.text) {
texts.push(event.text);
}
} catch {
texts.push(line.slice(6));
}
} else if (line.trim()) {
texts.push(line);
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
: "";
lines.push(`- ${statusIcon} ${id}${duration}`);
if (info.error) {
lines.push(` Error: ${info.error}`);
}
}
return texts.join("\n");
lines.push("");
}
return output;
return lines.join("\n");
}
// ─── Async Agent Session ────────────────────────────────────────────────────
/**
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
*
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
* this uses createAgentSession from the Pi SDK, keeping the event loop
* responsive and allowing progress updates during task execution.
*/
export async function runAgentSession(
taskPrompt: string,
cwd: string,
timeoutMs: number,
onEvent?: (event: AgentSessionEvent) => void,
signal?: AbortSignal,
): Promise<{
success: boolean;
text: string;
error?: string;
toolUsage: ToolUsage;
stopReason?: string;
events: AgentSessionEvent[];
}> {
const toolUsage: ToolUsage = {
read: 0,
write: 0,
edit: 0,
bash: 0,
other: 0,
};
const recordedEvents: AgentSessionEvent[] = [];
// Wire timeout via abort signal
const timeoutHandle = setTimeout(() => {
if (sessionRef?.session) sessionRef.session.agent.abort();
}, timeoutMs);
const sessionRef: {
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
} = {};
try {
const loader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
noExtensions: true,
noSkills: false,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload();
const result = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(),
resourceLoader: loader,
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
});
sessionRef.session = result.session;
// Wire external abort signal
const abortHandler = () => result.session.agent.abort();
signal?.addEventListener("abort", abortHandler, { once: true });
let finalText = "";
let errorMessage: string | undefined;
let stopReason: string | undefined;
const unsubscribe = result.session.subscribe((event) => {
recordedEvents.push(event);
onEvent?.(event);
if (event.type === "message_end") {
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
if (message.role !== "assistant") return;
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
const text = extractAssistantText(message.content);
if (text) finalText = text;
}
if (event.type === "tool_execution_start") {
const name = event.toolName;
if (name in toolUsage) {
(toolUsage as unknown as Record<string, number>)[name]++;
} else {
toolUsage.other++;
}
}
});
if (signal?.aborted) throw new Error("Aborted before prompt");
await result.session.prompt(taskPrompt);
await result.session.agent.waitForIdle();
unsubscribe();
result.session.dispose();
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutHandle);
if (errorMessage && !finalText) {
return {
success: false,
text: "",
error: errorMessage,
toolUsage,
stopReason,
events: recordedEvents,
};
}
return {
success: true,
text: finalText.trim(),
toolUsage,
stopReason,
events: recordedEvents,
};
} catch (error) {
clearTimeout(timeoutHandle);
return {
success: false,
text: "",
error: error instanceof Error ? error.message : String(error),
toolUsage,
events: recordedEvents,
};
} finally {
sessionRef.session?.dispose();
}
}
/**
* Extract assistant text from message content (text blocks only).
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(c): c is { type: string; text?: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: string }).type === "text",
)
.map((c) => (c as { text?: string }).text ?? "")
.join("");
}
// ─── Git Commit Capture ──────────────────────────────────────────────────────
/**
* Capture recent git commits made during task execution
* Returns commit messages and a summary string
*/
export function captureGitCommits(projectDir: string): {
commitMessages: string[];
commitSummary: string;
} {
const { execSync } = require("node:child_process");
try {
// Check if this is a git repo
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
} catch {
return { commitMessages: [], commitSummary: "" };
}
const commitMessages: string[] = [];
let commitSummary = "";
try {
// Get recent commits (last 5) with short hash and subject
const output = execSync("git log --oneline -5 --no-decorate", {
cwd: projectDir,
encoding: "utf-8",
}).trim();
if (output) {
const lines = output.split("\n").filter((l: string) => l.trim());
for (const line of lines) {
// Format: "abc1234 Commit message"
const parts = line.split(" ", 2);
if (parts.length >= 2) {
commitMessages.push(parts[1]);
}
}
// Build summary from commit subjects
commitSummary = commitMessages.slice(0, 3).join("; ");
if (commitMessages.length > 3) {
commitSummary += ` (+${commitMessages.length - 3} more)`;
}
}
} catch {
// Git command failed, return empty
}
return { commitMessages, commitSummary };
}

View File

@@ -0,0 +1,38 @@
# 01. Fix `loadConfig` to return defaults gracefully when `.ralph/config.yaml` is missing
meta:
id: ralph-loop-fixes-01
feature: ralph-loop-fixes
priority: P1
depends_on: []
tags: [implementation, utils]
objective:
- `loadConfig()` should return `DEFAULT_CONFIG` silently when `.ralph/config.yaml` does not exist, without logging a warning to stderr
deliverables:
- Modified `src/utils.ts``loadConfig()` function
steps:
- Open `src/utils.ts` and locate `loadConfig()`
- Add `fs.existsSync()` check before `fs.readFileSync()`
- If config file does not exist, return a deep copy of `DEFAULT_CONFIG` without any console output
- If config file exists but is malformed, fall back to defaults silently
- Remove or suppress the `console.warn()` call
tests:
- Manual: Run `/ralph resume` in a project directory with no `.ralph/` directory — should not print warning
- Manual: Run `/ralph run` in a project with `.ralph/progress.json` but no `config.yaml` — should proceed with defaults
acceptance_criteria:
- No console warning when config.yaml is missing
- `loadConfig()` returns a valid `RalphConfig` object in all cases
- Existing behavior with valid config.yaml is unchanged
validation:
- Check `src/utils.ts` loadConfig function returns silently on missing file
- Verify no `console.warn` or `console.error` in the missing-config path
notes:
- Current code at line ~145 in utils.ts: `fs.readFileSync(configPath, "utf-8")` throws ENOENT
- The try-catch does catch it but still logs the warning — the warning is noisy for normal usage where config is optional

View File

@@ -0,0 +1,42 @@
# 02. Replace `spawnPi` with `--print` mode and stdin piping
meta:
id: ralph-loop-fixes-02
feature: ralph-loop-fixes
priority: P1
depends_on: []
tags: [implementation, utils]
objective:
- Replace `spawnPi()` so it invokes `pi --print` with prompt content piped via stdin, instead of using non-existent `--no-stream` and `--prompt` flags
deliverables:
- Modified `src/utils.ts``spawnPi()` function
- Updated `src/executor.ts` — import and call site for `spawnPi`
steps:
- Open `src/utils.ts` and locate `spawnPi()`
- Replace `spawnSync` args from `["--no-stream", "--prompt", promptFile, ...]` to `["--print"]`
- Read the prompt file content and pass it as `input` to `spawnSync`
- The `input` option accepts a string that is piped to the child process stdin
- Keep `encoding`, `timeout`, and `maxBuffer` options as-is
- Update the function signature if needed (no longer needs `promptFile` path, can take prompt content directly, or read it internally)
tests:
- Manual: Spawn pi with a simple prompt — verify it returns text output and exits cleanly
- Manual: Verify `result.stdout` contains the pi response text (not NDJSON or event stream)
acceptance_criteria:
- `spawnPi()` exits with code 0 on successful execution
- `result.stdout` contains plain text response from pi
- No "Unknown options: --no-stream, --prompt" error
validation:
- Run `pi --print` with piped input manually to verify behavior
- Check spawnSync call uses `["--print"]` args and `input` option
notes:
- Pi's `--print` flag runs in non-interactive mode: reads from stdin, writes to stdout, exits
- `spawnSync` accepts an `input` option (string) that pipes to child stdin
- Current broken args: `["--no-stream", "--prompt", promptFile]`
- The `extractTextFromEvent()` function can be simplified or removed since `--print` returns plain text

View File

@@ -0,0 +1,47 @@
# 03. Replace `sendMessage` with `ctx.ui` progress API
meta:
id: ralph-loop-fixes-03
feature: ralph-loop-fixes
priority: P1
depends_on: [ralph-loop-fixes-04]
tags: [implementation, executor]
objective:
- Replace all `piApi.sendMessage({ customType: "ralph-progress", display: true })` calls with `ctx.ui.notify()` and `ctx.ui.setStatus()` to avoid TUI crash from unregistered custom message renderer
deliverables:
- Modified `src/executor.ts` — remove `sendProgressMessage()`, replace with `ctx.ui` calls
- Modified `src/executor.ts` — remove `formatToolUsage()` if no longer needed, or keep for status text
steps:
- Open `src/executor.ts`
- Remove `sendProgressMessage()` function entirely
- In `runTask()`, replace `sendProgressMessage(piApi, task, project, "starting")` with `ctx.ui.setStatus("ralph", "Running ${task.id}: ${task.title}")`
- In `runTask()` success path, replace `sendProgressMessage(..., "completed")` with `ctx.ui.notify()` for completion summary
- In `runTask()` failure path, replace `sendProgressMessage(..., "failed")` with `ctx.ui.notify()` for error
- In `executeBatch()`, replace batch start `piApi.sendMessage()` with `ctx.ui.setStatus()`
- In `executeTask()`, replace retry `piApi.sendMessage()` with `ctx.ui.notify()`
- Remove `piApi: ExtensionAPI` parameter from all executor functions (replaced by `ctx: ExtensionCommandContext`)
- Remove unused `ExtensionAPI` import from executor.ts
tests:
- Manual: Run a task and verify progress appears in the Pi UI without crash
- Manual: Verify no `child.render is not a function` error
acceptance_criteria:
- No TUI crash during task execution
- Progress messages visible to user via `ctx.ui`
- `sendProgressMessage()` function removed from codebase
- `piApi.sendMessage()` no longer called anywhere in executor
validation:
- Grep for `sendMessage` in executor.ts — should only appear in comments or not at all
- Grep for `customType.*ralph-progress` — should be removed
- Verify `ctx.ui.notify` and `ctx.ui.setStatus` are used instead
notes:
- `ctx.ui.notify(message, type)` shows a notification — use "info" for progress, "error" for failures
- `ctx.ui.setStatus(key, text)` sets footer status text — good for "Running task X" updates
- `ctx.ui.setStatus(key, undefined)` clears the status
- The TUI crash (`child.render is not a function`) happens because `customType: "ralph-progress"` has no registered renderer via `pi.registerMessageRenderer()`

View File

@@ -0,0 +1,47 @@
# 04. Thread `ExtensionCommandContext` through `executeBatch`
meta:
id: ralph-loop-fixes-04
feature: ralph-loop-fixes
priority: P1
depends_on: []
tags: [implementation, plumbing]
objective:
- Pass `ctx: ExtensionCommandContext` from command handlers through to all executor functions that need it, replacing the missing `piApi: ExtensionAPI` parameter
deliverables:
- Modified `index.ts` — all `executeBatch()` calls pass `ctx` as 6th parameter
- Modified `src/executor.ts``executeBatch()`, `executeTask()`, `runTask()`, `executeBatchParallel()` accept `ctx: ExtensionCommandContext`
steps:
- Open `src/executor.ts`
- Add `import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"`
- Update `executeBatch()` signature: add `ctx: ExtensionCommandContext` as 6th parameter (after `progress`)
- Update `executeTask()` signature: add `ctx: ExtensionCommandContext` parameter
- Update `runTask()` signature: add `ctx: ExtensionCommandContext` parameter
- Update `executeBatchParallel()` signature: add `ctx: ExtensionCommandContext` parameter
- Thread `ctx` through all internal calls (batch → task → run)
- Open `index.ts`
- In `handleRun()`: pass `ctx` to `executeBatch()`
- In `handleResume()`: pass `ctx` to `executeBatch()`
- In `handleNext()`: pass `ctx` to `executeBatch()`
tests:
- Manual: `/ralph run` should execute without "undefined is not a function" errors
- Manual: `/ralph resume` should execute without context-related errors
acceptance_criteria:
- `executeBatch()` receives a valid `ExtensionCommandContext` in all call paths
- No `undefined` access errors when executor calls `ctx.ui.*`
- TypeScript compiles without errors
validation:
- Run `npx tsc --noEmit` in extension directory
- Verify `ctx` parameter exists in all executor function signatures
- Verify all call sites in index.ts pass `ctx`
notes:
- `ExtensionCommandContext` extends `ExtensionContext` and adds session control methods
- Command handlers receive `ExtensionCommandContext`, not bare `ExtensionContext`
- The `piApi` parameter was `ExtensionAPI` which has `sendMessage()` — we're replacing it with `ctx` which has `ctx.ui` for UI access

View File

@@ -0,0 +1,39 @@
# 05. Fix sequential mode batch labels
meta:
id: ralph-loop-fixes-05
feature: ralph-loop-fixes
priority: P2
depends_on: [ralph-loop-fixes-04]
tags: [implementation, ui]
objective:
- Suppress "Batch N:" label for single-task batches; use numbered list format (1., 2., 3.) for sequential task execution to match original behavior
deliverables:
- Modified `src/executor.ts``executeBatch()` console output
steps:
- Open `src/executor.ts` and locate `executeBatch()`
- In the batch header log, check if `tasks.length === 1`
- If single task: log `[ralph] Running task ${task.id}: ${task.title}` (no "Batch N" wrapper)
- If multiple tasks: keep existing `=== Batch N (M tasks) ===` format
- Track global task counter for sequential numbered output if needed
tests:
- Manual: Run a single-task batch — verify no "Batch N" in output
- Manual: Run a multi-task batch — verify "Batch N" still appears
acceptance_criteria:
- Single-task batches do not show "Batch N:" prefix
- Multi-task batches still show batch header
- Output format matches original: `[ralph] Running task 001: Title`
validation:
- Check `console.log` output in executeBatch for conditional formatting
- Verify single-task path uses task-focused label
notes:
- Original behavior: single tasks show numbered list (1., 2., 3.), batches show "Batch N:"
- Current code always shows `[ralph] === Batch N (M tasks) ===` regardless of batch size
- This is cosmetic but matches user preference for compact UI

View File

@@ -0,0 +1,40 @@
# 06. Simplify `parseToolUsage` for plain text output
meta:
id: ralph-loop-fixes-06
feature: ralph-loop-fixes
priority: P2
depends_on: [ralph-loop-fixes-02]
tags: [implementation, utils]
objective:
- Remove NDJSON event parsing from `parseToolUsage()` since `pi --print` returns plain text, not structured event streams
deliverables:
- Modified `src/utils.ts``parseToolUsage()` function
steps:
- Open `src/utils.ts` and locate `parseToolUsage()`
- Remove the NDJSON parsing block (lines that check `line.startsWith("data: ")` and `JSON.parse`)
- Keep only the regex fallback that counts tool mentions in plain text output
- Remove `extractTextFromEvent()` if no longer needed (plain text from `--print` needs no extraction)
- Update `executor.ts` to call `parseToolUsage()` directly on `result.stdout` without `extractTextFromEvent()`
tests:
- Manual: Run a task that uses multiple tools — verify tool counts are captured from plain text output
- Manual: Verify no JSON parse errors in tool usage parsing
acceptance_criteria:
- `parseToolUsage()` works correctly on plain text output
- No JSON parsing logic remains in `parseToolUsage()`
- Tool counts ([read], [write], [edit], [bash]) are still extracted via regex
validation:
- Grep for `JSON.parse` in parseToolUsage — should be removed
- Grep for `data:` prefix check — should be removed
- Verify regex-based tool counting still present and functional
notes:
- `pi --print` returns plain text, not NDJSON event stream
- The regex fallback patterns (`\[read\]`, `read(`, etc.) are sufficient for counting tool mentions
- `extractTextFromEvent()` was only needed for NDJSON — can be removed or simplified to identity function

View File

@@ -0,0 +1,26 @@
# Ralph-Loop Extension Fixes
Objective: Fix critical bugs preventing `/ralph resume` from working — broken CLI flags, unthreaded context, missing config, and TUI crash.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — Fix `loadConfig` to return defaults gracefully when `.ralph/config.yaml` is missing → `01-fix-loadconfig-graceful-default.md`
- [x] 02 — Replace `spawnPi` with `--print` mode and stdin piping → `02-fix-spawnpi-print-mode.md`
- [x] 03 — Replace `sendMessage` with `ctx.ui` progress API → `03-replace-sendmessage-with-ctx-ui.md`
- [x] 04 — Thread `ExtensionCommandContext` through `executeBatch``04-thread-ctx-through-execute-batch.md`
- [x] 05 — Fix sequential mode batch labels → `05-fix-sequential-mode-labels.md`
- [x] 06 — Simplify `parseToolUsage` for plain text output → `06-simplify-parsertoolsusage.md`
Dependencies
- 02 depends on nothing (standalone utils fix)
- 03 depends on 04 (needs ctx available in executor)
- 04 depends on nothing (standalone plumbing fix)
- 05 depends on 04 (executor changes)
- 06 depends on 02 (output format changes from --print)
Exit criteria
- `/ralph resume` runs without errors in a project with no `.ralph/config.yaml`
- Pi subprocess spawns successfully with `--print` mode
- Progress messages display via `ctx.ui` without TUI crash
- All batch execution paths receive context parameter

View File

@@ -4,7 +4,7 @@
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -14,6 +14,6 @@
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"include": ["index.ts", "src/**/*"],
"exclude": ["node_modules", "dist"]
}