commit 81e0e8ec1c9316bab77d4e6bc0e1360268509451 Author: Michael Freno Date: Sat May 30 01:26:17 2026 -0400 initial commit: ralph-loop extension - DAG-based task execution with dependency resolution - Persistent progress tracking in .ralph/progress.json - Reflection system for cross-task context - Support for Fio README, checkbox, and YAML formats - Retry with exponential backoff - Parallel batch execution diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb3a3a2 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# ralph-loop + +Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking. + +## Features + +- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm +- **Parallel batching**: Independent tasks in each batch can run concurrently +- **Persistent progress**: Execution state saved to `.ralph/progress.json` +- **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 + +## Usage + +``` +/ralph plan [task-file] # Show execution plan +/ralph run [task-file] # Execute all tasks +/ralph status [task-file] # Show current progress +/ralph resume [task-file] # Resume paused execution +/ralph next [task-file] # Execute next batch only +/ralph reset [task-file] # Reset all progress +``` + +## Task File Formats + +### Fio README Format + +```markdown +# Project Title + +## Tasks + +- [ ] 01 — Setup project structure -> `tasks/01-setup.md` +- [ ] 02 — Implement auth -> `tasks/02-auth.md` +- [ ] 03 — Build API -> `tasks/03-api.md` + +## Dependencies + +1 -> 2 +2 -> 3 +``` + +### Simple Checkbox Format + +```markdown +- [ ] 01: Setup project structure +- [ ] 02: Implement auth +- [ ] 03: Build API +``` + +### YAML Format + +```yaml +objective: Build a web application +tasks: + - id: "01" + title: Setup project structure + file: tasks/01-setup.md + dependencies: [] + - id: "02" + title: Implement auth + file: tasks/02-auth.md + depends_on: ["01"] +``` + +## Configuration + +Create `.ralph/config.yaml`: + +```yaml +maxRetries: 3 +retryDelayMs: 5000 +timeoutMs: 1800000 +maxParallel: 3 +projectContext: "Additional context for all tasks" +``` + +## State Files + +- `.ralph/progress.json` - Execution progress +- `.ralph/reflections/` - Per-task reflections +- `.ralph/prompts/` - Generated prompts diff --git a/package.json b/package.json new file mode 100644 index 0000000..a5290a8 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "ralph-loop", + "version": "1.0.0", + "description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "dependencies": { + "yaml": "^2.4.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/skills/ralph-task/SKILL.md b/skills/ralph-task/SKILL.md new file mode 100644 index 0000000..767c7d4 --- /dev/null +++ b/skills/ralph-task/SKILL.md @@ -0,0 +1,34 @@ +# ralph-task + +Execute a single task from a ralph task file. + +## When to Use + +- User asks to execute a specific task from a task file +- User provides a task ID and wants to run it +- User wants to run the next task in sequence + +## Usage + +``` +/ralph run [task-file] # Run all tasks +/ralph next [task-file] # Run next batch +/ralph status [task-file] # Check progress +``` + +## Task File Location + +Default: `README.md` in current directory. Can be overridden with explicit path. + +## Reflection Format + +After completing a task, include: + +``` +## REFLECTION +SUMMARY: [what was done] +FILES: [files changed] +LEARNINGS: +- [key learning] +BLOCKERS: [issues or 'none'] +``` diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..2a7868c --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,24 @@ +import type { RalphConfig } from "./types"; +import { DEFAULT_CONFIG } from "./types"; + +export { DEFAULT_CONFIG }; + +// CLI +export const SLASH_COMMAND = "/ralph"; +export const COMMANDS = ["run", "plan", "status", "resume", "next", "reset"] as const; + +// Task file detection +export const TASK_FILE_NAMES = [ + "README.md", + "PRD.md", + "tasks.md", + "tasks.yaml", + "tasks.yml", +] as const; + +// Reflection parsing +export const REFLECTION_HEADER = "## REFLECTION"; +export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i; + +// Pi subprocess +export const DEFAULT_PI_ARGS = ["--no-stream"] as const; diff --git a/src/dag.ts b/src/dag.ts new file mode 100644 index 0000000..b8b3272 --- /dev/null +++ b/src/dag.ts @@ -0,0 +1,296 @@ +import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; + +// ─── Main Entry ────────────────────────────────────────────────────────────── + +/** + * Build an execution plan from project tasks using DAG analysis. + * Returns ordered batches of parallelizable tasks. + */ +export function buildExecutionPlan( + project: Project, + completed: Set, + parallelGroup?: number, +): ExecutionPlan { + 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)); + + // 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)), + }; +} + +// ─── Sequential Plan ───────────────────────────────────────────────────────── + +/** + * Build a sequential execution plan (one task per batch) + */ +export function buildSequentialPlan( + 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, + })); + + 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, +): ExecutionBatch[] { + 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; + + const deps = task.dependencies || []; + const depsSatisfied = deps.every( + dep => done.has(dep) || !allTasks.has(dep) + ); + + 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(", ")}` + ); + } + + batches.push({ tasks: ready, batchIndex: batches.length }); + for (const task of ready) { + done.add(task.id); + remaining.delete(task.id); + } + } + + return batches; +} + +// ─── Parallel Group Batching ───────────────────────────────────────────────── + +/** + * Build batches from explicit parallel_group values. + * Groups execute in ascending order; tasks within a group run concurrently. + */ +function buildParallelGroupBatches( + pendingTasks: Task[], + allTasks: Map, + completed: Set, +): ExecutionBatch[] { + 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); + } + + const sortedGroups = Array.from(groups.entries()).sort( + (a, b) => a[0] - b[0] + ); + + return sortedGroups.map(([groupNum, tasks], i) => ({ + tasks, + batchIndex: i, + })); +} + +// ─── Cycle Detection ───────────────────────────────────────────────────────── + +/** + * 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 WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + + for (const task of project.tasks) { + color.set(task.id, WHITE); + } + + const cycleNodes: string[] = []; + + 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); + + if (depColor === GRAY) { + cycleNodes.push(dep); + return true; + } + if (depColor === WHITE && dfs(dep)) { + cycleNodes.push(node); + return true; + } + } + + color.set(node, BLACK); + return false; + } + + for (const task of project.tasks) { + if (color.get(task.id) === WHITE) { + dfs(task.id); + } + } + + return [...new Set(cycleNodes)]; +} + +// ─── Ready Tasks ───────────────────────────────────────────────────────────── + +/** + * Get tasks that are ready to execute (all dependencies completed) + */ +export function getReadyTasks( + 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)); + }); +} + +// ─── Critical Path ─────────────────────────────────────────────────────────── + +/** + * 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(); + + // 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(); + + 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 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; + + const newDist = dist.get(dep) + 1; + if (newDist > dist.get(task.id)!) { + 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; + } + } + + 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; +} + +// ─── Format Execution Plan ─────────────────────────────────────────────────── + +/** + * 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}`); + + 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(""); + } + + return lines.join("\n"); +} diff --git a/src/executor.ts b/src/executor.ts new file mode 100644 index 0000000..4a6fdb5 --- /dev/null +++ b/src/executor.ts @@ -0,0 +1,174 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Task, Project, ExecutionPlan, Reflection } from "./types"; +import type { RalphConfig } from "./types"; +import { ProgressTracker } from "./progress"; +import { buildTaskPrompt } from "./prompts"; +import { extractReflection } from "./reflection"; +import { getPiPath, spawnPi, extractTextFromEvent, writeFileSafe, ensureDir } from "./utils"; + +// ─── Run Single Task ──────────────────────────────────────────────────────── + +/** + * Execute a single task by spawning pi with the task prompt + */ +export async function runTask( + task: Task, + project: Project, + config: RalphConfig, + depReflections: Reflection[], +): Promise<{ success: boolean; reflection?: Reflection; error?: string; durationMs: number }> { + const startMs = Date.now(); + const piPath = getPiPath(); + + // Build prompt + const prompt = buildTaskPrompt( + task, + project, + depReflections, + 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`); + writeFileSafe(promptFile, prompt); + + console.log(`[ralph] Running task ${task.id}: ${task.title}`); + console.log(`[ralph] Prompt written to ${promptFile}`); + + // Spawn pi + const result = spawnPi(promptFile, piPath, config.execution.maxParallel > 0 ? [] : []); + + const durationMs = Date.now() - startMs; + + if (result.code !== 0) { + return { + success: false, + error: result.stderr || `pi exited with code ${result.code}`, + durationMs, + }; + } + + // Extract output text + const output = extractTextFromEvent(result.stdout); + + // Extract reflection + const reflection = extractReflection(output, task.id, task.title); + + return { + success: true, + reflection, + durationMs, + }; +} + +// ─── Execute Batch ─────────────────────────────────────────────────────────── + +/** + * Execute a batch of tasks (sequentially or in parallel) + */ +export async function executeBatch( + batchIndex: number, + tasks: Task[], + project: Project, + config: RalphConfig, + progress: ProgressTracker, +): Promise { + console.log(`\n[ralph] === Batch ${batchIndex + 1} (${tasks.length} task${tasks.length > 1 ? "s" : ""}) ===`); + + // For now, execute sequentially (parallel support requires more complex event handling) + for (const task of tasks) { + await executeTask(task, project, config, progress); + } +} + +// ─── Execute Single Task with Retry ────────────────────────────────────────── + +async function executeTask( + task: Task, + project: Project, + config: RalphConfig, + progress: ProgressTracker, +): Promise { + const maxRetries = config.execution.maxRetries; + let retries = 0; + + while (retries <= maxRetries) { + try { + // Mark as in progress + progress.markInProgress(task.id); + + // Get dependency reflections + const depReflections = progress.getDependencyReflections( + task.dependencies || [], + ); + + // Run the task + const result = await runTask(task, project, config, depReflections); + + if (result.success) { + // Save reflection + if (result.reflection) { + 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)}`); + 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}`, + ); + + // Exponential backoff + const delay = config.execution.retryDelayMs * Math.pow(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) { + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + throw error; + } + } +} + +// ─── Save Reflection to File ──────────────────────────────────────────────── + +function saveReflectionToFile( + sourceDir: string, + config: RalphConfig, + reflection: Reflection, +): void { + const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir); + ensureDir(reflectionsDir); + const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`); + writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + 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`; + } + return `${seconds}s`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fa54442 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,187 @@ +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 new file mode 100644 index 0000000..f87787d --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,273 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Task, Project } from "./types"; + +// ─── Main Entry ────────────────────────────────────────────────────────────── + +/** + * Parse a task file (markdown or YAML) into a Project structure. + * Supports: + * - Fio README format (numbered tasks with dependency graph) + * - Simple checkbox format (- [ ] task) + * - 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); + + 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); +} + +// ─── Fio Format Parser ─────────────────────────────────────────────────────── + +function hasDependenciesSection(content: string): boolean { + 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; + + 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 (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()); + } + } + + // 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; + + 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++; + } + } + + 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"); + } + + 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, + }); + }); + } + + return { + tasks, + dependencies: doc.dependencies || {}, + sourcePath, + sourceDir, + exitCriteria: doc.exit_criteria || doc.exitCriteria, + objective: doc.objective, + }; +} + +// ─── Task Spec Reader ──────────────────────────────────────────────────────── + +/** + * 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"); +} + +// ─── Task File Updater ─────────────────────────────────────────────────────── + +/** + * 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); + + // 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 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"); + } +} + +// ─── Auto-Detect Dependencies ──────────────────────────────────────────────── + +/** + * 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]) + ); + + 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); + } + } + } + } + + const dependencies: Record = {}; + for (const task of tasks) { + if (task.dependencies.length > 0) { + dependencies[task.id] = task.dependencies; + } + } + + return { ...project, tasks, dependencies }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +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"; + } +} + +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 "-"; + } +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/progress.ts b/src/progress.ts new file mode 100644 index 0000000..1cedeee --- /dev/null +++ b/src/progress.ts @@ -0,0 +1,148 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ProgressState, Task, Reflection } from "./types"; +import { ensureDir } from "./utils"; + +/** + * Manages persistent progress state for a ralph execution. + * State is stored as JSON in .ralph/progress.json + */ +export class ProgressTracker { + private statePath: string; + private state: ProgressState; + + constructor(projectDir: string, sourcePath: string) { + const stateDir = path.join(projectDir, ".ralph"); + ensureDir(stateDir); + this.statePath = path.join(stateDir, "progress.json"); + this.state = this.loadOrCreate(sourcePath); + } + + /** Load existing state or create a fresh one */ + private loadOrCreate(sourcePathHint: string): ProgressState { + if (fs.existsSync(this.statePath)) { + try { + const raw = fs.readFileSync(this.statePath, "utf-8"); + return JSON.parse(raw) as ProgressState; + } catch { + // Fall through to create new + } + } + return { + sourcePath: sourcePathHint, + tasks: {}, + startedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + paused: false, + }; + } + + /** Save current state to disk */ + save(): void { + this.state.lastUpdatedAt = new Date().toISOString(); + fs.writeFileSync( + this.statePath, + JSON.stringify(this.state, null, 2), + "utf-8", + ); + } + + /** 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(); + this.save(); + } + + /** Mark a task as completed */ + markCompleted( + taskId: string, + durationMs: number, + reflection?: Reflection, + ): 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; + } + 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; + this.save(); + } + + /** Get task status */ + getTaskStatus(taskId: string): Task["status"] { + return this.state.tasks[taskId]?.status ?? "pending"; + } + + /** Get IDs of all completed tasks */ + getCompletedTaskIds(): string[] { + return Object.entries(this.state.tasks) + .filter(([, info]) => info.status === "completed") + .map(([id]) => id); + } + + /** Get all reflections from completed tasks */ + getAllReflections(): Reflection[] { + const reflections: Reflection[] = []; + for (const info of Object.values(this.state.tasks)) { + if (info.reflection) { + reflections.push(info.reflection); + } + } + return reflections; + } + + /** Get reflections for specific dependency tasks */ + getDependencyReflections(depIds: string[]): Reflection[] { + return depIds + .map((id) => this.state.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++; + this.save(); + return this.state.tasks[taskId].retries; + } + + /** Set paused state */ + setPaused(paused: boolean): void { + this.state.paused = paused; + this.save(); + } + + /** Get the raw state (for status display) */ + getState(): ProgressState { + return this.state; + } + + /** Reset all progress */ + reset(): void { + this.state = { + sourcePath: this.state.sourcePath, + tasks: {}, + startedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + paused: false, + }; + this.save(); + } + + private ensureTask(taskId: string): void { + if (!this.state.tasks[taskId]) { + this.state.tasks[taskId] = { status: "pending", retries: 0 }; + } + } +} diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..577a162 --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,174 @@ +import type { Task, Project, Reflection } from "./types"; +import { readTaskSpec } from "./parser"; + +// ─── Task Prompt ───────────────────────────────────────────────────────────── + +/** + * Build the prompt for a single task execution. + * Injects task details, dependency reflections, and project context. + */ +export function buildTaskPrompt( + task: Task, + project: Project, + depReflections: Reflection[], + projectContext?: string, +): string { + const parts: string[] = []; + + // ── Header ── + + parts.push(`# Task ${task.id}: ${task.title}`); + parts.push(""); + + // ── Project Objective ── + + if (project.objective) { + parts.push("## Project Objective"); + parts.push(project.objective); + parts.push(""); + } + + // ── Exit Criteria ── + + if (project.exitCriteria && project.exitCriteria.length > 0) { + parts.push("## Exit Criteria"); + for (const criterion of project.exitCriteria) { + parts.push(`- ${criterion}`); + } + parts.push(""); + } + + // ── Task Description ── + + if (task.description) { + parts.push("## Description"); + parts.push(task.description); + parts.push(""); + } + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Dependencies ── + + if (task.dependencies && task.dependencies.length > 0) { + parts.push("## Dependencies"); + parts.push(`This task depends on: ${task.dependencies.join(", ")}`); + parts.push(""); + } + + // ── Dependency Reflections ── + + if (depReflections.length > 0) { + parts.push("## Completed Dependency Reflections"); + parts.push( + "The following tasks have been completed. Use their reflections for context:", + ); + parts.push(""); + + for (const ref of depReflections) { + parts.push(`### Task ${ref.taskId}: ${ref.title}`); + parts.push(`**Summary:** ${ref.summary}`); + + if (ref.keyLearnings && ref.keyLearnings.length > 0) { + parts.push("**Key Learnings:**"); + for (const learning of ref.keyLearnings) { + parts.push(`- ${learning}`); + } + } + + if (ref.filesChanged && ref.filesChanged.length > 0) { + parts.push(`**Files Changed:** ${ref.filesChanged.join(", ")}`); + } + + if (ref.blockers && ref.blockers.length > 0) { + parts.push(`**Known Issues:** ${ref.blockers.join("; ")}`); + } + + parts.push(""); + } + } + + // ── Project Context ── + + if (projectContext) { + parts.push("## Additional Context"); + parts.push(projectContext); + parts.push(""); + } + + // ── Reflection Instructions ── + + parts.push("## REFLECTION (REQUIRED)"); + parts.push( + "When the task is COMPLETE, end your response with a reflection section.", + ); + parts.push("Use EXACTLY this format at the END of your response:"); + parts.push(""); + parts.push("```"); + parts.push("## REFLECTION"); + parts.push("SUMMARY: [1-2 sentence description of what was accomplished]"); + parts.push("FILES: [comma-separated list of files created or modified]"); + parts.push("LEARNINGS:"); + parts.push("- [key decision, pattern, or architectural choice]"); + parts.push("- [important API or interface details]"); + parts.push("- [anything downstream tasks need to know]"); + parts.push("BLOCKERS: [any unresolved issues, or 'none']"); + parts.push("```"); + parts.push(""); + parts.push( + "Also use the `memory` tool to save important learnings that will", + ); + parts.push( + "be useful across future sessions (architecture decisions, API patterns, etc.)", + ); + + return parts.join("\n"); +} + +// ─── Plan Prompt ───────────────────────────────────────────────────────────── + +/** + * Build the prompt for a dry-run / plan display + */ +export function buildPlanPrompt(project: Project): string { + const lines: string[] = []; + + lines.push("# Project Plan"); + lines.push(""); + + if (project.objective) { + lines.push("## Objective"); + lines.push(project.objective); + lines.push(""); + } + + lines.push("## Tasks"); + for (const task of project.tasks) { + const deps = task.dependencies.length > 0 + ? ` (depends on: ${task.dependencies.join(", ")})` + : ""; + lines.push(`- [ ] ${task.id}: ${task.title}${deps}`); + } + lines.push(""); + + if (project.exitCriteria && project.exitCriteria.length > 0) { + lines.push("## Exit Criteria"); + for (const criterion of project.exitCriteria) { + lines.push(`- ${criterion}`); + } + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/reflection.ts b/src/reflection.ts new file mode 100644 index 0000000..e715f94 --- /dev/null +++ b/src/reflection.ts @@ -0,0 +1,128 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Reflection } from "./types"; +import { REFLECTION_PATTERN } from "./constants"; +import { ensureDir, writeFileSafe } from "./utils"; + +// ─── Extract Reflection ────────────────────────────────────────────────────── + +/** + * Extract a reflection block from pi's output text + */ +export function extractReflection( + output: string, + taskId: string, + title: string, +): Reflection | null { + const match = output.match(REFLECTION_PATTERN); + if (!match) return null; + + const block = match[1]; + const summary = extractField(block, "SUMMARY"); + const files = extractField(block, "FILES"); + const learnings = extractList(block, "LEARNINGS"); + const blockersRaw = extractField(block, "BLOCKERS"); + + const blockers = + blockersRaw && blockersRaw.toLowerCase() !== "none" + ? blockersRaw.split(",").map(b => b.trim()).filter(Boolean) + : undefined; + + return { + taskId, + title, + summary: summary || "Task completed", + keyLearnings: learnings || [], + filesChanged: files + ? files.split(",").map(f => f.trim()).filter(Boolean) + : [], + blockers, + timestamp: new Date().toISOString(), + }; +} + +function extractField(block: string, field: string): string | null { + const regex = new RegExp(`${field}:\\s*(.+?)$`, "im"); + const match = block.match(regex); + return match ? match[1].trim() : null; +} + +function extractList(block: string, field: string): string[] | null { + const regex = new RegExp(`${field}:\\s*\\n((?:- .+\\n?)+)`, "im"); + const match = block.match(regex); + if (!match) return null; + return match[1] + .split("\n") + .map(l => l.replace(/^-\\s*/, "").trim()) + .filter(Boolean); +} + +// ─── Save / Load Reflections ──────────────────────────────────────────────── + +/** + * Save a reflection to a file + */ +export function saveReflection( + reflectionsDir: string, + reflection: Reflection, +): void { + ensureDir(reflectionsDir); + const filePath = path.join( + reflectionsDir, + `${reflection.taskId}.json`, + ); + writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); +} + +/** + * Load a reflection from a file + */ +export function loadReflection( + reflectionsDir: string, + taskId: string, +): Reflection | null { + const filePath = path.join(reflectionsDir, `${taskId}.json`); + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Reflection; + } catch { + return null; + } +} + +// ─── Format Reflections ────────────────────────────────────────────────────── + +/** + * Format reflections for display + */ +export function formatReflections(reflections: Reflection[]): string { + if (reflections.length === 0) return "No reflections yet."; + + const lines: string[] = []; + lines.push("## Task Reflections"); + lines.push(""); + + for (const ref of reflections) { + lines.push(`### ${ref.taskId}: ${ref.title}`); + lines.push(`Summary: ${ref.summary}`); + + if (ref.keyLearnings.length > 0) { + lines.push("Learnings:"); + for (const l of ref.keyLearnings) { + lines.push(` - ${l}`); + } + } + + if (ref.filesChanged.length > 0) { + lines.push(`Files: ${ref.filesChanged.join(", ")}`); + } + + if (ref.blockers && ref.blockers.length > 0) { + lines.push(`Blockers: ${ref.blockers.join("; ")}`); + } + + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..89edfff --- /dev/null +++ b/src/types.ts @@ -0,0 +1,136 @@ +// ─── Task Model ─────────────────────────────────────────────────────────────── + +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; +} + +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; +} + +// ─── Execution Plan ─────────────────────────────────────────────────────────── + +export interface ExecutionBatch { + /** 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[]; +} + +// ─── 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; +} + +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; +} + +// ─── 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; + }; +} + +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: "", + }, +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6ab7b6d --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,279 @@ +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 { DEFAULT_CONFIG } from "./types"; + +// ─── Directory Helpers ─────────────────────────────────────────────────────── + +/** + * Ensure a directory exists, creating it recursively if needed + */ +export function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +/** + * Write file content, creating parent directories if needed + */ +export function writeFileSafe(filePath: string, content: string): void { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, "utf-8"); +} + +// ─── Command Helpers ───────────────────────────────────────────────────────── + +/** + * Check if a command exists in PATH + */ +export function commandExists(command: string): boolean { + try { + const { execSync } = require("node:child_process"); + execSync(`which ${command}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * 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; + } + + // 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.", + ); +} + +// ─── Config ────────────────────────────────────────────────────────────────── + +function parseSimpleYaml(content: string): Record { + const result: Record = {}; + const lines = content.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const match = trimmed.match(/^([^:]+):\s*(.+)$/); + if (match) { + const key = match[1].trim(); + let value = match[2].trim(); + + // Parse booleans + if (value === "true") value = true; + else if (value === "false") value = false; + // Parse numbers + else if (/^\d+$/.test(value)) value = parseInt(value, 10); + else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value); + + result[key] = value; + } + } + + return result; +} + +/** + * Deep merge configuration objects + */ +function mergeConfig( + defaults: RalphConfig, + overrides: Record, +): RalphConfig { + const result = { ...defaults }; + + for (const [key, value] of Object.entries(overrides)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + (result as any)[key] = { ...(defaults as any)[key], ...value }; + } else { + (result as any)[key] = value; + } + } + + return result as RalphConfig; +} + +/** + * Load configuration from .ralph/config.yaml or return defaults + */ +export function loadConfig(projectDir: string): RalphConfig { + const configPath = path.join(projectDir, ".ralph", "config.yaml"); + + 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); + return { ...DEFAULT_CONFIG }; + } +} + +// ─── Task Resolution ───────────────────────────────────────────────────────── + +/** + * Resolve a task argument to a file path + */ +export function resolveTaskArg( + arg: string, + cwd: string, +): string { + const candidates = [ + path.resolve(cwd, arg), + path.resolve(cwd, arg + ".md"), + path.resolve(cwd, arg + ".yaml"), + path.resolve(cwd, arg + ".yml"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + // 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; + } + + throw new Error( + `Task file not found: ${arg}\nSearched: ${candidates.join("\n ")}`, + ); +} + +// ─── Formatting ────────────────────────────────────────────────────────────── + +/** + * Format duration in milliseconds to human-readable string + */ +export 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`; +} + +/** + * Format progress status for display + */ +export function formatProgressStatus(state: ProgressState): string { + const lines: string[] = []; + const tasks = state.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; + + lines.push("## Progress"); + lines.push(""); + 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" ? "[!]" : + "[ ]"; + + const duration = info.durationMs + ? ` (${formatDuration(info.durationMs)})` + : ""; + + lines.push(`- ${statusIcon} ${id}${duration}`); + + if (info.error) { + lines.push(` Error: ${info.error}`); + } + } + + lines.push(""); + lines.push(`Started: ${state.startedAt}`); + lines.push(`Updated: ${state.lastUpdatedAt}`); + lines.push(`Paused: ${state.paused ? "yes" : "no"}`); + + return lines.join("\n"); +} + +// ─── Pi Subprocess ─────────────────────────────────────────────────────────── + +/** + * Spawn a pi subprocess with the given prompt file + */ +export function spawnPi( + promptFile: string, + piPath: string, + args?: string[], +): { stdout: string; stderr: string; code: number | null } { + const spawnArgs = ["--prompt", promptFile, ...(args || [])]; + + const result = spawnSync(piPath, spawnArgs, { + encoding: "utf-8", + timeout: 60 * 60 * 1000, // 1 hour + maxBuffer: 10 * 1024 * 1024, // 10MB + }); + + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + code: result.status, + }; +} + +/** + * 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[] = []; + + 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); + } + } + + return texts.join("\n"); + } + + return output; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}