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
This commit is contained in:
2026-05-30 01:26:17 -04:00
commit 81e0e8ec1c
14 changed files with 1972 additions and 0 deletions

83
README.md Normal file
View File

@@ -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

17
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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']
```

24
src/constants.ts Normal file
View File

@@ -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;

296
src/dag.ts Normal file
View File

@@ -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<string>,
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<string>,
): 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<string, Task>,
completed: Set<string>,
): 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<string, Task>,
completed: Set<string>,
): ExecutionBatch[] {
const groups = new Map<number, Task[]>();
for (const task of pendingTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
}
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<string, string[]>();
for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []);
}
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
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<string>,
): Task[] {
return project.tasks.filter(task => {
if (completed.has(task.id)) return false;
const deps = task.dependencies || [];
return deps.every(dep => completed.has(dep));
});
}
// ─── 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<string, number>();
const prev = new Map<string, string | null>();
// Initialize
for (const task of project.tasks) {
dist.set(task.id, 1);
prev.set(task.id, null);
}
// Topological sort
const sorted: Task[] = [];
const visited = new Set<string>();
function visit(id: string) {
if (visited.has(id)) return;
visited.add(id);
const task = taskMap.get(id);
if (!task) return;
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");
}

174
src/executor.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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`;
}

187
src/index.ts Normal file
View File

@@ -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<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
// Show plan
const planPrompt = buildPlanPrompt(project);
const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan);
return `${planPrompt}\n\n${formatted}`;
}
// ─── /ralph run ──────────────────────────────────────────────────────────────
async function handleRun(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
// Build execution plan
const completed = new Set(progress.getCompletedTaskIds());
const plan = buildExecutionPlan(project, completed);
// Execute batches
for (const batch of plan.batches) {
// Check if paused
if (progress.getState().paused) {
return `Execution paused. Use /ralph resume to continue.`;
}
await executeBatch(
batch.batchIndex,
batch.tasks,
project,
config,
progress,
);
// Update task file
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
// Final status
const state = progress.getState();
const output = formatProgressStatus(state);
// Show reflections
const reflections = progress.getAllReflections();
if (reflections.length > 0) {
return `${output}\n\n${formatReflections(reflections)}`;
}
return output;
}
// ─── /ralph status ───────────────────────────────────────────────────────────
async function handleStatus(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
return formatProgressStatus(progress.getState());
}
// ─── /ralph resume ───────────────────────────────────────────────────────────
async function handleResume(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
// Unpause
progress.setPaused(false);
// Get remaining batches
const completed = new Set(progress.getCompletedTaskIds());
const plan = buildExecutionPlan(project, completed);
// Execute remaining batches
for (const batch of plan.batches) {
await executeBatch(
batch.batchIndex,
batch.tasks,
project,
config,
progress,
);
// Update task file
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
}
return formatProgressStatus(progress.getState());
}
// ─── /ralph next ─────────────────────────────────────────────────────────────
async function handleNext(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const project = parseTaskFile(taskFile);
const config = loadConfig(process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
const completed = new Set(progress.getCompletedTaskIds());
const ready = getReadyTasks(project, completed);
if (ready.length === 0) {
return "No tasks ready to execute. All tasks completed or blocked.";
}
// Execute just the next batch (first ready tasks)
const nextBatch = ready.slice(0, config.execution.maxParallel || ready.length);
for (const task of nextBatch) {
await executeBatch(
0,
[task],
project,
config,
progress,
);
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
}
return `Executed: ${nextBatch.map(t => t.id).join(", ")}\n\n${formatProgressStatus(progress.getState())}`;
}
// ─── /ralph reset ────────────────────────────────────────────────────────────
async function handleReset(context: ExtensionContext, args: string[]): Promise<string> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
const progress = new ProgressTracker(process.cwd(), taskFile);
progress.reset();
return "Progress reset. All task statuses cleared.";
}

273
src/parser.ts Normal file
View File

@@ -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<string, string[]> = {};
let inTasks = false;
let inDeps = false;
for (const line of lines) {
if (/^##\s+Tasks\s*$/m.test(line)) {
inTasks = true;
inDeps = false;
continue;
}
if (/^##\s+Dependencies\s*$/m.test(line)) {
inTasks = false;
inDeps = true;
continue;
}
if (/^##\s/.test(line) && !/^##\s+Tasks/.test(line) && !/^##\s+Dependencies/.test(line)) {
inTasks = false;
inDeps = false;
continue;
}
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<string, string[]> = {};
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, "\\$&");
}

148
src/progress.ts Normal file
View File

@@ -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 };
}
}
}

174
src/prompts.ts Normal file
View File

@@ -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");
}

128
src/reflection.ts Normal file
View File

@@ -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");
}

136
src/types.ts Normal file
View File

@@ -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<string, string[]>;
/** Exit criteria (from README ## Exit Criteria section) */
exitCriteria?: string[];
/** Path to the source task file */
sourcePath: string;
/** Directory containing the source file */
sourceDir: string;
}
// ─── Execution Plan ───────────────────────────────────────────────────────────
export interface ExecutionBatch {
/** Tasks that can run concurrently in this batch */
tasks: Task[];
/** Batch number (0-indexed) */
batchIndex: number;
}
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<string, {
status: Task["status"];
startedAt?: string;
completedAt?: string;
retries: number;
durationMs?: number;
reflection?: Reflection;
error?: string;
}>;
/** When execution started */
startedAt: string;
/** When execution last updated */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped */
paused: boolean;
}
// ─── 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: "",
},
};

279
src/utils.ts Normal file
View File

@@ -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<string, any> {
const result: Record<string, any> = {};
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<string, any>,
): 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;
}

19
tsconfig.json Normal file
View File

@@ -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"]
}