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