This commit is contained in:
2026-05-30 19:37:17 -04:00
parent 81e0e8ec1c
commit e6a8c8bedc
19 changed files with 2393 additions and 858 deletions

View File

@@ -1,8 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
import type { RalphConfig, ProgressState, Task } from "./types";
import type {
RalphConfig,
PRDProgress,
ProgressState,
ToolUsage,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
// ─── Directory Helpers ───────────────────────────────────────────────────────
@@ -23,39 +34,50 @@ export function writeFileSafe(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, "utf-8");
}
// ─── Command Helpers ─────────────────────────────────────────────────────────
// ─── Async Agent Session ────────────────────────────────────────────────────
// ─── Progress Discovery ─────────────────────────────────────────────────────
/**
* Check if a command exists in PATH
* Find the nearest .ralph/progress.json by walking up from the given directory.
* For a specific sourcePath, finds the matching PRD entry.
*/
export function commandExists(command: string): boolean {
try {
const { execSync } = require("node:child_process");
execSync(`which ${command}`, { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export function findProgressFile(
startDir: string,
sourcePath?: string,
): { path: string; state: ProgressState; prdKey?: string } | null {
let current = path.resolve(startDir);
const root = path.parse(current).root;
/**
* Get the path to the pi executable
*/
export function getPiPath(): string {
// Check if PI_PATH environment variable is set
const envPath = process.env.PI_PATH;
if (envPath && fs.existsSync(envPath)) {
return envPath;
while (current !== root) {
const candidate = path.join(current, ".ralph", "progress.json");
if (fs.existsSync(candidate)) {
try {
const raw = fs.readFileSync(candidate, "utf-8");
const state = JSON.parse(raw) as ProgressState;
// If looking for a specific source path, find matching PRD
if (sourcePath && state.prds) {
const resolvedSource = path.resolve(sourcePath);
for (const [key, prd] of Object.entries(state.prds)) {
if (path.resolve(prd.sourcePath) === resolvedSource) {
return { path: candidate, state, prdKey: key };
}
}
// No matching PRD found, continue walking up
current = path.dirname(current);
continue;
}
return { path: candidate, state };
} catch {
return null;
}
}
current = path.dirname(current);
}
// Try to find pi in PATH
if (commandExists("pi")) {
return "pi";
}
throw new Error(
"pi executable not found. Set PI_PATH or ensure pi is in PATH.",
);
return null;
}
// ─── Config ──────────────────────────────────────────────────────────────────
@@ -71,7 +93,7 @@ function parseSimpleYaml(content: string): Record<string, any> {
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
let value: string | boolean | number = match[2].trim();
// Parse booleans
if (value === "true") value = true;
@@ -113,13 +135,18 @@ function mergeConfig(
export function loadConfig(projectDir: string): RalphConfig {
const configPath = path.join(projectDir, ".ralph", "config.yaml");
// Return defaults silently when config file does not exist
if (!fs.existsSync(configPath)) {
return { ...DEFAULT_CONFIG };
}
try {
const content = fs.readFileSync(configPath, "utf-8");
// Simple YAML parsing (key: value format)
const config = parseSimpleYaml(content);
return mergeConfig(DEFAULT_CONFIG, config);
} catch (error) {
console.warn("Failed to load .ralph/config.yaml, using defaults:", error);
} catch {
// Malformed config — fall back to defaults silently
return { ...DEFAULT_CONFIG };
}
}
@@ -127,17 +154,18 @@ export function loadConfig(projectDir: string): RalphConfig {
// ─── Task Resolution ─────────────────────────────────────────────────────────
/**
* Resolve a task argument to a file path
* Resolve a task argument to a file path.
* Strips leading `@` (from autocomplete) before resolution.
*/
export function resolveTaskArg(
arg: string,
cwd: string,
): string {
export function resolveTaskArg(arg: string, cwd: string): string {
// Strip leading @ from autocomplete
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
const candidates = [
path.resolve(cwd, arg),
path.resolve(cwd, arg + ".md"),
path.resolve(cwd, arg + ".yaml"),
path.resolve(cwd, arg + ".yml"),
path.resolve(cwd, cleanArg),
path.resolve(cwd, cleanArg + ".md"),
path.resolve(cwd, cleanArg + ".yaml"),
path.resolve(cwd, cleanArg + ".yml"),
];
for (const candidate of candidates) {
@@ -145,13 +173,17 @@ export function resolveTaskArg(
}
// Try looking for README.md in the arg directory
if (fs.statSync(path.resolve(cwd, arg)).isDirectory()) {
const readme = path.resolve(cwd, arg, "README.md");
if (fs.existsSync(readme)) return readme;
try {
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
const readme = path.resolve(cwd, cleanArg, "README.md");
if (fs.existsSync(readme)) return readme;
}
} catch {
// Directory doesn't exist, fall through to error
}
throw new Error(
`Task file not found: ${arg}\nSearched: ${candidates.join("\n ")}`,
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
);
}
@@ -175,33 +207,38 @@ export function formatDuration(ms: number): string {
}
/**
* Format progress status for display
* Format progress status for display. Accepts a single PRDProgress entry.
*/
export function formatProgressStatus(state: ProgressState): string {
export function formatProgressStatus(state: PRDProgress): string {
const lines: string[] = [];
const tasks = state.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
t => t.status === "completed",
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
t => t.status === "failed",
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
t => t.status === "in_progress",
(t) => t.status === "in_progress",
).length;
lines.push("## Progress");
lines.push("");
lines.push(`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`);
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed" ? "[x]" :
info.status === "in_progress" ? "[~]" :
info.status === "failed" ? "[!]" :
"[ ]";
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
@@ -222,58 +259,274 @@ export function formatProgressStatus(state: ProgressState): string {
return lines.join("\n");
}
// ─── Pi Subprocess ───────────────────────────────────────────────────────────
/**
* Spawn a pi subprocess with the given prompt file
* Format progress status for all PRDs in a ProgressState.
*/
export function spawnPi(
promptFile: string,
piPath: string,
args?: string[],
): { stdout: string; stderr: string; code: number | null } {
const spawnArgs = ["--prompt", promptFile, ...(args || [])];
export function formatAllPRDsStatus(state: ProgressState): string {
const prds = state.prds;
if (!prds || Object.keys(prds).length <= 1) {
// Single PRD — use simple format
const prd = prds
? Object.values(prds)[0]
: (state as unknown as PRDProgress);
return formatProgressStatus(prd);
}
const result = spawnSync(piPath, spawnArgs, {
encoding: "utf-8",
timeout: 60 * 60 * 1000, // 1 hour
maxBuffer: 10 * 1024 * 1024, // 10MB
});
const lines: string[] = [];
lines.push("## Progress (all PRDs)");
lines.push("");
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
code: result.status,
};
}
for (const [key, prd] of Object.entries(prds)) {
const tasks = prd.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
/**
* Extract text content from pi event stream output
*/
export function extractTextFromEvent(output: string): string {
// If output is JSON event stream, extract text fields
if (output.startsWith("{") || output.startsWith("data:")) {
const lines = output.split("\n");
const texts: string[] = [];
lines.push(`### ${key}`);
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const line of lines) {
// Try to parse NDJSON events
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6));
if (event.type === "text" && event.text) {
texts.push(event.text);
}
} catch {
texts.push(line.slice(6));
}
} else if (line.trim()) {
texts.push(line);
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
: "";
lines.push(`- ${statusIcon} ${id}${duration}`);
if (info.error) {
lines.push(` Error: ${info.error}`);
}
}
return texts.join("\n");
lines.push("");
}
return output;
return lines.join("\n");
}
// ─── Async Agent Session ────────────────────────────────────────────────────
/**
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
*
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
* this uses createAgentSession from the Pi SDK, keeping the event loop
* responsive and allowing progress updates during task execution.
*/
export async function runAgentSession(
taskPrompt: string,
cwd: string,
timeoutMs: number,
onEvent?: (event: AgentSessionEvent) => void,
signal?: AbortSignal,
): Promise<{
success: boolean;
text: string;
error?: string;
toolUsage: ToolUsage;
stopReason?: string;
events: AgentSessionEvent[];
}> {
const toolUsage: ToolUsage = {
read: 0,
write: 0,
edit: 0,
bash: 0,
other: 0,
};
const recordedEvents: AgentSessionEvent[] = [];
// Wire timeout via abort signal
const timeoutHandle = setTimeout(() => {
if (sessionRef?.session) sessionRef.session.agent.abort();
}, timeoutMs);
const sessionRef: {
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
} = {};
try {
const loader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
noExtensions: true,
noSkills: false,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload();
const result = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(),
resourceLoader: loader,
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
});
sessionRef.session = result.session;
// Wire external abort signal
const abortHandler = () => result.session.agent.abort();
signal?.addEventListener("abort", abortHandler, { once: true });
let finalText = "";
let errorMessage: string | undefined;
let stopReason: string | undefined;
const unsubscribe = result.session.subscribe((event) => {
recordedEvents.push(event);
onEvent?.(event);
if (event.type === "message_end") {
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
if (message.role !== "assistant") return;
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
const text = extractAssistantText(message.content);
if (text) finalText = text;
}
if (event.type === "tool_execution_start") {
const name = event.toolName;
if (name in toolUsage) {
(toolUsage as unknown as Record<string, number>)[name]++;
} else {
toolUsage.other++;
}
}
});
if (signal?.aborted) throw new Error("Aborted before prompt");
await result.session.prompt(taskPrompt);
await result.session.agent.waitForIdle();
unsubscribe();
result.session.dispose();
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutHandle);
if (errorMessage && !finalText) {
return {
success: false,
text: "",
error: errorMessage,
toolUsage,
stopReason,
events: recordedEvents,
};
}
return {
success: true,
text: finalText.trim(),
toolUsage,
stopReason,
events: recordedEvents,
};
} catch (error) {
clearTimeout(timeoutHandle);
return {
success: false,
text: "",
error: error instanceof Error ? error.message : String(error),
toolUsage,
events: recordedEvents,
};
} finally {
sessionRef.session?.dispose();
}
}
/**
* Extract assistant text from message content (text blocks only).
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(c): c is { type: string; text?: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: string }).type === "text",
)
.map((c) => (c as { text?: string }).text ?? "")
.join("");
}
// ─── Git Commit Capture ──────────────────────────────────────────────────────
/**
* Capture recent git commits made during task execution
* Returns commit messages and a summary string
*/
export function captureGitCommits(projectDir: string): {
commitMessages: string[];
commitSummary: string;
} {
const { execSync } = require("node:child_process");
try {
// Check if this is a git repo
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
} catch {
return { commitMessages: [], commitSummary: "" };
}
const commitMessages: string[] = [];
let commitSummary = "";
try {
// Get recent commits (last 5) with short hash and subject
const output = execSync("git log --oneline -5 --no-decorate", {
cwd: projectDir,
encoding: "utf-8",
}).trim();
if (output) {
const lines = output.split("\n").filter((l: string) => l.trim());
for (const line of lines) {
// Format: "abc1234 Commit message"
const parts = line.split(" ", 2);
if (parts.length >= 2) {
commitMessages.push(parts[1]);
}
}
// Build summary from commit subjects
commitSummary = commitMessages.slice(0, 3).join("; ");
if (commitMessages.length > 3) {
commitSummary += ` (+${commitMessages.length - 3} more)`;
}
}
} catch {
// Git command failed, return empty
}
return { commitMessages, commitSummary };
}