almost
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.pi-lens
|
||||
19
README.md
19
README.md
@@ -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
543
index.ts
Normal 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
65
package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
387
src/dag.ts
387
src/dag.ts
@@ -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");
|
||||
}
|
||||
|
||||
362
src/executor.ts
362
src/executor.ts
@@ -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");
|
||||
}
|
||||
|
||||
187
src/index.ts
187
src/index.ts
@@ -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.";
|
||||
}
|
||||
498
src/parser.ts
498
src/parser.ts
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
205
src/progress.ts
205
src/progress.ts
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
250
src/types.ts
250
src/types.ts
@@ -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: "",
|
||||
},
|
||||
};
|
||||
|
||||
449
src/utils.ts
449
src/utils.ts
@@ -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 };
|
||||
}
|
||||
|
||||
38
tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md
Normal file
38
tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md
Normal 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
|
||||
42
tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md
Normal file
42
tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md
Normal 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
|
||||
47
tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md
Normal file
47
tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md
Normal 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()`
|
||||
@@ -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
|
||||
39
tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md
Normal file
39
tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md
Normal 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
|
||||
40
tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md
Normal file
40
tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md
Normal 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
|
||||
26
tasks/ralph-loop-fixes/README.md
Normal file
26
tasks/ralph-loop-fixes/README.md
Normal 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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user