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:
83
README.md
Normal file
83
README.md
Normal 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
17
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
skills/ralph-task/SKILL.md
Normal file
34
skills/ralph-task/SKILL.md
Normal 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
24
src/constants.ts
Normal 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
296
src/dag.ts
Normal 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
174
src/executor.ts
Normal 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
187
src/index.ts
Normal 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
273
src/parser.ts
Normal 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
148
src/progress.ts
Normal 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
174
src/prompts.ts
Normal 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
128
src/reflection.ts
Normal 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
136
src/types.ts
Normal 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
279
src/utils.ts
Normal 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
19
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user