From e6a8c8bedc45b3f6697355b8166a41a750633b7e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 30 May 2026 19:37:17 -0400 Subject: [PATCH] almost --- .gitignore | 3 + README.md | 19 + index.ts | 543 ++++++++++++++++++ package-lock.json | 65 +++ src/dag.ts | 387 ++++++------- src/executor.ts | 362 ++++++++++-- src/index.ts | 187 ------ src/parser.ts | 498 ++++++++++------ src/progress.ts | 205 +++++-- src/types.ts | 250 ++++---- src/utils.ts | 449 +++++++++++---- .../01-fix-loadconfig-graceful-default.md | 38 ++ .../02-fix-spawnpi-print-mode.md | 42 ++ .../03-replace-sendmessage-with-ctx-ui.md | 47 ++ .../04-thread-ctx-through-execute-batch.md | 47 ++ .../05-fix-sequential-mode-labels.md | 39 ++ .../06-simplify-parsertoolsusage.md | 40 ++ tasks/ralph-loop-fixes/README.md | 26 + tsconfig.json | 4 +- 19 files changed, 2393 insertions(+), 858 deletions(-) create mode 100644 .gitignore create mode 100644 index.ts create mode 100644 package-lock.json delete mode 100644 src/index.ts create mode 100644 tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md create mode 100644 tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md create mode 100644 tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md create mode 100644 tasks/ralph-loop-fixes/04-thread-ctx-through-execute-batch.md create mode 100644 tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md create mode 100644 tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md create mode 100644 tasks/ralph-loop-fixes/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8407f3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.pi-lens diff --git a/README.md b/README.md index cb3a3a2..ec562a2 100644 --- a/README.md +++ b/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 diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..86ee08a --- /dev/null +++ b/index.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + // If a task file arg is provided, find progress for that specific PRD + let taskFile: string; + let projectDir: string; + let found: ReturnType; + + if (args[0]) { + taskFile = resolveTaskArg(args[0], process.cwd()); + found = findProgressFile(process.cwd(), taskFile); + if (!found) { + ctx.ui.notify( + `No existing progress for ${args[0]}. Start with /ralph run ${args[0]}`, + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralph/progress.json found. Start with /ralph run [task-file]", + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + // For no-arg resume, use the first PRD's source path or legacy sourcePath + taskFile = found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath; + } + + 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 { + let taskFile: string; + let projectDir: string; + let found: ReturnType; + + if (args[0]) { + taskFile = resolveTaskArg(args[0], process.cwd()); + found = findProgressFile(process.cwd(), taskFile); + if (found) { + projectDir = path.dirname(path.dirname(found.path)); + } else { + projectDir = process.cwd(); + } + } else { + found = findProgressFile(process.cwd()); + if (!found) { + ctx.ui.notify( + "No .ralph/progress.json found. Start with /ralph run [task-file]", + "warning", + ); + return; + } + taskFile = found.state.prds + ? Object.values(found.state.prds)[0].sourcePath + : found.state.sourcePath; + projectDir = path.dirname(path.dirname(found.path)); + } + + 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 { + 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`; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..692db40 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/src/dag.ts b/src/dag.ts index b8b3272..bd7d0e6 100644 --- a/src/dag.ts +++ b/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, - parallelGroup?: number, + project: Project, + completed: Set, + 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, + project: Project, + completed: Set, ): 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, - completed: Set, + pendingTasks: Task[], + allTasks: Map, + completed: Set, ): 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, - completed: Set, + pendingTasks: Task[], + allTasks: Map, + completed: Set, ): ExecutionBatch[] { - const groups = new Map(); + const groups = new Map(); - 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(); - for (const task of project.tasks) { - adj.set(task.id, task.dependencies || []); - } + const adj = new Map(); + 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(); + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); - 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, + project: Project, + completed: Set, ): 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(); - const prev = new Map(); + const taskMap = new Map(project.tasks.map((t) => [t.id, t])); + const dist = new Map(); + const prev = new Map(); - // 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(); + // Topological sort + const sorted: Task[] = []; + const visited = new Set(); - 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"); } diff --git a/src/executor.ts b/src/executor.ts index 4a6fdb5..4e71bb8 100644 --- a/src/executor.ts +++ b/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 { - 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 { + const maxParallel = config.execution.maxParallel; + const results: Array<{ task: Task; result: Promise }> = []; + + 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 { 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 { - 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; + 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(); + 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"); } diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index fa54442..0000000 --- a/src/index.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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."; -} diff --git a/src/parser.ts b/src/parser.ts index f87787d..ba978be 100644 --- a/src/parser.ts +++ b/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 = {}; - 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 = {}; + 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 = {}; - for (const task of tasks) { - if (task.dependencies.length > 0) { - dependencies[task.id] = task.dependencies; - } - } + const dependencies: Record = {}; + 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, "\\$&"); } diff --git a/src/progress.ts b/src/progress.ts index 1cedeee..f41de84 100644 --- a/src/progress.ts +++ b/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 { + 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 }; } } } diff --git a/src/types.ts b/src/types.ts index 89edfff..30272bf 100644 --- a/src/types.ts +++ b/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; - /** 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; + /** 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; - /** 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; + /** 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; +} + +export interface PRDProgress { + /** Path to the source task file for this PRD */ + sourcePath: string; + /** Per-task status tracking */ + tasks: Record; + /** 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: "", + }, }; diff --git a/src/utils.ts b/src/utils.ts index 6ab7b6d..c9c4973 100644 --- a/src/utils.ts +++ b/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 { 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>["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)[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 }; } diff --git a/tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md b/tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md new file mode 100644 index 0000000..fc3196f --- /dev/null +++ b/tasks/ralph-loop-fixes/01-fix-loadconfig-graceful-default.md @@ -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 diff --git a/tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md b/tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md new file mode 100644 index 0000000..8f2ad2f --- /dev/null +++ b/tasks/ralph-loop-fixes/02-fix-spawnpi-print-mode.md @@ -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 diff --git a/tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md b/tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md new file mode 100644 index 0000000..2217090 --- /dev/null +++ b/tasks/ralph-loop-fixes/03-replace-sendmessage-with-ctx-ui.md @@ -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()` diff --git a/tasks/ralph-loop-fixes/04-thread-ctx-through-execute-batch.md b/tasks/ralph-loop-fixes/04-thread-ctx-through-execute-batch.md new file mode 100644 index 0000000..c79ad24 --- /dev/null +++ b/tasks/ralph-loop-fixes/04-thread-ctx-through-execute-batch.md @@ -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 diff --git a/tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md b/tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md new file mode 100644 index 0000000..958e2bf --- /dev/null +++ b/tasks/ralph-loop-fixes/05-fix-sequential-mode-labels.md @@ -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 diff --git a/tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md b/tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md new file mode 100644 index 0000000..7da8570 --- /dev/null +++ b/tasks/ralph-loop-fixes/06-simplify-parsertoolsusage.md @@ -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 diff --git a/tasks/ralph-loop-fixes/README.md b/tasks/ralph-loop-fixes/README.md new file mode 100644 index 0000000..866b3ad --- /dev/null +++ b/tasks/ralph-loop-fixes/README.md @@ -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 diff --git a/tsconfig.json b/tsconfig.json index 1951778..ad9c562 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }